纵横Android阅读器-绘制优化

背景

随着纵横小说app各模块的迭代升级,阅读器所承载的功能也日益复杂。由于诸多历史遗留问题,阅读器内控件的绘制和点击实现方式变得相当繁琐,导致即便是对阅读器的微小调整,也需要耗费较长的开发周期,尤其是在当前一周一版的发布节奏下。因此,阅读器的需求变得难以快速响应,很难实现每周小步迭代。在本文中,将探讨纵横小说阅读器中可交互控件在简化现有实现方式上所做的一些尝试。

概述

简言之,纵横的阅读器采用了通过绘制Bitmap的方式实现,即将文字、按钮和图片测量出宽高,根据屏幕尺寸和显示规则通过canvas绘制在一个Bitmap上,然后将该Bitmap绘制到阅读器的View上展示。由于系统的View无法满足阅读器复杂的需求,必须依赖canvas自定义的绘制方式,以便更灵活地控制绘制过程,然而这也带来了一些复杂的问题和挑战。

比如阅读器内绘制一个简单的用户余额的功能

55EE4D75-003C-424C-94EE-6EC4CB53564E-2

下面是具体的代码实现

private float drawBalanceText(Canvas canvas, float top, boolean isCanvasSave, boolean isDrawCoupon, boolean isDrawReadGold) {
    if (isCanvasSave) {
        canvas.save();
    }
    SparseIntArray theme = getTheme();
    int txtSize = DensityManager.dip2px(context, TEXT_SIZE);
    top += txtSize;
    paint.setTextAlign(Paint.Align.LEFT);
    paint.setTextSize(txtSize);
    paint.setColor(getColor(theme.get(TC_TEXT1)));
    paint.setTypeface(Typeface.DEFAULT_BOLD);
    float startX = DensityManager.dip2px(context, LEFT_PADDING);
    canvas.drawText("余", startX, top, paint);
    float textWidth = paint.measureText("余");
    canvas.drawText("额:", startX + textWidth * 3, top, paint);
    paint.setColor(getColor(theme.get(TC_PRICE)));
    float tw1 = textWidth * 3 + paint.measureText("额:");
    float leftBalanceStart = startX + tw1;
    canvas.drawText(String.valueOf(getBalance()), startX + tw1, top, paint);
    paint.setTypeface(Typeface.DEFAULT);
    startX += tw1;
    paint.setColor(getColor(theme.get(TC_TEXT2)));
    tw1 = paint.measureText(getBalance() + "") + textWidth / 2.0f;
    canvas.drawText("纵横币", startX + tw1, top, paint);
    if (isDrawReadGold || isDrawCoupon) {
        int money = isDrawReadGold ? getLeaveReadUnit() : getCoupons();
        String text = isDrawReadGold ? "读书币" : "书券";
        if (money > 0) {
            tw1 = tw1 + paint.measureText("纵横币");
            startX += tw1;
            paint.setColor(getColor(theme.get(TC_TEXT2)));
            canvas.drawText(" + ", startX, top, paint);
            tw1 = paint.measureText(" + ");
            startX += tw1;
            paint.setColor(getColor(theme.get(TC_PRICE)));
            paint.setTypeface(Typeface.DEFAULT_BOLD);
            canvas.drawText(String.valueOf(money), startX, top, paint);
            tw1 = paint.measureText(String.valueOf(money));
            paint.setTypeface(Typeface.DEFAULT);
            startX += tw1;
            paint.setColor(getColor(theme.get(TC_TEXT2)));
            canvas.drawText(" " + text, startX, top, paint);
        }
        if (isDrawCoupon && isDrawReadGold) {
            int coupons = getCoupons();
            top += txtSize + DensityManager.dip2px(context, 6);
            paint.setColor(getColor(theme.get(TC_TEXT1)));
            paint.setColor(getColor(theme.get(TC_COUPON)));
            paint.setTypeface(Typeface.DEFAULT_BOLD);
            canvas.drawText(String.valueOf(coupons), leftBalanceStart, top, paint);
            tw1 = paint.measureText(String.valueOf(coupons));
            paint.setTypeface(Typeface.DEFAULT);
            leftBalanceStart += tw1;
            paint.setColor(getColor(theme.get(TC_TEXT2)));
            canvas.drawText(" 书券", leftBalanceStart, top, paint);
        }
    }
    return top;
}

