在 AsyncTask doInBackground() 中修改视图不会(总是)抛出异常

Modifying views in AsyncTask doInBackground() does not (always) throw exception

我刚刚在尝试一些示例代码时遇到了一些意外行为。

As "everybody knows" 你不能修改来自另一个线程的 UI 元素,例如AsyncTask.

doInBackground()

例如:

public class MainActivity extends Activity {
    private TextView tv;

    public class MyAsyncTask extends AsyncTask<TextView, Void, Void> {
        @Override
        protected Void doInBackground(TextView... params) {
            params[0].setText("Boom!");
            return null;
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LinearLayout layout = new LinearLayout(this);
        tv = new TextView(this);
        tv.setText("Hello world!");
        Button button = new Button(this);
        button.setText("Click!");
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                new MyAsyncTask().execute(tv);
            }
        });
        layout.addView(tv);
        layout.addView(button);
        setContentView(layout);
    }
}

如果你 运行 这个,然后点击按钮,你的应用程序将按预期停止,你会在 logcat 中找到以下堆栈跟踪:

11:21:36.630: E/AndroidRuntime(23922): FATAL EXCEPTION: AsyncTask #1
...
11:21:36.630: E/AndroidRuntime(23922): java.lang.RuntimeException: An error occured while executing doInBackground()
...
11:21:36.630: E/AndroidRuntime(23922): Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
11:21:36.630: E/AndroidRuntime(23922): at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:6357)

到目前为止一切顺利。

现在我将 onCreate() 更改为立即执行 AsyncTask,而不是等待按钮单击。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // same as above...
    new MyAsyncTask().execute(tv);
}

应用程序没有关闭,日志中没有任何内容,TextView 现在屏幕上显示 "Boom!"。哇。没想到。

也许在 Activity 生命周期中还为时过早?让我们将执行移动到 onResume().

@Override
protected void onResume() {
    super.onResume();
    new MyAsyncTask().execute(tv);
}

与上述行为相同。

好的,让我们把它贴在 Handler 上。

@Override
protected void onResume() {
    super.onResume();
    Handler handler = new Handler();
    handler.post(new Runnable() {
        @Override
        public void run() {
            new MyAsyncTask().execute(tv);
        }
    });
}

同样的行为。我 运行 没有想法,尝试 postDelayed() 延迟 1 秒:

@Override
protected void onResume() {
    super.onResume();
    Handler handler = new Handler();
    handler.postDelayed(new Runnable() {
        @Override
        public void run() {
            new MyAsyncTask().execute(tv);
        }
    }, 1000);
}

终于!预期异常:

11:21:36.630: E/AndroidRuntime(23922): Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

哇,这跟时间有关?

我尝试了不同的延迟,似乎对于这个特定的测试 运行,在这个特定的设备(Nexus 4,运行ning 5.1)上,幻数是 60 毫秒,即有时会抛出异常,有时它会更新 TextView 就好像什么都没发生一样。

我假设在 AsyncTask 修改视图层次结构时尚未完全创建视图层次结构时会发生这种情况。这个对吗?有更好的解释吗? Activity 上是否有回调可用于确保已完全创建视图层次结构?与时间相关的问题很可怕。

我在这里发现了一个类似的问题Altering UI thread's Views in AsyncTask in doInBackground, CalledFromWrongThreadException not always thrown但是没有解释。

更新:

由于评论中的请求和建议的答案,我添加了一些调试日志记录以确定事件链...

public class MainActivity extends Activity {
    private TextView tv;

    public class MyAsyncTask extends AsyncTask<TextView, Void, Void> {
        @Override
        protected Void doInBackground(TextView... params) {
            Log.d("MyAsyncTask", "before setText");
            params[0].setText("Boom!");
            Log.d("MyAsyncTask", "after setText");
            return null;
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LinearLayout layout = new LinearLayout(this);
        tv = new TextView(this);
        tv.setText("Hello world!");
        layout.addView(tv);
        Log.d("MainActivity", "before setContentView");
        setContentView(layout);
        Log.d("MainActivity", "after setContentView, before execute");
        new MyAsyncTask().execute(tv);
        Log.d("MainActivity", "after execute");
    }
}

输出:

10:01:33.126: D/MainActivity(18386): before setContentView
10:01:33.137: D/MainActivity(18386): after setContentView, before execute
10:01:33.148: D/MainActivity(18386): after execute
10:01:33.153: D/MyAsyncTask(18386): before setText
10:01:33.153: D/MyAsyncTask(18386): after setText

一切如预期,这里没有异常,setContentView() 在调用 execute() 之前完成,而后者又在从 doInBackground() 调用 setText() 之前完成。所以不是这样。

更新:

另一个例子:

public class MainActivity extends Activity {
    private LinearLayout layout;
    private TextView tv;

