记划词模块重构感受



对本文有任何问题,可加我的个人微信询问:kymjs666

开源代码要慎用,容易中毒
先说感受再看看我是怎么中毒以及怎么解毒的。
何为中毒,并不是说性能多么差,也不是代码多么烂,而是你容易受到别人代码的影响,不知不觉间就顺着他的思路走了。
当然,有一种避免的办法就是,拿来主义。我只拿你的代码用,完全不看你怎么写的,也不做功能定制和扩展,那当然也就百毒不侵。

开源实验室-Android划词

事情的经过是这样的(我要开始讲故事了)。

很久很久以前,天地混沌,盘古开天辟地以后有了太阳和月亮,天空和大地,Android 操作系统也随之崛起。但是,还缺少一样东西,那就是自定义控件。有一天,我奉众神之王宙斯之命创建一个通用划词模块,让每条产线都接入这个控件。 何为通用划词模块,就是要通用,要有划词,还是个模块。
😂😂😂 扯不下去了,你们自己看图识意吧。

开源实验室-Android划词开源实验室-Android划词开源实验室-Android划词

中毒开始

就是这样两个效果,点按选中文字高亮,并弹出悬浮窗。
这种控件,偷个懒吧,去 GitHub 上找找,这一找,就成了我中毒的,开始。为了不坑大家,我就不说我找的那个项目地址了。

看了代码,那个项目是这样来做的:在 TextView 长按下的时候,通过getOffsetForPosition()来获取到当前点击坐标最近的一个字符在全部文本的第几个位置,以及layout.getPrimaryHorizontal()来根据一个位置获取到这个位置的字符在 View 内部的坐标。然后在这个文本相应的位置显示一个悬浮窗,这个悬浮窗是一个自定义 View,里面有一个 PopupWindow ,在 PopupWindow 里面自定义了一个布局显示自己的内容。
结合我们自己的逻辑,原本网上的开源项目只有一个悬浮窗,而我们自己的业务需要显示三个悬浮窗,分别是:数据加载中的样子、正常显示翻译内容的样子,找不到翻译内容的样子。
既然已经有了一个雏形了,那就在这个项目上做扩展吧。(从有这个想法开始,就跌入了一个大大的深坑)

慢性中毒

扩展的方法就是仿照原有的写法,再自定义两个悬浮窗,然后根据显示逻辑来切换什么时候应该显示哪个悬浮窗。好不容易做好了三种状态要显示的悬浮窗都做好了,又发现长按的时候操作菜单和游标也需要显示在正确的位置上。
那再改改,根据长按的坐标,找到对应的文本在 TextView 第几个字,找到这个字在第几行,找到这行文字的顶部坐标再减去行间距,再把悬浮操作菜单。原项目为了方便直接获取到 TextView 的边界值,直接在 TextView 的外层套了一个 Scrollview,方便实时获取到 TextView 的坐标。
Android, kotlin, 开源划词

结果又发现如果 TextView 在一个 Scrollview 里面的时候,如果 Scrollview 发生滚动,悬浮窗应该自动 dismiss;
那再改改,滚动状态获取不到啊,那不如让 TextView 在初始化的时候递归遍历父控件,如果是可以滚动的控件就给这个控件添加一个滚动状态监听器,发生滚动直接 dismiss 悬浮窗。
至此,一个划词模块的开发是完成了,功能表现也良好。

中毒太深

我靠,这通用划词模块根本不通用啊,谁特么也不知道业务线接入时候的环境是怎样的。

  1. 你控件使用的是自定义控件,可业务线有可能自己想使用划词功能的控件也是个自定义的 TextView,那没办法让一个 Java 类同时继承两个类啊。
  2. 业务线有可能一个界面同时有多个 TextView 都要接划词功能,我们之前完全没有考虑这种情况啊。照这种状态可能每个界面同时显示多个悬浮窗出来。
  3. 每个 TextView 在使用的时候,外面都套了一个 ScrollView,这要是接入这控件的界面有多个 TextView,界面估计要卡到爆。

解毒,重构的开始

没办法,意识到自己中毒太深了以后,只想说一句,活该你他妈偷懒想用别人代码。
首先理清项目的结构。整个项目分三大块:接入控件(TextView),游标和高亮,悬浮窗。

第一步,为了控件能够通用,把接入控件抽出来做成一个接口,只暴露出该 View 有的方法,然后所有要接入划词功能的 View 都实现这个接口就好了,其中 getTouchX() 和 Y 是返回用户手指按下的坐标,需要在实现接口协议时重写 onTouch() 事件记录下坐标:

public interface IViewProtocol {
    Context getContext();
    CharSequence getText();
    CharSequence getSelectedText(); // 获取当前选中的文本
    int getTop();
    int getBottom();
    int getLeft();
    int getRight();
    float getTouchX();
    float getTouchY();
    int[] getLocationInWindow();
}

第二步:创建一个 Controller 负责控制悬浮窗的显示,并将原项目中的悬浮窗修改为自定义 PopupWindow(原项目是一个 View,包含一个 PopupWindow,又包含一个自定义布局)。 PopupWindow 最大的好处就是,它的显示逻辑和隐藏逻辑都可以交给系统去控制,就不需要我们手动再控制显示隐藏了。
定义一个接口,封装悬浮窗应该包含的方法:

public interface IFloatWindow {
    PopupWindow getFloatWindow();
    boolean isShowing();
    void showAsDropDown(IViewProtocol anchor, int xoff, int yoff, int gravity);
    void showAtLocation(IViewProtocol anchor, int x, int y, int gravity);
    void update(int x, int y, int width, int height, boolean force);
    void dismiss();
    int getHeight();
    int getWidth();
}

悬浮窗有两类,一类是非交互的,类似加载界面、游标。另一类是可交互的,例如上文截图。
不可交互的很简单,直接显示就好了,抽出公共基类 AbsFloatWindow,实现 PopupWindow 创建、初始化、显示位置等方法就够了。

可交互的需要考虑内部控件的事件,他们的内容区域是不同的,但是外部显示框框是一样的。
所以需要再多写一个基类 AbsContentView<T> extends AbsFloatWindow 其中泛型 T 表示这个悬浮窗要显示的内容实体类。例如服务器返回一段翻译好的数据给客户端,客户端要将翻译后的内容显示出来;但如果网络请求失败,应该显示另一种内容;服务器无法翻译的时候,又显示另一种内容的文本。
并且需要注意的是,有交互的控件还得要暴露出控件给业务线,让他们自己根据自己的业务修改控件的图片、文字、点击事件。

第三步:抽出 SelectionInfo,封装高亮显示的文本信息,包括文本的起始坐标,结束坐标,文本长度,高亮的背景颜色,在整个 TextView 文本的位置等。

public class SelectionInfo {
    public static final int DEFAULT_CLOLOR = 0xffb7d9f8;
    private Object mSpan; //用于显示背景颜色
    private int mStart;
    private int mEnd;
    private Spannable mSpannable;
}

最后

从改为使用 PopupWindow 开始,我们已经解决了界面中多 TextView 弹出多个悬浮窗的问题。
使用接口协议也完美的解决了业务线自定义控件的兼容性问题,不过为了他们使用方便,我们还是可以定义一个默认的 TextView 让他们选择,同时也是他们修改自己的自定义控件的一个模板。
把之前所有基于控件内部的坐标全部转换成根据View.getLocationInWindow()获取屏幕绝对坐标,也解决了嵌套一层 ScrollView 的问题。

问题解决,又可以浪了。
Android, kotlin, 开源

最后的最后

记划词模块重构感受
——开源代码要慎用,容易中毒