阅读器优化—视图优化实践

前言

随着免费小说业务快速的发展,免费小说客户端的优化也逐渐进入深水区,在2020年我们开始整体阅读器优化的调研工作。阅读器视图控件作为整个阅读器中业务交互最复杂,业务变更最频繁的组件,也是最贴近用户的组件,却面临数据UI耦合严重,广告与阅读器强耦合,代码的可维护性和可拓展性、性能都面临越来越难的境地。越来越多样化的业务视图、视频流化的广告视图,更多的业务刷新场景,多个业务模块数据视图都在阅读器中交汇,要求我们必须优化好阅读器视图,提升阅读器视图控件的性能,代码拓展性。本文主要讲述了免费小说阅读器视图控件重构的主要思路及实践经验。

概述

重构后阅读器视图控件结构如下:

ReaderView 作为整个视图控件的入口。
ReaderWidget及其相关继承类,负责构成阅读器的基础视图单元”页“,也就是ReaderView的子View。

ReaderView 、ReaderWidget继承自ViewGroup ,为了更强的拓展性、性能,减少不必要的检查、重绘、布局等行为,我们重写了从测量、布局、绘制、布局参数等等一系列的方法,其中ReaderView 还负责对外提供关于阅读器相关的监听、刷新、滑动等等的接口。

LayoutManager及其继承类负责各种翻页模式下的布局、填充、回收、预加载等操作,还负责桥接了View的一些事件的处理。
AnimationProvider及其继承类负责各种翻页模式下的动画、惯性及触摸事件的处理。
ViewHolder 作为缓存的基础单元,包含了一些基础控件及相关属性。

Adapter主要负责把数据绑定到相关视图上。这些就组成了阅读器视图控件的主干。
ReaderView.OnPageChangeListener提供阅读器视图相关滑动、切页等事件的监听。

测量

在测量方面,我们重写了测量方法 ,并且我们根据不同翻页方式重写不同的generateDefaultLayoutParams 系列方法,以便于不同翻页方式进行拓展。由于阅读器控件也就是ReaderView 宽高不会受到子控件大小的影响,所以ReaderView 本身的宽高测量只收到其父控件的影响。ReaderView的子View也就是ReaderWidget测量是在预加载,布局之前我们手动完成测量的,而且绝大部分场景是不需要重新测量的,所以ReaderView 及其直接子View大部分系统方法触发的测量是不需要我们重复触发的,甚至可以拦截的。

ReaderView

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (layoutManager != null) {
            layoutManager.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
ReaderWidget

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
            autoWrapMeasure(widthMeasureSpec, heightMeasureSpec);
        } else {
            defaultMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }

刚刚提到绝大部分场景,另外还有一部分场景是是需要重新测量的,例如ReaderView受到外界影响宽高变化的时候,我们需要重新测量ReaderView及重新布局所有的子View。

 @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (adapter == null) {
            // leave the state in START
            return;
        }
        if (layoutManager == null) {
            // leave the state in START
            return;
        }
     ...
         //根据宽高变化处理即可
     ...
        layoutManager.onLayoutChildren(mRecycler, state);
        onLayoutCompleted();
    }

还有上下翻页,我们是不限制内容高度的(未来业务拓展性),所以上下翻页每个新的数据到来我们都需要重新确定ReaderWidget的高度。这块通过对应翻页模式下checkLayoutParams 等方法完成的。

        /** Create a default <code>LayoutParams</code> object for a child of the ReaderView.
         *
         * <p>LayoutManagers will often want to use a custom <code>LayoutParams</code> type
         * to store extra information specific to the layout. Client code should subclass
         * {@link LayoutParams} for this purpose.</p>
         */
   protected ReaderViewParams generateDefaultLayoutParams() {
        return new ReaderViewParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT);
    }

    public ReaderViewParams generateLayoutParams(AttributeSet attrs) {
        return new ReaderViewParams(readerView.getContext(), attrs);
    }

    protected ReaderViewParams generateLayoutParams(ViewGroup.LayoutParams p) {
        p.width = ViewGroup.LayoutParams.MATCH_PARENT;
        p.height = ViewGroup.LayoutParams.MATCH_PARENT;
        return new ReaderViewParams(p);
    }

         /**
         * Determines the validity of the supplied LayoutParams object.
         *
         * <p>This should check to make sure that the object is of the correct type
         * and all values are within acceptable ranges. The default implementation
         * returns <code>true</code> for non-null params.</p>
         *
         * @param lp LayoutParams object to check
         * @return true if this LayoutParams object is valid, false otherwise
         */
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return p instanceof ReaderViewParams && p.height == ViewGroup.LayoutParams.WRAP_CONTENT;
    }

对于”免费小说“的阅读器来说,内容不可能是静态的,如果我们拦截onMeasure,可能会带来例如展示过程中无法动态添加删除View的窘境。
对于这部分场景,我们采用的“中间层分发”的策略。

布局

布局相关的逻辑主要在LayoutManager中处理的,外部视图主要是在ReaderWidget中处理的。