    public class MyAsyncTask extends AsyncTask<Void, Void, Void> {
        @Override
        protected Void doInBackground(Void... params) {
            tv.setText("Boom!");
            return null;
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        layout = new LinearLayout(this);
        Button button = new Button(this);
        button.setText("Click!");
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                tv = new TextView(MainActivity5.this);
                tv.setText("Hello world!");
                layout.addView(tv);
                new MyAsyncTask().execute();
            }
        });
        layout.addView(button);
        setContentView(layout);
    }
}

这一次,我在 ButtononClick() 中添加 TextView,紧接着在 AsyncTask 上调用 execute()。在此阶段,初始 Layout(没有 TextView)已正确显示(即我可以看到按钮并单击它)。同样,没有抛出异常。

而反例,如果我在 doInBackground() 中的 setText() 之前将 Thread.sleep(100); 添加到 execute() 中,通常会抛出异常。

我刚刚注意到的另一件事是,就在抛出异常之前,TextView 的文本实际上已更新并正确显示,只有一瞬间,直到应用程序关闭自动。

我想我的 TextView 一定发生了某些事情(异步,即脱离任何生命周期 methods/callbacks),不知何故 "attaches" 它变成了 ViewRootImpl,这使得后者抛出异常。有没有人有关于 "something" 是什么的解释或指向进一步文档的指针?

如果 TextView 还不是 GUI 的一部分,您可以在 doInBackground 中写入它。

它只是声明setContentView(layout);后GUI的一部分。

只是我的想法。

ViewRootImpl.java的checkThread()方法负责抛出这个异常。 使用成员 mHandlingLayoutInLayoutRequest 抑制此检查,直到 performLayout() 即所有初始绘图遍历都已完成。

因此只有当我们使用延迟时它才会抛出异常。

不确定这是 android 中的错误还是故意的:)

根据 RocketRandom 的回答,我进行了更多挖掘并提出了一个更全面的答案,我认为这里有必要。

负责最终异常的确实是 ViewRootImpl.checkThread(),它在调用 performLayout() 时被调用。 performLayout() 在视图层次结构中向上移动,直到它最终在 ViewRootImpl 中结束,但它起源于 TextView.checkForRelayout(),它被 setText() 调用。到目前为止,一切都很好。那么为什么我们调用 setText() 时有时不会抛出异常?

TextView.checkForRelayout() 仅在 TextView 已经有 Layout (mLayout != null) 时调用。 (此检查是阻止在这种情况下抛出异常的原因,而不是 ViewRootImpl 中的 mHandlingLayoutInLayoutRequest。)

那么,为什么 TextView 有时 而不是 Layout?或者更好,因为很明显它一开始没有,它是什么时候从哪里得到的?

TextView 最初使用 layout.addView(tv); 添加到 LinearLayout 时,再次调用 requestLayout() 链,向上移动 View层次结构,结束于 ViewRootImpl,这次没有抛出异常,因为我们仍在 UI 线程上。这里,ViewRootImpl 然后调用 scheduleTraversals().

这里的重要部分是这会将回调/Runnable 发送到 Choreographer 消息队列,该消息队列被处理 "asynchronously" 到主要执行流程:

mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

Choreographer 最终将使用 Handler 和 运行 处理此问题,无论 Runnable ViewRootImpl 已在此处发布,最终将调用 performTraversals()measureHierarchy()performMeasure()(在 ViewRootImpl 上),这将执行进一步的一系列 View.measure()onMeasure() 调用(以及其他一些调用),沿着 View 层次结构向下移动,直到它最终到达我们的 TextView.onMeasure(),它调用 makeNewLayout(),它调用 makeSingleLayout(),它最终设置我们的 mLayout 成员变量:

mLayout = makeSingleLayout(wantWidth, boring, ellipsisWidth, alignment, shouldEllipsize,
            effectiveEllipsize, effectiveEllipsize == mEllipsize);

发生这种情况后,mLayout 不再为 null,任何修改 TextView 的尝试,即在我们的示例中调用 setText(),将导致井已知 CalledFromWrongThreadException.

所以我们这里有一个很好的小竞争条件,如果我们的 AsyncTask 可以在 Choreographer 遍历完成之前得到 TextView,它可以修改它没有处罚。当然这仍然是不好的做法,不应该这样做(有很多其他 SO 帖子处理这个),但是 如果 这是意外或不知不觉地完成的, CalledFromWrongThreadException 不是一个完美的保护。

这个人为的例子使用了 TextView 并且细节可能因其他视图而异,但一般原则保持不变。是否有其他 View 实现(可能是自定义实现)在每种情况下都不会调用 requestLayout() 是否可以在没有惩罚的情况下进行修改,这可能会导致更大的(隐藏的)问题,这还有待观察。 =63=]