前言
相信不少读者都阅读过相类似的文章了,但是我还是想完整的把这之间的关系梳理清楚,细节聊好,希望你也能从中学到一些。
进入正题,大家应该都听过这样一句话——“UI更新要在主线程,子线程更新UI会崩溃”。久而久之就感觉这是个真理,甚至被认为是“官方结论”。
但是如果问你,官方什么时候在哪里说过这句话,你会不会有点懵。而且就算是官方说的,也就不一定对的是吧,众所周知,Google
官方文档一直都有点说的不清不楚,需要我们进行大量实践得出实际的结论。
就好比之前的Android11
更新文档,我也是看了好久,通过一个个实践才写出了适配指南,然后就发现其中一个比较明显的BUG
,Google
官方有说过这样一句:
下面是首先需要关注的行为变更 (无论您应用的 targetSdkVersion 是多少): 外部存储访问权限 - 应用无法再访问外部存储空间中其他应用的文件。
其实经过实践会发现,外部存储访问权限还是会和targetSdkVersion
有关,具体可以看这篇Android11适配指南。
废话有点多了,今天还是通过实践案例,看看这个关于线程和UI更新的 “官方结论” 正确吗?
案例一,子线程更新button文字
1)onCreate
方法中更新了按钮显示文字,修改Button
的宽度为固定或者wrap_content
,都不崩溃。
<Button android:id="@+id/btn_ui" android:layout_width="100dp" android:layout_height="70dp" android:layout_centerInParent="true" android:text="我是一个按钮" /> //或者 <Button android:id="@+id/btn_ui" android:layout_width="wrap_content" android:layout_height="70dp" android:layout_centerInParent="true" android:text="我是一个按钮" /> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_ui) thread { btn_ui.text="年轻人要讲武德" } }
2)onCreate
方法中更新了按钮显示文字,加了延时。
Button
的宽度为固定不崩溃。Button
的宽度为wrap_content,崩溃报错——Only the original thread that created a view hierarchy can touch its views
。
<Button android:id="@+id/btn_ui" android:layout_width="100dp" android:layout_height="70dp" android:layout_centerInParent="true" android:text="我是一个按钮" /> //或者 <Button android:id="@+id/btn_ui" android:layout_width="wrap_content" android:layout_height="70dp" android:layout_centerInParent="true" android:text="我是一个按钮" /> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_ui) thread { Thread.sleep(3000) btn_ui.text="年轻人要讲武德" } }
案例一分析
有点懵的感觉,不慌,来看看崩溃信息。
崩溃是在按钮宽度为wrap_content
,也就是根据内容设定宽度,然后3秒之后去更新按钮文字,发生了崩溃。相比之下,有两个崩溃影响点
需要注意下:
宽度wrap_content
。如果设置为固定值,是不会崩溃的,见案例2,所以是不是跟布局改变的逻辑有关呢?延时3秒
。如果不延时的话,即使是wrap_content也不会崩溃,见案例1,所以是不是跟某些类的加载进度有关呢?
带着这些疑问去源码中找找答案。先看看崩溃日志:
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:9219) at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1600) at android.view.View.requestLayout(View.java:24884)
可以看到是ViewRootImpl
的requestLayout
中检查线程的时候报错了,那我们就看看这个方法:
@Override public void requestLayout() { if (!mHandlingLayoutInLayoutRequest) { checkThread(); mLayoutRequested = true; scheduleTraversals(); } } void checkThread() { if (mThread != Thread.currentThread()) { throw new CalledFromWrongThreadException( "Only the original thread that created a view hierarchy can touch its views."); } }
在解开谜底之前,我们先了解下ViewRootImpl
。
ViewRootImpl
Activity从创建到我们看到界面,其实是经历了两个过程:加载布局和绘制
。
- 加载布局
加载布局其实就是我们常用的setContentView(int layoutResID)
方法,这个方法主要做的就是新建了一个DecorView
,然后根据activity
设置的主题(theme)
或者特征(Feature)
加载不同的根布局文件,最后再加载layoutResID
资源文件。为了方便大家理解,画了一张图:
这里的最后一步是调用了LayoutInflater
的inflate()
方法,这个方法只做了一件事,就是解析xml
文件,然后根据节点生成了view
对象。最后形成了一个完整的DOM
结构,返回最顶层的根布局View。(DOM
是一种文档对象模型,他的层次结构是除了顶级元素,所有元素都被包括到另外的元素节点中,有点像家谱树结构,很典型的就是html
代码解析)
到这里,一个有完整view结构的DecorView
就创建出来了,但是它还没有被绘制,也没有被显示到手机界面上。
- 绘制
绘制的流程发生在handleResumeActivity
中,熟悉app启动流程的朋友应该知道,handleResumeActivity
方法是用来触发onResume
方法的,这里也完成了DecorView的绘制。再来一张图:
- 总结
由此我们可以得出一些结论:
1)setContentView
用来新建DecorView
并加载布局的资源文件。
2)onResume
方法之后,会新建一个ViewRootImpl
,作为DecorView
的parent
对DecorView
进行测量,布局和绘制等操作。
3)PhoneWindow
作为Window
的唯一子类,存储了DecorView
变量,并对其进行管理,属于Activity
和View
交互的中间层。
分析崩溃
好了。再回来看看崩溃的原因:
void checkThread() { if (mThread != Thread.currentThread()) { throw new CalledFromWrongThreadException( "Only the original thread that created a view hierarchy can touch its views."); } }
可以看到是因为当前线程currentThread
不是mThread
的时候,就会崩溃,报的错误是 “只有创建视图层次结构的原始线程才能触摸它的视图” ,看到这里是不是猜到一些了,这个mThread
难道就是“创建视图的原始线程”?
通过查找,其实这个mThread
是在ViewRootImpl
被创建的时候赋值的:
public ViewRootImpl(Context context, Display display) { mThread = Thread.currentThread(); }
而通过上方分析Activity
加载布局过程得知,ViewRootImpl
实例化发生在onResume
之后,用来绘制DecorView
到window
上。
所以我们就可以得知崩溃的真正原因,就是当前线程不是ViewRootImpl
创建时候的线程就会崩溃。翻译的还是比较准确的,只有创建视图的原始线程才能修改这个视图,听起来也蛮有道理的,我创造了你才有权利改变你,有那味了。
然后再看看前面的案例:
- 案例一,在
onCreate
中修改Button,这时候只是在修改DecorView,都没创建ViewRootImpl
,也就没走到所以checkThread
方法,当然不会崩溃了。ViewRootImpl
的创建是在onResume之后。 - 案例二,延时3秒之后,界面也绘制完成了,创建
ViewRootImpl
显然是在主线程完成的,所以mThread
为主线程,而改变Button
的线程为子线程,所以setText方法会触发requestLayout
方法重新绘制,最终导致崩溃。
但是,Button
的宽度设置为固定值咋又不崩溃了?难道就不会执行checkThread
方法了?奇怪。
找找setText
的源码可以发现,有一个方法是负责检查是否需要新的布局——checkForRelayout()
private void checkForRelayout() { // If we have a fixed width, we can just swap in a new text layout // if the text height stays the same or if the view height is fixed. if ((mLayoutParams.width != LayoutParams.WRAP_CONTENT || (mMaxWidthMode == mMinWidthMode && mMaxWidth == mMinWidth)) && (mHint == null || mHintLayout != null) && (mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight() > 0)) { if (mEllipsize != TextUtils.TruncateAt.MARQUEE) { // In a fixed-height view, so use our new text layout. if (mLayoutParams.height != LayoutParams.WRAP_CONTENT && mLayoutParams.height != LayoutParams.MATCH_PARENT) { autoSizeText(); invalidate(); return; } //... } // We lose: the height has changed and we have a dynamic height. // Request a new view layout using our new text layout. requestLayout(); invalidate(); } else { // Dynamic width, so we have no choice but to request a new // view layout with a new text layout. nullLayouts(); requestLayout(); invalidate(); } }
可以看到,如果布局大小没有改变的话,我们是不会去执行requestLayout
方法重新进行布局绘制的,只会调用autoSizeText
方法计算文字大小,invalidate
绘制文字本身,所以当我们宽高设置为固定值,setText()
方法就不会执行到requestLayout()
方法了,自然也就执行不到checkThread()
方法了。
反思
解决了问题,还需要反思下,为什么需要checkThread
检查线程呢?
- 检查线程,其实就是检查更新UI操作的当前线程是不是当初创建UI的那个线程,这样就保证了
线程安全
,因为UI控件本身不是线程安全的,但是加锁又显得太重,会降低View加载效率,毕竟是跟交互相关的。所以就直接通过判断线程这一逻辑来形成一个单线程模型
,保证View操作的线程安全。