前言:
看到这个标题,初学者肯定说,怎么可能嘛,安卓不是规定UI线程才能刷新UI的吗?
稍微资深一些的安卓会说,不就是在刚启动的时候在子线程中去刷新嘛,这个面试经常被问到。
资深的安卓会说,安卓的定义是在UI创建线程去刷新,如果子线程创建的话,那么子线程也可以刷新UI的。
这些就完了吗?当然没有,这次就带了第三种子线程刷新UI的场景,并且对前两种场景的原理进行详细的分析。
场景一:
1.现象
onCreate方法里面创建子线程,然后线程里面去更新UI。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Thread {
mResult.visibility = View.GONE
}.start()
}
这样调用是不会有任何问题的,但是如果我们对代码稍加修改,改成下面这样,就会崩溃了。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Thread {
Thread.sleep(1000)
mResult.visibility = View.GONE
}.start()
}
2.崩溃触发点
这是为什么呢?那我们就根据崩溃日志反着找原因吧。
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:9312)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1772)
at android.view.View.requestLayout(View.java:25697)
at android.view.View.requestLayout(View.java:25697)
at android.view.View.requestLayout(View.java:25697)
at android.view.View.requestLayout(View.java:25697)
at android.widget.RelativeLayout.requestLayout(RelativeLayout.java:380)
at android.view.View.requestLayout(View.java:25697)
at android.view.View.setFlags(View.java:16377)
at android.view.View.setVisibility(View.java:11896)
at com.xt.client.activitys.ThreadRefreshActivity$onCreate$1.run(ThreadRefreshActivity.kt:15)
at java.lang.Thread.run(Thread.java:920)
对应的ViewRootImpl的checkThread方法做的就检查,如果当前调用的线程不是绑定的线程,那么就会跑出异常发生崩溃。
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
3.推测原因
我们可以发现checkThread是被requestLayout方法调用的,所以至此我们可以有这样一个猜测:
调用requestLayout这个方法才会发生崩溃,但是如果我们在子线程调用setVisibility,并不调用requestLayout方法。
后续在主线程调用requestLayout触发全局刷新,是不是就可以避免发生崩溃?
4.验证推测1-setVisibility方法
为了验证我们的推测,那我们就去看一下setVisibility方法,里面直接调用了setFlags方法。我们只要搜requestLayout()方法即可。这里我们发现,只要是显示状态状态的变化都会触发requestLayout方法,那么requestLayout在这里一定是会被调用的。那我们就继续往下看
void setFlags(int flags, int mask) {
...
/* Check if the GONE bit has changed */
if ((changed & GONE) != 0) {
needGlobalAttributesUpdate(false);
requestLayout();
}
...
if ((changed & DRAW_MASK) != 0) {
if ((mViewFlags & WILL_NOT_DRAW) != 0) {
if (mBackground != null
|| mDefaultFocusHighlight != null
|| (mForegroundInfo != null && mForegroundInfo.mDrawable != null)) {
mPrivateFlags &= ~PFLAG_SKIP_DRAW;
} else {
mPrivateFlags |= PFLAG_SKIP_DRAW;
}
} else {
mPrivateFlags &= ~PFLAG_SKIP_DRAW;
}
requestLayout();
invalidate(true);
}
}
4.验证推测2-View.requestLayout()方法
requestLayout方法里面会调用mParent.requestLayout();
正常情况下,这里会一层一层的向上通知,最上面一层就是DecorView,而DecorView的mParent为ViewRootImpl,主线程检查也就是在ViewRootImpl中的requestLayout方法中执行的。
但是场景一的情况下,我们在这里断点调试一下就会发现,这里的mParent全部都是null,所以就不会一层一层的向上传递,所以也就不会发生崩溃了。
if (mParent != null && !mParent.isLayoutRequested()) {
mParent.requestLayout();
}
5.mParent赋值时机
我们找到了原因是mParent为null所以不会崩溃,那我们接下来肯定就要了解下,mParent是什么时候被赋值的:这个我们分两种场景,ViewGroup和DecorView,
首先是ViewGroup,这个简单,就是在addView的时候:
private void addViewInner(View child, int index, LayoutParams params,
boolean preventRequestLayout) {
...
if (preventRequestLayout) {
child.assignParent(this);
} else {
child.mParent = this;
}
...
}
DecorView的话,则是在ViewRootImpl.setView的时候:
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
int userId) {
...
view.assignParent(this);
...
}
setView是在resume的时候被调用的:
所以我们得出一个结论,最顶层的mParent是在resume方法之后被赋值的。
6.resume方法中子线程调用也不会发生崩溃
你猜这就完成了吗?当然没有,我们上面知道了是在resume的时候给mParent赋值的,那我们就把上面那个线程刷新UI的代挪到onResume方法中,如下,这时候mParent肯定是有值的,但是我们仍然发现,程序竟然还是正常运行没有崩溃。why?
override fun onResume() {
super.onResume()
Thread {
mResult.visibility = View.GONE
}.start()
}
我们回头看一下之前requestLayout的判断方法:
(mParent != null && !mParent.isLayoutRequested())有两个条件,虽然DecorView的mParent不为空了,但是后面这个返回的仍然是false,所以最终的requestLayout方法仍然没有执行。
这时候我们就要看一下判断条件了:
public boolean isLayoutRequested() {
return (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
}
全局搜索代码我们可以知道,PFLAG_FORCE_LAYOUT是在layout方法中赋值的。同样,我们打个断点,看看DecorView的layout方法什么时候被执行。如下图所示:
至此,我们终于全部了解清楚了。resume方法执行的时候,还只是关联到ViewRootImpl上而已,最终还需要执行一次doFrame全局刷新,才会开启对应的主线程检查,否则在此之前,子线程都是可以操作UI的。(PS:当然,只有执行了首次的全局刷新之后,UI才会呈现到屏幕上,在此之前屏幕上都是不会展示的)
场景二:
1.现象
我们在做一个实验,点击按钮后,启动一个子线程去显示dialog,代码如下:
这时候我们发现,弹窗是可以正常显示的,也不会报错。
override fun clickItem(position: Int) {
if (position == 0) {
Thread {
//dialog显示
Looper.prepare()
Handler().post {
val dialog = Dialog(this)
dialog.setTitle("子线程刷新的内容-场景2")
dialog.show()
}
Looper.loop()
}.start()
return
}
if (position == 1) {
Thread {
mResult.text = "子线程刷新的内容-场景3"
}.start()
return
}
}
2.探索原因
一样,我们既然知道执行UI线程检查的方法在ViewRootImpl的requestLayout方法中。那我们就断点过去看看为什么没有报错。这时候我们发现,mThread竟然是一个子线程,而当前线程就是子线程,所以自然就不会报错了。
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
3.原因分析
所以,下一步我们就找一下,mThread是啥时候被赋值的。我们发现就是在初始化ViewRootImpl的时候取得当前的线程。
public ViewRootImpl(@UiContext Context context, Display display, IWindowSession session,
boolean useSfChoreographer) {
...
mThread = Thread.currentThread();
...
}
而ViewRootImpl的初始化是在WIndowManagerGlobal.addView的时候;
我们回头看一下Dialog的show方法:
public void show() {
...
mWindowManager.addView(mDecor, l);
...
}
至此,我们知道所有的原因了。本身show方法就在子线程,所以addView也发生在子线程,因为ViewRootImpl绑定的就是子线程,因此继续在子线程中进行UI操作就不会报错。相反,如果切换到主线程操作Dialog,反而会报错
场景三:
前两种都是相对老一些的,网上也有一些其它的分析文章。近期在做项目的时候,发现还有一种场景子线程进行UI操作也不会抛出异常。
1.现象
这次我们更简单了,直接点击按钮触发一个子线程,子线程中修改textView的显示文本。
这次,我们还是发现,正常运行,text文本会变掉,并且不会报错。这又是为什么呢?
override fun clickItem(position: Int) {
if (position == 1) {
Thread {
mResult.text = "子线程刷新的内容-场景3"
}.start()
return
}
」
xml中text显示如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:orientation="vertical">
...
<TextView
android:id="@+id/result_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:gravity="center"
android:text="result" />
</RelativeLayout>
2.原因排查
有了之前的排查套路,所以我们自然的,仍然断点到checkThread方法中,看看为什么没有发生异常。这时候我们惊奇的发现,修改了text后,checkThread方法并没有被执行。甚至继续追查下去,发现ViewGroup的requestLayout方法都没有被执行。
这又是为什么呢?
3.checkForRelayout检查
这次我们从前向后排查,最终定位到了textView的checkForRelayout方法。
setText()->checkForRelayout();
@UnsupportedAppUsage
private void checkForRelayout() {
...
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;
}
// Dynamic height, but height has stayed the same,
// so use our new text layout.
if (mLayout.getHeight() == oldht
&& (mHintLayout == null || mHintLayout.getHeight() == oldht)) {
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();
}
}
我们看代码有两处直接return了,所以并没有执行后面的requestLayout方法。
简单看一下判断条件后我们可以知道,如果宽度不是自适应宽度,并且当前不是跑马灯效果的情况下,存在两种情况直接会直接return:
1.高度写死。
2.高度相对于设置之前没有变化。
哦吼,知道了,细想一下也可以理解,既然高度没有变化,宽度自身变化又不影响外界,所以自己刷新就好了,就没必要通知父级也随之刷新了。因此,子线程刷新也不会有问题。
2022年7月31日补充:
还是上面那个例子,我们在极限一点,即然高度没有变化就不会出问题,那我们就高度变化一下试一下呢?把本来一行显示的长度加长到2行。
最终实验结果果然是崩溃了。安卓的渲染体系,针对自身进行判断,一旦调用了requestLayout方法,就会开始逐级上报,一直通知到最上层ViewRootImpl,然后再由ViewRootImpl发起整个绘制的流程。也就是说哪怕一共5个层级(ViewRootImpl,DecorView,ContentView,LinearLayout,TextView),仅仅只是下面两级的内部发生变化,也会导致整颗渲染树进行完整的绘制流程,这一点,我觉得其实是不好的。
结论:
通过对这三种情况的分析,我们可以得出这样一个结论, 子线程操作UI报不报错,其实关键进行checkThread方法有没有被执行,执行的结果如何。(PS:有点废话的感觉,但结论就是这样)
google之所以设计UI线程才能操作UI,怕的就是也是多线程操作UI会出现各种显示的异常。如果界面还没有显示,或者影响的范围只是自身,那么相互之间就不会有影响,所以就没必要进行UI线程检查。
本文示例代码链接:
android_all_demo/ThreadRefreshActivity.kt at master · aa5279aa/android_all_demo · GitHub