Android子线程到底能否更新UI——可能是最全面的解析
Android子线程到底能否更新UI——可能是最全面的解析
前言:Android 开发中有这么一条“潜规则”:「一个 App 的主线程就是 UI 线程,子线程不能更新 UI」。绝大部分情况下,我们在需要处理 UI 逻辑时,都会自觉地放在主线程操作,但是为什么会有这么一条“铁律”,其原因是什么,以及这条“铁律”就一定正确吗?带着这些疑问我在度娘和 StackOverFlow 上搜了一遍,绝大部分分析都止于「子线程更新 UI 会抛出异常的逻辑在哪」,所以我决定自己探索一遍,并写下这篇截止到目前,【可能】是最全面的一篇分析。
当然,这篇文章不会深入到屏幕渲染、线程调度等等这样的层面,其重点在于从源码的角度论证:「子线程到底能不能更新 UI」,本文默认读者已有初级 Android 基础。
1. 子线程更新UI异常
下面这段代码,是很典型的子线程更新 UI 的操作:
1 | HandlerThread newThread = new HandlerThread("NewThread"); |
这段代码运行会抛出异常:
1 | CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. |
为什么子线程不能更新 UI 呢?这就需要从 3 个方面解释。
1.1 设计思想
首先不得不提到,子线程更新 UI 会抛出异常肯定是 Android 有意为之,但 Android 为什么要这么设计呢?
这是因为,人眼感到【流畅】需要满足帧率大于等于 60 Fps,对应的也就是约等于 16 毫秒一帧,Android 为了让交互和显示足够流畅,就需要尽可能保证这个帧率,尤其在现在高刷屏普及的时代,就需要尽可能缩短每一帧的渲染时间。因为频繁的加锁和锁释放会带来很大的内存开销,很可能会延长每一帧的渲染时间,因此对于 UI 更新的操作,是没有加锁的。但如果不加锁,在出现并发问题时,系统如何确保下一帧画面到底应该渲染成什么样呢?
所以,Android 系统为了避免这个问题,就从源码层限制了其他线程更新 UI,以兼顾 UI 更新的效率和并发安全性。
1.2 异常原因
解释完设计思想,就要老生常谈分析一下抛出异常的直接原因了。
首先有一个基础:View 在更新时,是将自己测量并绘制,但这个绘制并不是一旦 View 完成初始化、或者调用更新时就马上绘制,而是发起一个屏幕同步 Sync 请求,等待下一次屏幕刷新时,再绘制到屏幕上。
而这个 View 发起绘制请求的命令,就是 UI 更新都离不开的:requestLayout()
。
就以 TextView.setText(...)
为例,顺着 setText(...)
的源码一路点进去,直到下面这个方法(省略其他代码):
1 | public class TextView { |
可以看到,每个 View 都会一层层请求自己的父布局调用 requestLayout()
,而最最上层的父布局,就是一个 ViewRootImpl,它也实现了 ViewParent
接口。而在 ViewRootImpl 内,requestLayout()
的实现是:
1 | public final class ViewRootImpl implements ViewParent { |
到这一步就清晰明了了,因为 Thread.currentThread()
是子线程,而 mThread
是主线程,所以在这里抛出了异常。
【但是!】这个判断有个很大的问题,因为它的判断是 mThread != Thread.currentThread()
,而 mThread
是在这个 ViewRootImpl 的构造方法里面存入的,因此这个判断本质上比较的是:当前线程与「ViewRootImpl 初始化时的线程」是否相同,而不是当前线程与「主线程」是否相同,并且这里抛出的异常说明也是「the original thread」而不是「the main thread」,所以我们常说的「子线程不能更新 UI」,实际上是:
一个线程初始化了 ViewRootImpl 后,其 UI 不能被其他线程更新!而这两个线程,和是不是主线程并没有关系!
2. Activity视图加载流程
现在知道了线程能否更新 UI 主要看这个 UI 所处的最上层 ViewRootImpl 是否由同一个线程初始化,那么 ViewRootImpl 是怎么初始化的呢?又是在什么时候初始化的呢?
既然 ViewRootImpl 是最上层布局,那不妨从 Activity 启动加载开始:Android-Activity深入理解。
3. ViewRootImpl线程的定义
通过 Android-Activity深入理解,可以知道:
- ViewRootImpl 判断线程时依据的
mThread
就是创建并初始化 ViewRootImpl 时的所在线程。 setContentView(...)
之后 Activity 视图的加载流程,主要包括对 DecorView、subDecor、以及 mContentParent 的加载和持有逻辑。
但是!结合上面这两条来看,就会发现两个结论:
- 在
setContentView(...)
中,虽然也有判断 Window、DecorView、subDecor 等是否创建以及立即创建的逻辑,但并没有对 ViewRootImpl 的操作逻辑!!!也就是说,调用setContentView(...)
时所处的线程并不能决定 ViewRootImpl 的初始线程,也就无法决定哪个线程可以更新 UI! - 从 Activity 的加载流程 2.2.1 部分来看,决定 ViewRootImpl 初始线程的,似乎只有
handleResumeActivity(...)
,而不论 startActivity 是否在子线程中调用,一个 Activity 都是通过 AMS 管理的,handleResumeActivity(...)
的调用都会发生在 ActivityThread 中,ActivityThread 又处在主线程中。
这两个结论说明:不论 startActivity 是否在子线程中调用,也不论一个 Activity 的 setContentView(...)
是否在子线程中调用,都无法影响到 Activity 是在 ActivityThread 这个主线程中加载的,所以尽管 ViewRootImpl 比较的线程是【初始线程】与当前线程,但在 Activity 常规加载流程中,ViewRootImpl 总是在主线程初始化的,所以在大部分情况下,子线程的确无法更新 UI。
4. 子线程绝对不能更新UI吗?
在上面第 3 部分,我做了一个结论,表明「大部分情况下,子线程的确无法更新 UI」,但请注意原画中的「常规加载流程」,以及「大部分情况下」这两个关键词。
先写结论:实际上,子线程可以更新 UI。
在这里我又再一次推倒了前面的结论,因为我们已经知道,只要能让 ViewRootImpl 在子线程中初始化,就能在该子线程中更新 UI。虽然通常初始化 ViewRootImpl 的动作会被 ActivityThread 自动完成,但实际上仍有方法手动创建。
4.1 手动触发ViewRootImpl的初始化
从前文可以知道,ViewRootImpl 的初始化发生在 ActivityThread.handleResumeActivity(...)
中,并且发生在初始化 Window 和 DecorView 之后调用 mWindow.addView(DecorView, LayoutParams)
时。那就可以想个办法,在 onCreate 阶段就手动初始化 PhoneWindow,手动触发 mWindow.addView(...)
:
1 |
|
实践证明:通过手动初始化 Window 并添加 View,的确可以在子线程中更新 UI,且该方法适用于所有 View。
4.2 避开ViewRootImpl的检查
从文章最开始对 TextView#setText(...)
的源码分析可知,子线程中更新 UI 会抛出异常在于更新 UI 时,View 会逐级向上层父 View 调用 requestLayout()
,直到最上层的 ViewRootImpl#requestLayout()
判断了线程。但在后面 Activity 加载流程的分析中又发现,ViewRootImpl 是在 handleResumeActivity()
时初始化的,也就是说,在 Activity 处于 onCreate 生命周期时,ViewRootImpl 根本都还没有初始化,此时如果 TextView 更新 UI,则在逐级向上层调用父 View 的 requestLayout()
时,到了 ViewRootImpl 就会因为 mParent == null
而跳过了。
Show me the code:
1 |
|
实践证明:通过避开 ViewRootImpl 的检查,的确也可以在子线程中更新 UI,且该方法适用于所有 View。
4.3 针对TextView避开重绘
4.1 和 4.2 中的两个方法,是对所有 View 更新 UI 都适用的,但对于 TextView,还有一种方式,就是避开重绘。
首先看下这样一个布局(省略部分):
1 | <TextView |
通过 Activity 加载(省略部分):
1 |
|
点击 Button 时,会开启一个子线程并在子线程中更新 TextView。
毫无疑问这段代码 Crash 了,原因和文首说明的一样,因为 ViewRootImpl 在主线程中初始化,因此子线程无法更新 UI。
但!如果把布局中 TextView 的宽度改为精确值或 match_parent
,Activity 中的代码不变:
1 | <!--布局中把 TextView 的宽度改为精确值或 match_parent--> |
1 | // Activity 中的代码逻辑不变,仍然是在点击时开启子线程并在子线程中更新 TextView |
再次运行发现居然没有 Crash,子线程成功更新了 UI!这难道又要再次推翻之前的结论吗?
再重新翻一下 TextView#setText(...)
的源码,这一次仔细看看:
1 | public class TextView { |
看源码可以发现,在 checkForRelayout()
之前,先通过 setTextInternal(text);
把 text 存入了成员变量,然后才会调用 checkForRelayout()
检查线程。
通过上文已经知道,如果调用了 requestLayout()
,就会导致 ViewRootImpl 判断线程。而 TextView#checkForRelayout()
中,requestLayout()
之前有两个 return
的机会(已在代码注释中标出),接下来就是看看如何才能触发这两个 return
!
(1)首先是最外层的 if
判断必须要满足的条件,否则 else
中一定会走到 requestLayout
。这个最外层的 if
条件是(为了更加直观调整了缩进):
1 | if ( |
简单来说,这三个条件均满足就表示 TextView 的宽度是固定值且大于 0,也就是宽度是不需要重新测绘的。这也是为什么当 TextView 的宽度设置为 wrap_content
时,子线程更新 TextView 会抛出异常的原因,因为这个最外层的 if
不满足而走到了 else
中。
(2)接着是第二层的 if
判断也必须要满足条件:
1 | if (mEllipsize != TextUtils.TruncateAt.MARQUEE) |
这个判断满足时表示 TextView 不是跑马灯效果的状态。这个很好理解,因为跑马灯效果是需要一直刷新 UI 的。
(3)然后是第一个可能 return
的条件(为了更加直观调整了缩进):
1 | if ( |
代码很好懂,如果高度既不是 wrap_content
又不是 match_parent
,那就只能是精确高度了,这也就表示高度也不需要重新测绘。
(4)最后是第二个可能 return
的条件(为了更加直观调整了缩进):
1 | if ( |
代码依然很好懂,如果新的高度和久的高度一致,也表示高度不需要重新测绘。
综合上述的(1)、(2)、(3)、(4)可以得出结论:如果一个 TextView 的内容被改变了,但是新 TextView 的高度和宽度都不会发生变化,并且也不是跑马灯效果模式,也即 TextView 不需要重新测绘,则不需要调用 requestLayout,也就不会走到 ViewRootImpl 判断线程的地方!
这里需要注意的是:宽度和高度必须同时都是固定值(精确值或 match_parent
)才不会发生重绘。上面测试代码中,TextView 的高度为 wrap_content
却没问题的原因,是更新内容时能在一行内显示完全,因此高度没有发生变化,走进了条件(4)中的 return
。如果把 TextView 改成宽度为很小的值、高度为自适应,然后子线程中 set 一个很长的文本,使得 TextView 会因为换行导致高度发生变化,则也是会抛出异常的:
1 | <!--布局中把 TextView 的宽度设为很小的值,高度为自适应,然后子线程中 set 一个很长的文本使其换行导致高度变化,会抛出异常--> |
因此 TextView 的 UI 更新方式可以总结为两种:
- 如果更新后宽度或高度会发生变化,或者是跑马灯效果模式,则立即逐级向父 View 请求重绘一次,并在绘制时绘制出新的文本。
- 否则就把把需要更新的文本存在 TextView 内,等下一次屏幕刷新的时候顺便就绘制成新的文本。
实践证明:针对 TextView,通过避免重绘,的确可以实现子线程更新 UI,但仅针对 TextView 或类似有跳过重绘逻辑的 View。
4.4 使用SurfaceView/TextureView
SurfaceView 算是正儿八经使用子线程更新 UI 的例子了,也是其最大的优点。SurfaceView 的画面渲染主要是通过其持有的一个 Surface
类型的 mSurface
对象实现的,这个 Surface
并不是一个 View 的子类,因此其更新并不收到 View 更新中 checkThread()
的限制。简单来说,SurfaceView 可以在子线程中更新 UI 的原理是因为其渲染的目标并不是一个 View。
当然,实际上 SurfaceView / TextureView 的原理远不止这么简单,本文主要聚焦于子线程更新 UI 的可行性,所以不对 SurfaceView / TextureView 的原理深入解析,相关解析也在计划中,感兴趣的读者可以关注后续更新。
4.5 特例Toast
Toast 作为 Android 系统级别的 UI 组件,甚至与 Activity 生命周期都无关,常见的例子就是如果一个 App 正在弹 Toast 的时候出现 Crash 或者手动杀掉了,Toast 还是能正常显示的。
4.5.1 Toast可以跨线程显示
实际上 Toast 的显示除了和 Activity 无关之外,也和线程无关,下面这段代码执行不会抛出异常:
1 |
|
同时抛出一个注意事项:如果需要在子线程中 Toast,则该子线程必须初始化 Looper,因此需要使用 HandlerThread 或者在子线程中手动调用 Looper 的初始化:
1 | // 直接在未初始化 Looper 的子线程中 Toast 会抛出异常 |
Toast 本质上也是一种 View,因此是可以通过 toast.setView(View)
来自定义 Toast 样式的,那既然 Toast 是 View,为什么可以在子线程显示呢?老办法,看源码:
1 | public class Toast { |
可以看到:
- 默认情况下三参数的
Toast.makeText(...)
会调用四参数的重载方法,并且传入的 looper 参数是null
- 四参数的方法中,
new
了一个 Toast 实例 - 查看对应的 Toast 构造方法发现,又用传入的 looper 作为构造函数参数
new
了一个TN
类的实例 - 再查看 TN 的构造方法发现,如果传入的 looper 为
null
,就直接用当前调用线程的 Looper
简言之,Toast.makeText(...)
是直接使用调用的线程作为显示线程的,这就可以直接验证上文说的 Toast 的两个特性:
- Toast 可以在子线程显示,因为
Toast.makeText(...)
内部在调用时每次都使用当前线程作为显示线程,因此实际上不存在跨线程的问题。 - Toast 要求线程初始化 Looper 否则在
new TN(...)
的时候就会因为拿不到 looper 抛出异常。
4.5.2 Toast不能跨线程更新
看到这个小标题别慌,Toast 可以在子线程中显示是毫无疑问的,但是有一种情况下,Toast 也会抛出 CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
异常,就是更新内容:
1 | private Toast generalToast; |
运行发现报错了:
1 | CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. |
为什么 Toast.makeText(...)
不限制线程,但 toast.setText(...)
又限制线程呢?再仔细看看:
1 | public class Toast { |
原来在 Toast.makeText(...)
时,Toast 会使用当前线程作为该 Toast 的消息处理 Looper,然后使用系统的 Inflater 服务去加载一个 com.android.internal.R.layout.transient_notification
的布局作为 Toast 的根布局,其中具有一个 TextView 元素,使用该 TextView 元素承载需要显示的文字。
当调用 toast.setText(...)
时,TextView 就会像文首提到的方式,一层层向上通知更新,因此如果线程与 toast 在初始化时的线程不一致,自然会抛出异常。
4.6 捕获异常
上述的在子线程更新 UI 的方式,都是通过避开已知会抛出异常的情况(SurfaceView 相当于直接不检查)实现的。还有一种更新 UI 的方式最为简单粗暴,就是捕获异常:
1 | HandlerThread newThread = new HandlerThread("NewThread"); |
这段代码当然不会抛出异常,并且 TextView 也确实能更新文本内容,但是 ImageView 却没有任何反应。对比一下 TextView#setText(...)
和 ImageView#setImageResource(...)
的源码:
1 | public class TextView { |
通过看源码发现:
TextView#setText(...)
是先通过setTextInternal(...)
把 text 存入到成员变量,然后再调用checkForRelayout()
检查是否需要重绘,如果需要的话才调用requestLayout()
,检查线程也就发生在ViewRootImpl#requestLayout()
中。所以即使ViewRootImpl#requestLayout()
抛出了异常,也不会影响到setTextInternal(...)
已经把 text 存下来了,那只需要等待下一次屏幕刷新即可把文本刷新上去。ImageView#setImageResource(...)
是先通过requestLayout()
请求更新,并在ViewRootImpl#requestLayout()
中检查了线程,只有未抛出异常时,才会走到invalidate()
并重绘,否则抛出异常则会中断跳出方法。
所以,通过捕获异常的方式,只能针对类似于 TextView 这种,可以在检查线程前先做更新 / 缓存的 View,其他 View 则尽管不会抛出异常,也无法更新 UI,所以捕获异常属于一种“骚操作”,是极为不建议使用的。
5. 总结
概括一下本文内容:
- Android 中视图的顶点都是 Window,显示视图的根基就是需要有一个可用的 Window
- Window 持有 DecorView
- Window 在创建并持有 DecorView 时会初始化 ViewRootImpl 时的当前线程会作为 ViewRootImpl 持有的初始化线程
- DecorView 加载 subDecor
- 用 subDecor 承载 Activity 的 layout
- 更新 View 时,如果需要重绘,会逐级调用父 View 的
requestLayout()
,最上层的父 View 就是 ViewRootImpl,在ViewRootImpl#requestLayout()
中判断了当前线程与初始化线程是否相同,如果不相同则抛出异常 - 有几种方式是可以在子线程更新 UI 的:
- 手动触发 ViewRootImpl 初始化:也就是手动创建 Window 并添加 DecorView。
- 避开 ViewRootImpl 的检查:针对 TextView 这类先缓存再判断的 View 可以通过避开重绘等待下一次屏幕刷新时显示已缓存的内容来刷新 UI。
- 使用 SurfaceView / TextureView。
- 显示 Toast:Toast 的创建每次都会使用当前线程初始化,因此显示 Toast 不受跨线程的影响。但不能对其他线程的 Toast 实例对象调用
Toast#setText(...)
,否则就相当于子线程更新 UI。 - 捕获异常:针对 TextView 这类先缓存再判断的 View,可以更新 UI。但其他 View 通常会先检查线程再重绘,就会导致检查的那一步抛出异常,虽然捕获了不会 Crash,但也会中断重绘逻辑导致无法刷新。