问题

通过上述例子可见,这种实现方式存在一些弊端,特别是在考虑屏幕适配、事件处理以及各种翻页模式下内容移动的情况下。这使得代码复杂性大幅增加,即便是微小的调整也可能导致重大改动。这种复杂性主要源自自定义绘制中的三个问题:

  1. 屏幕适配: 自定义绘制可能导致在不同屏幕密度和尺寸上显示问题。需要处理屏幕适配,确保绘制的内容在不同设备上都能正常显示。
  2. 测量布局逻辑: 由于没有依赖系统的 View 测量机制,需要手动实现自己的测量逻辑,不再依赖系统的自动布局。要实现的逻辑有对内容的测量和绘制区域大小、相对位置、间距的计算。
  3. 事件处理: 自定义绘制的内容不会被系统视为交互元素,因此需要手动处理触摸事件、点击事件等。这需要复杂的逻辑来确定用户是否与绘制的内容进行交互。

思路和解决办法

通过阅读View的源码并分析需求,发现系统的View已经实现了这些按钮的测量、绘制、点击等逻辑。将阅读器中能够拆分出去的部分通过View的方式来实现,并解决一些难点问题,可以满足阅读器的需求。这样一来,在不降低阅读器绘制灵活性的前提下,提高了可维护性。

通过上述例子,我们可以看到,绘制余额的过程实际上就是将要显示的文字绘制到canvas上的过程。系统View的draw(Canvas canvas)方法能够将view的内容绘制到canvas上。利用这个方法,在canvas将阅读器内容绘制到Bitmap上之前,将自定义view绘制到canvas上,这样生成的Bitmap上就包含了自定义view的内容。因此,主要的思路是通过一个自定义view(ReadDrawer)来管理绘制内容并将UI绘制到canvas上。然而,要满足阅读器的需求,还需要解决一些其他问题。

1、阅读器中有预加载Bitmap的需求,需要在子线程中生成。然而,Android要求所有对UI的操作都在主线程中进行。这是因为UI元素不是线程安全的,直接在非主线程中进行UI操作可能导致不可预知的问题。在子线程生成或操作view时,可能会触发异常CalledFromWrongThreadException。以下是在android.view.ViewRootImpl中检测主线程的两个方法。

android.view.ViewRootImpl

void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}
android.view.ViewRootImpl

public void runOrPost(View source, int changeType) {
    if (mHandler.getLooper() != Looper.myLooper()) {
        CalledFromWrongThreadException e = new CalledFromWrongThreadException("Only the "
                + "original thread that created a view hierarchy can touch its views.”);
    ...
}

ViewRootImpl 实例在创建视图的过程中由 PhoneWindowDialogsetContentView 方法中被创建。通常,当设置一个布局给 ActivityDialog 时,会触发 ViewRootImpl 的生成。 这个 mThread 或者 mHandlerRootViewImpl 布局创建所在的线程,当和要操作的子 view 不在同一个线程就会出现异常。

解决办法:因为这些线程相关的检测都是在 ViewRootImpl 中进行的,如果自定义 ReadDrawer 不添加到 ViewRootImpl 中,没有父布局就不会有上面的线程检测了。

2、ReadDrawer 不添加到父布局就会出现无法正常获取宽高的问题,因为系统无法触发测量(measure)和布局(layout)过程,这两个过程通常是在父容器对子 View 进行管理时触发的。

解决办法:可以根据阅读器的宽高手动测量和布局,这样可以手动控制 ReadDrawer 的尺寸和位置。

fun measureAndLayout(width: Int, height: Int) {
    getDrawerView()?.let {
        it.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
        it.layout(0, 0, it.measuredWidth, it.measuredHeight)
    }
}

3、点击事件的传递问题,ReadDrawer不添加到父布局,事件就无法传递到ReadDrawer。怎么让点击事件到达ReadDrawer呢?如果一个页面由多个可能重叠的ReadDrawer绘制,如何正确响应事件呢?这些就需要自己处理了。

dispatchTouchEvent 是 Android 中 View 类的一个方法,用于分发触摸事件。其主要作用是将触摸事件传递给 View 树中的目标视图,并在事件传递的过程中执行相应的处理逻辑,然后返回事件是否被消费。

android.view.View

/**
 * Pass the touch screen motion event down to the target view, or this
 * view if it is the target.
 *
 * @param event The motion event to be dispatched.
 * @return True if the event was handled by the view, false otherwise.
 */
public boolean dispatchTouchEvent(MotionEvent event) {

    ...

    if (onFilterTouchEventForSecurity(event)) {
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
            result = true;
        }
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }


        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }

    ...

    return result;
}

