Изменение вида в AsyncTask doInBackground() не исключает (всегда) исключение

Я просто столкнулся с каким-то неожиданным поведением при игре с некоторым примером кода.

Как "все знают", вы не можете изменять элементы пользовательского интерфейса из другого потока, например. doInBackground() AsyncTask.

Например:

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: Произошла ошибка при выполнении doInBackground()
...
11: 21: 36.630: E/AndroidRuntime (23922): вызвано: android.view.ViewRootImpl $CalledFromWrongThreadException: только исходный поток, создавший иерархию представлений, может коснуться его представлений. 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: только исходный поток, создавший иерархию представлений, может коснуться его представлений.

Ничего себе, это связано с хронологией?

Я пробую разные задержки и кажется, что для этого конкретного тестового прогона на этом конкретном устройстве (Nexus 4, работает 5.1) магическое число составляет 60 мс, то есть иногда выдает исключение, иногда оно обновляет TextView, как будто ничего не случилось.

Я предполагаю, что это происходит, когда иерархия представлений не была полностью создана в точке, где она была изменена с помощью AsyncTask. Это верно? Есть ли для этого лучшее объяснение? Есть ли обратный вызов на Activity, который можно использовать, чтобы убедиться, что иерархия представления полностью создана? Сроки связаны с проблемами.

Я нашел аналогичный вопрос здесь Изменяет представления потоков пользовательского интерфейса в AsyncTask в doInBackground, CalledFromWrongThreadException не всегда выбрасывается, но объяснений нет.

Update:

Из-за запроса в комментариях и предлагаемого ответа я добавил несколько протоколов отладки, чтобы выяснить цепочку событий...

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): до setContentView
10: 01: 33.137: D/MainActivity (18386): после setContentView перед выполнением 10: 01: 33.148: D/MainActivity (18386): после выполнения 10: 01: 33.153: D/MyAsyncTask (18386): перед setText
10: 01: 33.153: D/MyAsyncTask (18386): после setText

Все, как ожидалось, ничего необычного здесь, setContentView() завершено до того, как вызывается execute(), который, в свою очередь, завершается до того, как setText() вызывается из doInBackground(). Так что не это.

Update:

Другой пример:

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);
    }
}

На этот раз я добавляю TextView в onClick() Button непосредственно перед вызовом execute() на AsyncTask. На этом этапе исходный Layout (без TextView) отображается правильно (т.е. Я вижу кнопку и нажимаю ее). Опять же, никакого исключения не было.

И пример счетчика, если я добавлю Thread.sleep(100); в execute() до setText() в doInBackground(), выдается обычное исключение.

Еще одна вещь, которую я только что заметил сейчас, заключается в том, что перед тем, как исключение будет выброшено, текст TextView фактически обновляется и отображается правильно, всего за несколько секунд, пока приложение не закроется автоматически.

Я предполагаю, что что-то должно происходить (асинхронно, т.е. отделяться от любых методов/обратных вызовов жизненного цикла) до моего TextView, который каким-то образом "прикрепляет" его к ViewRootImpl, что заставляет последнее генерировать исключение. У кого-нибудь есть объяснение или указатели на дальнейшую документацию о том, что это "что-то"?

Ответ 1

Основываясь на ответе RocketRandom, я сделал еще несколько копаний и придумал более полный ответ, который мне кажется оправданным.

Ответственный за возможное исключение действительно ViewRootImpl.checkThread(), который вызывается при вызове performLayout(). performLayout() перемещает иерархию представления до тех пор, пока она не закончится в ViewRootImpl, но она возникнет в TextView.checkForRelayout(), которая вызывается setText(). Все идет нормально. Итак, почему исключение иногда не возникает, когда мы вызываем setText()?

TextView.checkForRelayout() вызывается только в том случае, если TextView уже имеет Layout (mLayout != null). (Эта проверка запрещает исключение из этого исключения, а не mHandlingLayoutInLayoutRequest в ViewRootImpl.)

Итак, опять же, почему TextView иногда не имеет Layout? Или лучше, поскольку, очевидно, у него начинается не наличие одного, когда и откуда оно получается?

Когда TextView изначально добавляется к LinearLayout с помощью layout.addView(tv);, снова вызывается цепочка из requestLayout(), перемещаясь вверх по иерархии View, заканчивая в ViewRootImpl, где это время, исключение не выбрасывается, потому что мы все еще находимся в потоке пользовательского интерфейса. Здесь ViewRootImpl затем вызывает scheduleTraversals().

Важная часть здесь заключается в том, что это сообщение обратного вызова / Runnable в очереди сообщений Choreographer, которое обрабатывается "асинхронно" с основным потоком выполнения:

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 может получить руки TextView до завершения обходов Choreographer, он может изменить его без штрафов. Конечно, это по-прежнему плохая практика, и ее не следует делать (есть много других сообщений SO, посвященных этому), но если это делается случайно или неосознанно, CalledFromWrongThreadException не является идеальной защитой.

В этом надуманном примере используется TextView, и детали могут отличаться для других представлений, но общий принцип остается тем же. Остается выяснить, может ли какая-либо другая реализация View (возможно, пользовательская), которая не вызывает requestLayout(), в каждом случае может быть изменена без штрафов, что может привести к большим (скрытым) проблемам.

Ответ 2

Метод checkThread() для ViewRootImpl.java отвечает за исключение этого исключения. Эта проверка подавляется с использованием члена mHandlingLayoutInLayoutRequest до выполнения функцииLayout(), то есть все начальные обходы чертежа завершены.

поэтому он выдает исключение, только если мы используем задержку.

Не уверен, что это ошибка в андроиде или намеренно:)

Ответ 3

Вы можете писать в doInBackground в TextView, если он еще не является частью GUI.

Это только часть графического интерфейса после оператора setContentView(layout);.

Просто моя мысль.