1. AsyncLayoutInflater框架是Google提供的可用于异步线程中解析布局的工具,原理就是在新建的单线程循环执行提交到**任务队列(容量固定为10的ArrayBlockingQueue)**的任务。

    注意事项

    1. 所填充的布局中自定义View中不能直接使用Handler,因为其布局中所有子View都是在异步线程中创建的,故默认没有初始化Looper
    2. 单线程易阻塞
    3. 提交超过10个任务会导致主线程等待
    4. view需手动加到Viewparent
    5. 不支持设置LayoutInflater. Factory或LayoutInflater. Factory2
    6. 锁可能导致inflater不及时,导致反而比不用异步布局慢

理论基础

但是异步布局并不需要担心View测绘时抛出"Only the original thread that created a view hierarchy can touch its views."异常。

因为:

1
2
3
4
5
6
7
ViewRootImpl.java
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}

checkThread判断的是更新UI(requestLayout or invalidate)时,ViewRootImpl的创建线程与更新UI线程是否一致

ViewRootImpl线程是在初始化的时候赋值的。而ViewRootImpl的初始化是在:

  1. PhoneWindow(如Activity)在setContentView时先installDecor(),然后将setContentView的入参view添加到DecorView布局中的R.id.content上

  2. 之后会等到AMS发来Resume消息的时候,走ActivityThread.handleResumeActivity时,调用wm.addView(decor, l);,走到activity.mWindowManager.addView -> WindowManagerImpl.addView -> WindowManagerGlobal.addView

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    WindowManagerGlobal.java
    public void addView(View view, ViewGroup.LayoutParams params,
    Display display, Window parentWindow, int userId) {
    ViewRootImpl root;
    ...
    root = new ViewRootImpl(view.getContext(), display);
    view.setLayoutParams(wparams);

    mViews.add(view);
    mRoots.add(root);
    mParams.add(wparams);
    }

    由于ActivityThread所在线程其实就是主线程(ActivityThread.main调用了Looper.prepareMainLooper()、Looper.loop(),整个应用都是存活在这个looper的调度中的),所以调用ActivityThread.handleResumeActivity初始化ViewRootImpl的时候,就是在主线程。

故而不管是不是用异步布局填充最后checkThread时还是比较是否为主线程

改进方式

可以通过拷贝一份AsyncLayoutInflater,然后改动代码的方式:

  1. 通过增加线程池调度,规避只能单线程inflater的弊端,解决注意事项2
  2. 可以通过在inflater前,反射调用线程池中每个线程设置MainLooper(),解决注意事项1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void forceSetMainLoop() {
try {
Field field = Looper.class.getDeclaredField("sThreadLocal");
field.setAccessible(true);
Object object = field.get(Looper.getMainLooper());
if (object instanceof ThreadLocal) {
ThreadLocal threadLocal = (ThreadLocal<Looper>) object;
threadLocal.set(Looper.getMainLooper());
}
} catch (Throwable e) {
e.printStackTrace();
return;
}
}
  1. 页面没出来之前(空闲时渲染)直接填充布局,生成View后缓存到内存中,使用时直接取用,空间换时间的方式降低页面耗时,简单将就是填充一份View备用,需要时直接取用后再异步填充取代回去。

  2. 重写cloneInContext(LayoutInflater.from(context)最后走到的是ContextThemeWrapper的getSystemService。mInflater = LayoutInflater.from(getBaseContext()).cloneInContext(this);故重写该方法直接返回新的LayoutInflater即可)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    ContextThemeWrapper.java
    public Object getSystemService(String name) {
    if (LAYOUT_INFLATER_SERVICE.equals(name)) {
    if (mInflater == null) {
    mInflater = LayoutInflater.from(getBaseContext()).cloneInContext(this);
    }
    return mInflater;
    }
    return getBaseContext().getSystemService(name);
    }

    //改进后be like:
    public LayoutInflater cloneInContext(Context newContext) {
    return new BasicInflater(newContext);
    }

一、现状

项目中针对xml布局加载,一般是使用LayoutInflate.from(context).inflate或则View.inflate来进行,其他方式则是直接new XXXView

LayoutInflate 进行 xml 加载包括三个步骤:

  • 1、将 xml 文件解析到内存中 XmlResourceParser 的 IO 过程
  • 2、根据 XmlResourceParser 的 Tag name 获取 Class 的 Java 反射过程
  • 3、创建 View 实例,最终生成 View 树