可以利用这个方法在有多个ReadDrawer时,通过手动调用对应ReadDrawerdispatchTouchEvent方法来找到能响应点击事件的view。但是要保证事件处理顺序,显示在最上层的ReadDrawer优先处理事件。

下面是一个结构图,根据ReadDrawer的功能可以分为三类书、章节和页,当page生成Bitmap时把参与绘制的ReadDrawer记录在Page中,方便后续处理事件

14EAEE6D-3B4E-42F5-94C2-19A825A7F07F

找到相应事件的 ReadDrawer 后怎么触发对应 view 的点击事件呢?

阅读器首先处理手势和长按等事件,确认是点击事件后,会将相应的MotionEvent传递出来。通过这个MotionEvent,可以找到能够消费事件的ReadDrawer。一旦找到了ReadDrawer,可以利用dispatchTouchEvent方法模拟一个down up事件,从而触发view的点击监听。

fun checkClick(clickEntity: SlideClickEntity?): Boolean {
    ...
    //获取参与绘制的所有ReadDrawer
    val readDrawers = clickEntity.pageHolder?.getReadDrawers()

    //获取显示在最上层的ReadDrawer
    val listIterator = readDrawers!!.listIterator(size)
    var interceptClick = false
    while (listIterator.hasPrevious()) {
        val event = clickEntity.event

        //需要处理阅读器有顶部间距和页面的滚动距离
        val y = event.y + clickEntity.marginTop - clickEntity.canvasMarginTop

        //生成一个down事件
        val newEventDown = MotionEvent.obtain(event.downTime, event.eventTime, MotionEvent.ACTION_DOWN, event.x, y, event.metaState)
        val drawerView = listIterator.previous()?.getDrawerView() ?: break

        //判断当前drawer会不会消费本次事件
        interceptClick = drawerView.dispatchTouchEvent(newEventDown) == true
        if (interceptClick) {
            newEventDown.action = MotionEvent.ACTION_UP
            drawerViewReference?.get()?.setTag(R.id.click_entity, clickEntity)

            //重新发送一个up事件,模拟点击的down和up,用来触发view的点击事件
            drawerView.dispatchTouchEvent(newEventDown)
            break
        }
        newEventDown.recycle()
    }
    return interceptClick
}

4、点击事件失效。尽管已经模拟了down和up事件,但发现OnClickListener却未被正常触发。通过研究源码,发现在触发view的点击事件之前,存在一些检测机制。如果View尚未附加到窗口,窗口管理相关的信息AttachInfo为null。在这种情况下,系统会推迟执行action,直到View成功附加到窗口为止。因此,点击事件的action是不会被执行的。

android.view.View

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        return attachInfo.mHandler.post(action);
    }
    // Postpone the runnable until we know on which thread it needs to run.
    // Assume that the runnable will be successfully placed after attach.
    getRunQueue().post(action);
    return true;
}

但是发现 OnTouchListeneronTouch 是没有相关检测的,可以正常触发。

android.view.View

public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            //onTouch事件是没有AttachInfo检测的
            && li.mOnTouchListener.onTouch(this, event)) {
        result = true;
    }
    ...
}

这样就可以通过 ViewonTouch 事件手动调用 performClick 方法来触发点击事件。

fun setClickListener(view: View?, listener: View.OnClickListener?) {
    view?.setOnClickListener(listener)
    view?.setOnTouchListener(touchListener)
}

private val touchListener = View.OnTouchListener { v, event ->
    if (event.action == MotionEvent.ACTION_UP) {
        v?.performClick()
    }
    true
}

总结

解决了上述问题后,阅读器中的这些控件现在可以像普通的view一样正常设置属性和点击事件了。这不仅提升了阅读器代码的可维护性,而且显著缩短了阅读器需求的开发周期。这个问题解决的过程让我更加清晰地理解了Android系统是如何处理这些问题的。深入学习和理解系统源码不仅有助于理解底层原理,还能够提供解决问题的思路和方法。

展示评论