ReaderWidget

 private void layoutChild(View child) {
        LayoutParams lp = (LayoutParams) child.getLayoutParams();
        final int width = child.getMeasuredWidth();
        final int height = child.getMeasuredHeight();
        int childTop = 0, childLeft = 0, childRight, childBottom = 0;
        if (lp != null && lp instanceof MarginLayoutParams) {
            childLeft = ((MarginLayoutParams) lp).leftMargin;
            childTop = ((MarginLayoutParams) lp).topMargin;
            childBottom = height + childTop;
            if (lp instanceof ReaderViewParams) {
                if (((ReaderViewParams) lp).gravity == Gravity.CENTER) {
                    childLeft = childLeft + ((getRight() - getLeft()) - width) / 2;
                    childTop = childTop + ((getBottom() - getTop()) - height) / 2;
                    childBottom = height + childTop;
                } else {
                    if ((((ReaderViewParams) lp).gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.CENTER_HORIZONTAL) {
                        childLeft = childLeft + ((getRight() - getLeft()) - width) / 2;
                    }
                    if (((ReaderViewParams) lp).gravity == Gravity.BOTTOM || (((ReaderViewParams) lp).gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.BOTTOM) {
                        childBottom = getMeasuredHeight() - ((ReaderViewParams) lp).bottomMargin;
                        childTop = childBottom - height;
                    }
                }
            }
        } else {
            childBottom = height + childTop;
        }

        childRight = childLeft + width;
        child.layout(childLeft, childTop, childRight, childBottom);
    }

RequestLayout请求流程很长,而且调用层级很深,可能会导致到整个View树测量、布局,而且很多View常见的方法都会触发,这里由于篇幅限制就不去分析了。由于阅读器视图中有很多外界的视图及广告的视图,其中很多视图都有requestLayout调用的业务需求。对于阅读器来说,大多数视图是不需要从头到尾的重复测量、布局的,所以我们拦截大部分场景下的onLayout回调,减少不必要重复测量、布局,在适当的”中间层进行分发“足够满足这些视图布局需求。

LayoutManager

 protected void onLayoutChildren(ReaderView.Recycler recycler, ReaderView.State state) {
        if (state.getLayout_state() != ReaderView.State.DEFAULT) {
            updateCurrentPosition(0);
            if (state.getLayout_state() == ReaderView.State.RESET_VIEW) {
                detachAndRecyclerAllView(null);
            } else {
                detachAndRecyclerAllView(recycler);
            }
            fillEnd(0, true);
            dispatchOnPageSelected();
            dispatchOnADPageShow();
        }
    }

那些少部分场景还是需要我们重新布局的。对于这些场景的处理我们是采用'状态'来归纳处理的。
对于上下翻页来说,我们在重新布局后需要更精细的还原之前的应用场景,如跨页的情况:

当然其他翻页方式也可以根据业务需求来判断是否这样做。

因为不同的翻页方式对前一页、当前页 、下一页的位置相对关系要求不一样,所以我们把与之相关逻辑放到LayoutManager相关实现类中处理。为了保证统一性,防止View的索引跟数据自然顺序不匹配,我们所有翻页方式排版顺序仍然是按照自然顺序的排版布局的。系统默认是按照自然顺序布局,绘制的,后添加的View层级在上面,所以就导致层级更高的View渲染在上面 。那么问题来了,为什么覆盖翻页、及仿真翻页向前翻页时候,上一页反而在最上面,答案是修改绘制顺序,而不是修改布局顺序。

我们在添加View及移除View的时候尽可能采用更加轻量的方法。例如 attachViewToParent,detachViewFromParent 替代addView、removeView,我们在不需求一些安全检测(重复添加、匹配LayoutParams),onMeasure、onLayout、onDraw 等的情况下可以使用更高效的attachViewToParent,大部分场景下缓存的View宽高是不需要改变的,有些场景甚至不需要重绘。detachViewFromParent同理,详细逻辑请参考源码自行分析。

涉及到部分广告业务需求对布局位置特殊需求的,需要结合动画及手势、View状态、生命周期等全局协调处理,这里就不细说了......

绘制

有些场景我们是不需要绘制子View的,比如预加载视图,预加载的视图都是超出屏幕范围的或者被覆盖的视图,这种情况下,我们是不需要主动触发刷新的,减少invalidate 的调用,采用类似attachViewToParent进行预加载。
页面动画、移动避免采用频繁ondraw来达到动画效果的方案。

刚刚布局里提到了部分翻页方式,需要绘制层级跟View添加的顺序不一样的需求,先添加的View展示在最上面。解决这个问题我们只需要通过getChildDrawingOrder来改变绘制顺序,额外提一下这个方法还会改变事件分发的顺序。

文字内容采用Bitmap的方案来减少ondraw的工作量,尽量减少View层级,减少多层背景的使用及透明度背景的使用,减少不必要、不可见的绘制。

填充

  • 填充时机:分别在用户滑动时 及“空闲”时。滑动时一般选择相同的滑动方向及及滑动的范围区间进行实时判断填补视图,“空闲”时采用双向检测视图,避免由于卡帧导致的没有滑动。
    protected int fill(int delta) {
        if (delta > 0) {
            return fillStart(delta, false);
        } else {
            return fillEnd(delta, false);
        }
    }


    protected int fillStart(int delta, boolean preLayout) {
       ......
     
    }


    protected int fillEnd(int delta, boolean preLayout) {
       .......
    }

在填充视图的同时,我们要测量视图大小,根据空间,给视图对应位置(参考”布局“模块)。

  • 填充逻辑: 以首尾子视图的坐标、宽高 和整个阅读控件的宽高进行综合判断,填充。填充时注意减少不必要宽高判断,焦点等逻辑处理,参考上文提到的轻量添加操作。

视图回收

  • 视图回收时机:一般在用户滑动时及“空闲”时。滑动时一般选择相反的滑动方向进行判断回收处理,“空闲”时,前、后方向都做判断回收。
  • 视图回收逻辑:以首尾子视图的坐标、宽高 和整个阅读控件的宽高进行综合判断,回收。回收视图时,参考上文提到的轻量移除操作等注意点,还要额外注意跟填充判断不要有”交集“。

预加载

  • 预加载的判断逻辑。 为了整个视图控件的通用性,我们采用水平或者竖直方向上View的坐标及View的宽高综合来判断的而不是有多个View,我们可以简化理解在水平方向上及竖直方向预加载多大视图控件。这样的好处是无论上下翻页(viwe高度可变)还是其他翻页方式都能统一判断逻辑。由于上下翻页有惯性,滑动速度要求高,且一次滑动可达几十页,所以上下翻页采用范围区间判断逻辑,其他翻页由于宽高固定,可以近似理解通过超出屏幕外的视图个数来判断是否要预加载。

  • 预加载的视图数量。我们尽可能的根据不同翻页模式的需求来动态处理,我们是缓存一页、二页、还是三页视图更多的是根据整体阅读器的性能及业务需要做到均衡。对于左右翻页来说(覆盖翻页、平滑翻页、仿真翻页等),三页足够完美展示任意的翻页动画,但是对于用户来说,大部分场景都是向后翻页,我们如果出于内存的考虑是不是可以减少前一页缓存,只缓存当前页及下一页视图,如果我们内存比较吃紧,是不是可以更极端只缓存当前页,只在用户手指滑动时加载前一页或者后一页。由于上下翻页是高度是可变的我们预加载数量不能只根据视图数量来判断,也需要判断视图高度及控件的高度。

  • 关于预加载时机,我们需要在翻页动画进行时预加载吗?我们真的需要在动画完成时立马预加载吗? 答案是有时需要、有时不需要。对于左右翻页来说用户单次触摸行为的操作空间是有限制的,不会超过前一页、当前页、下一页的范围,只要用户手指没有抬起,我们是没有必要跟用户手势来抢占CPU资源的,所以我们选择在空闲时。对于上下翻页来说,用户操作空间极大,可能滑动范围、速度都很大,我们更没有理由抢占资源,所以最好也是空闲时。

缓存

缓存方面考虑到内存问题,部分业务的频繁刷新场景,部分业务视图的移除产生的影响,只做了两层缓存(冷、热),都是以ViewHolder为基础单元的缓存,热缓存以全部数据进行复用为目标,冷缓存只会复用外层控件。考虑到广告的视图的移除影响,及业务的不可控性质,我们倾向于严格限制热缓存使用的。

数据

视图的数据通过Adapter跟视图控件进行统一绑定的。书籍内容、广告、章末视图、作者有话说、章末推荐等来自各个业务模块的数据整合、整理、抽象会在后续的文章中详细说明。

视图刷新

因为阅读器的视图数据涉及到好多外部业务模块,这些数据获取可能是异步的,且是动态可变的,这就导致阅读器部分视图会存在多次刷新的情况。这些刷新主要分为两种情况:单个视图刷新、多个视图刷新。为了避免频繁刷新的视图,简化刷新逻辑,我们采用数据跟UI“相互”绑定方式,UI 跟数据相互独立又有联系,当数据变化时,UI监听数据变化进行局部的刷新,当UI变化时数据也能相应及时更新。

单页的视图监听单页数据的更新;而UI的更新(一般是尺寸变化)是需要通知到所有数据的。具体实现方案有多种,也可以参考MVVM中 数据跟UI的双向绑定思路自行实现。

其他

其他关于触摸事件、事件分发及拦截、惯性滑动、这些都是基础操作,由于篇幅原因就不过多赘述了。

总结

阅读器视图控件重构后已经上线将近一年,如我们预测一样,在我们重构后阅读器迎来了大量的业务接入,更多的视频广告、章末视图、作者有话说、章末推荐,段评、神评、书签标识等等一系列业务数据、视图相继融汇到阅读器中。业务总是千变万化的,我们不可能永远料敌先机,要加深内功,保持初心,时时勤拂拭,持续优化,才能以不变应万变。

关于阅读器针对多个外部业务模块的数据整合、抽象,刷新及更新后续会单独分析讲解。

参考文档

Recyclerview