这 3 步涉及IO和反射,所以是耗时的。

在业务层面上,我们可以通过优化 xml 层级、使用 ViewStub 方式进行按需加载等方式进行优化,降低布局填充耗时。

或则使用View复用方式(业务销毁时重置View属性)

但对于一些页面元素仍然较多,暂无法View复用,或则启动阶段针对布局填充还需要进一步降低耗时的,可以考虑布局异步预加载方案.

google本身提供了AsyncLayoutInflater类来完成布局异步加载,这套方案暂不支持预存View,只能通过回调来通知主线程。 主端也提供了一套异步加载基础能力AsyncInflateManager,但主端只是在VM架构中使用软引用来使用。 目前根据咱们自身业务形态中遇到的布局填充耗时问题,需要在AsyncInflateManager基础能力进行扩展。 可以先看几个trace:

img

中端机一次播控栏布局的填充,按trace的时间维度77ms,按损耗折算也有30ms,低端机耗时会更多。

img

中端机一次More面板布局填充,也差不多75ms,咱们在某些切换视频场景,大概一次要填充10个布局,可想而知,光填充时间就要占大量主线程时间。 方案上可以按需不加载10个这么多,而选择性填充,例如这些场景优化填充数量到5个来优化。 这里不讨论按需的场景,主要看下布局异步加载整个流程如何来优化这种元素较多的使用场景。

二、异步预加载方案

主端方案AsyncInflateManager实现上比较简单大致流程如下:

img

同时,也支持原生方案中的回调来通知主线程。即支持主动查询,也支持被动回调

三、遇到的问题以及方案改造

上述方案使用场景当前仅限于VM架构: 在XXXVM调用bindFields开始做异步加载,在XXXCell调用getItemView的时候去获取缓存View 根据咱们业务需求,需要在非VM架构使用。

根据使用过程产生的问题先后顺序,记录不断升级改造的迭代

问题1:Map缓存的是软引用View对象,一些低端机或小内存设备,在布局异步加载完缓存进Map后,就被gc了

img

使用软引用,基本上都走向了兜底逻辑,主线程Inflate布局。 改造:保留软引用使用基础上,派生强引用View对象

img

这样就能适配需要频繁填充布局的场景,根据业务使用场景,控制好布局最大缓存数,避免过度加载浪费资源。

问题2:异步加载后的SeekBar触摸时序发生了变化,导致同样代码逻辑缺出现问题,音量控件滑动后,声音大小没变

分析原因: 如果是主线程创建出的SeekBar,那么滑动事件的时序如下: onStartTrackingTouch -> onProgressChanged -> onStopTrackingTouch

而如果是子线程创建的SeekBar,同样滑动事件时序如下: onStartTrackingTouch -> onStopTrackingTouch -> onProgressChanged

img

所以这段逻辑的isVolumeSeeking没有起到作用,导致onProgressChanged没有执行changeVolume 时序执行发生变化,从源码中可以找到原因:

img

img

代码语言:javascript

复制

1
2
3
4
SeekBar的父类ProgressBar构造的时候会记录线程id,在刷新progress的时候,如果当前
线程id与构造记录的线程id一致,则直接回调onProgressChanged。否则就抛到主线程在执行这个操作
所以出现上述调用时序变化
改造:使用fromUser参数

img

代码语言:javascript

复制

1
2
3
4
5
使用这个参数来判定变化是不是来自用户操作



问题3:如果自定义View使用的VM架构,同时该View被其他页面复用,同时使用了DataBinding进行view绑定,那么不能使用异步加载该View布局,会出现Lifecycle绑定宿主错误问题

代码语言:javascript

复制

1
例子:业务SubmarineImmersiveVideoBoardView主feeds用来作为播放器容器,同时被创作者页面复用也是作为创作者播放器容器,同时bindViewModel的方法中使用

DataBinding.bind(mLayoutAbovePlayer, vm.mPosterField);来绑定对应View和VisibilityField可见性属性。

代码语言:javascript

复制

1
2
3
如果主feeds页面异步预加载了1次,而主feeds因为某些原因这一次没使用到,当切到创作者页面后使用到这个预加载的布局,那么,这个View对应的上下文还是主Feeds的Activity,
DataBinding.bind过程会识别到这个宿主是主Feeds Activity,而不是创作者Activity,导致生命周期绑定错误
所以对于这样场景,暂不能使用异步加载布局,后续可以考虑预加载与页面绑定,避免自定义可复用View引起DataBinding绑定问题
问题4:ViewConfiguration初始化阶段和Typeface create阶段线上产生ArrayIndexOutOfBoundsException

https://bugly.woa.com/v2/exception/crash/issues/detail?productId=8cff7e7c1f&pid=1&token=1ff71910a43e91d6a3b0057fc8869e10&feature=874B15C5217D653EAAF155D17F0B049B&cId=7E:4D:A4:E3:CA:14:7D:E9:6E:D5:BB:7A:E6:95:8B:D8

img

两个问题是同一个根因,异步加载布局后,一些系统属性在主线程初始化的同时,子线程也在初始化,导致同时访问了线程不安全的SparseArray容器出现越界。

改造:AttachBase阶段都在子线程先初始化完,一般主线程需要初始这些属性要在firstActivity创建之后,这个初始化耗时本身不高,所以到firstActivity阶段已经完成

img

优化后后续没出现类似crash

问题5:LayoutInflate对象锁导致的锁等待

img

锁等待发生在inflate阶段,LayoutInflater.inflate和View.inflate如果都是在主线程调用,不会存在锁等待,因为是单一线程。

而异步加载布局如果也是用这两个方法进行填充,那么就会因线程竞争导致锁等待,可能是主线程等子线程释放锁,也可能是子线程等主线程释放锁

锁等待会导致主线程在耗时增加,比没有优化更耗时,所以是必须要解决的问题

改造:使用new BasicInflater进行布局填充,避免对象锁

img

只要保证异步加载的LayoutInflater与主线程LayoutInflater是不同对象即可。 基于现有的方式在子线程已经使用了new BasicInflater,但某些布局是嵌套布局,View构造的时候 还是会使用LayoutInflater,所以全部替换为new BasicInflater

img

父布局xml被异步加载了,PlayerIntroView作为自定义子布局,如果使用了Inflate的方式,需要换成new BasicInflater(context).inflate

问题5:AssetMananger对象锁导致的锁等待

解决完LayoutInflater锁问题,还有AssetMananger对象锁问题

img

查看源码是对象锁

img

解决思路就变成如何新生成AssetMananger对象,而inflate填充传入了context,那么问题就变成新生成一个包含新AssetMananger对象context 改造:使用context.createConfigurationContext来生成

img

这个方法创建的context是一个新对象,但AssetMananger还是同一对象 还是需要查看源码了解原因。源码里面要生成新AssetMananger,需要ResourcesKey不同,如果同一个key 那么就会从map取出缓存的Assetmanager对象,显然不是我们预期的新对象

img

为了能产生不同ResourcesKey,需要改下Configuartion配置

img

通过增加语言来创建语言环境对象,新增AssetManager对象。这样异步加载AssetManager对象锁才得以解决

img

这里在回顾View的构造,可以看到进行异步加载的布局context是子线程使用的MutableContextWrapper可变上下文,代理mBase在子线程是由getAsyncLayoutContext生成,当主线程获取这个缓存后,通过replace会将这个mBase替换为页面上下文,完成上下文替换。

但mResources还是使用的子线程创建的Resources,如果主线程通过View.getResources的方式来获取资源,那么在极端场景下,子线程正在预加载同一个布局,而主线程使用上一次预加载缓存View,那么也会存在AssetManager锁等待的情况,

当然这种也可以通过将业务调用方式改为context.getResources来解决。

问题6:Art虚拟机InternTable Lock导致的锁等待

img

第三种锁,除上述截图外还有:

Lock contention on ClassLinker classes lock Lock contention on thread suspend count lock

Lock contention on runtime shutdown lock Lock contention on linear alloc

都是Art虚拟机的相关锁,这个不是特有锁,其他线程也有,具体原因不得而知,有清楚的同学还望不吝指点哈。 这个锁每次耗时不长,大概us级别,但数量不少,目前还不清楚原因以及如何处理,暂时记录下

问题7:使用单一线程还是线程池

目前我们业务统一采用单一高优线程来做异步预加载,线程池解决掉上述2种锁等待后,也是可用的。 但线程池每个线程的优先级不同,可能会导致某些高优布局需要更多的时间片更快执行,所以使用线程池 需要对执行线程有优先级要求

四、数据对比

img

使用这种方案后,inflate操作变成了读取缓存View,时间上就很快,和读取普通集合对象一样。一般不超过5ms

Author

white crow

Posted on

2025-01-07

Updated on

2025-01-07

Licensed under