Другое

Объяснение исключения безопасности потоков Android TextView

Узнайте, почему Android TextView вызывает исключение CalledFromWrongThreadException при обновлении из фоновых потоков, в то время как SeekBar работает нормально. Узнайте о решениях безопасности потоков и лучших практиках для музыкальных приложений.

Android “CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views” - Почему обновление TextView из фонового потока вызывает это исключение, в то время как обновление SeekBar работает нормально?

Я разрабатываю музыкальный плеер для Android и столкнулся с проблемой при попытке обновить TextView из фонового потока. Вот моя реализация:

java
public class Song extends Activity implements OnClickListener,Runnable {
    private SeekBar progress;
    private TextView currentTime;
    private MediaPlayer mp;
    private MPService appService;

    // Реализация подключения к сервису
    private ServiceConnection onService = new ServiceConnection() {
        public void onServiceConnected(ComponentName className, IBinder rawBinder) {
            appService = ((MPService.LocalBinder)rawBinder).getService();
            progress.setVisibility(SeekBar.VISIBLE);
            progress.setProgress(0);
            mp = appService.getMP();
            appService.playSong(title);
            progress.setMax(mp.getDuration());
            new Thread(Song.this).start();
        }
        public void onServiceDisconnected(ComponentName classname) {
            appService = null;
        }
    };

    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.song);
        
        progress = (SeekBar) findViewById(R.id.progress);
        currentTime = (TextView) findViewById(R.id.current_time);
    }

    public void run() {
        int pos = 0;
        int total = mp.getDuration();
        while (mp != null && pos < total) {
            try {
                Thread.sleep(1000);
                pos = appService.getSongPosition();
            } catch (InterruptedException e) {
                return;
            } catch (Exception e) {
                return;
            }
            progress.setProgress(pos); // Это работает нормально
            
            // Эта строка вызывает исключение:
            String time = String.format("%d:%d",
                TimeUnit.MILLISECONDS.toMinutes(pos),
                TimeUnit.MILLISECONDS.toSeconds(pos),
                TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(pos))
            );
            currentTime.setText(time); // android.view.ViewRoot$CalledFromWrongThreadException
        }
    }
}

Я обновляю и SeekBar, и TextView из одного и того же фонового потока, но только обновление TextView вызывает CalledFromWrongThreadException. Почему это происходит и как правильно обновлять TextView для отображения текущего времени воспроизведения?

Android CalledFromWrongThreadException возникает при обновлении TextView из фоновых потоков, потому что TextView строго обеспечивает безопасность потоков, проверяя текущий поток против исходного UI-потока, в то время как SeekBar имеет внутренние механизмы, которые обрабатывают синхронизацию потоков по-разному. Метод setText() TextView напрямую вызывает invalidate(), что запускает проверку валидации потока, тогда как setProgress() SeekBar может использовать разные внутренние механизмы, которые автоматически обрабатывают переключение потоков.

Содержание

Понимание CalledFromWrongThreadException

CalledFromWrongThreadException — это способ Android обеспечивать безопасность потоков при операциях с пользовательским интерфейсом. Когда вы создаете иерархию представлений в Android, она привязана к основному потоку пользовательского интерфейса (также называемому главным потоком или UI-потоком). В Android-фреймворке есть метод checkThread() в ViewRoot, который проверяет, соответствует ли текущий поток исходному потоку, создавшему иерархию представлений.

Согласно исходному коду Android, эта проверка выглядит так:

java
void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
            "Only the original thread that created a view hierarchy can touch its views.");
    }
}

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


Почему TextView вызывает ошибку, а SeekBar работает

Ключевое различие в поведении TextView и SeekBar в вашем коде заключается во внутренней реализации и способе обеспечения безопасности потоков:

Реализация TextView

Метод setText() TextView напрямую вызывает invalidate() для запроса перерисовки, что немедленно запускает проверку валидации потока в ViewRoot. Когда вы вызываете setText() из фонового потока, он не проходит проверку checkThread() и вызывает исключение CalledFromWrongThreadException.

Как отмечено в исследованиях, “SeekBar может работать нормально благодаря своему внутреннему механизму, который гарантирует обновления в главном потоке, но TextView вызовет исключение.”

Реализация SeekBar

Метод setProgress() SeekBar имеет другую внутреннюю обработку. Он может использовать post() или аналогичные механизмы для планирования обновлений UI в главном потоке, эффективно откладывая фактическое изменение UI до выполнения в правильном потоке. Именно поэтому ваш вызов progress.setProgress(pos) работает без вызова исключения.

Обсуждение на Reddit объясняет, что “TextView вызывает invalidate() на самом себе при изменении текста только при выполнении определенных условий”, что указывает на более прямую проверку потоков в TextView.


Решения для обновления TextView из фоновых потоков

Метод 1: Использование runOnUiThread()

Самый простой способ — обернуть обновление TextView в runOnUiThread():

java
public void run() {
    int pos = 0;
    int total = mp.getDuration();
    while (mp != null && pos < total) {
        try {
            Thread.sleep(1000);
            pos = appService.getSongPosition();
        } catch (InterruptedException e) {
            return;
        } catch (Exception e) {
            return;
        }
        
        // Обновление SeekBar (работает нормально)
        progress.setProgress(pos);
        
        // Форматирование строки времени
        String time = String.format("%d:%02d",
            TimeUnit.MILLISECONDS.toMinutes(pos),
            TimeUnit.MILLISECONDS.toSeconds(pos) % 60
        );
        
        // Обновление TextView в главном потоке
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                currentTime.setText(time);
            }
        });
    }
}

Метод 2: Использование Handler

Для более сложных сценариев можно использовать Handler:

java
private Handler handler = new Handler(Looper.getMainLooper());

public void run() {
    // ... существующий код ...
    
    handler.post(new Runnable() {
        @Override
        public void run() {
            currentTime.setText(time);
        }
    });
}

Метод 3: Использование View.post()

Также можно использовать метод post() View:

java
public void run() {
    // ... существующий код ...
    
    currentTime.post(new Runnable() {
        @Override
        public void run() {
            currentTime.setText(time);
        }
    });
}

Лучшие практики для реализации музыкального плеера

1. Использование AsyncTask или HandlerThread

Для современной разработки Android рекомендуется использовать AsyncTask (устарел в API 30) или HandlerThread:

java
private HandlerThread progressThread;
private Handler progressHandler;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.song);
    
    progress = (SeekBar) findViewById(R.id.progress);
    currentTime = (TextView) findViewById(R.id.current_time);
    
    // Настройка отдельного потока для обновления прогресса
    progressThread = new HandlerThread("ProgressThread");
    progressThread.start();
    progressHandler = new Handler(progressThread.getLooper());
}

public void updateProgress() {
    progressHandler.post(new Runnable() {
        @Override
        public void run() {
            if (mp != null && mp.isPlaying()) {
                int pos = mp.getCurrentPosition();
                progress.setProgress(pos);
                
                String time = String.format("%d:%02d",
                    TimeUnit.MILLISECONDS.toMinutes(pos),
                    TimeUnit.MILLISECONDS.toSeconds(pos) % 60
                );
                
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        currentTime.setText(time);
                    }
                });
            }
        }
    });
}

2. Использование CountDownTimer для обновления времени

Для обновлений, основанных на времени, CountDownTimer часто более подходит:

java
private CountDownTimer progressTimer;

private void startProgressTimer() {
    progressTimer = new CountDownTimer(mp.getDuration(), 1000) {
        public void onTick(long millisUntilFinished) {
            int pos = (int) (mp.getDuration() - millisUntilFinished);
            progress.setProgress(pos);
            
            String time = String.format("%d:%02d",
                TimeUnit.MILLISECONDS.toMinutes(pos),
                TimeUnit.MILLISECONDS.toSeconds(pos) % 60
            );
            currentTime.setText(time);
        }

        public void onFinish() {
            progress.setProgress(mp.getDuration());
            currentTime.setText("0:00");
        }
    }.start();
}

3. Использование OnSeekCompleteListener MediaPlayer

Для лучшей интеграции с MediaPlayer:

java
mp.setOnSeekCompleteListener(new MediaPlayer.OnSeekCompleteListener() {
    @Override
    public void onSeekComplete(MediaPlayer mp) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                currentTime.setText(formatTime(mp.getCurrentPosition()));
            }
        });
    }
});

Альтернативные подходы

1. Использование RxJava для управления потоками

Для более сложных приложений рассмотрите использование RxJava:

java
Observable.interval(1, TimeUnit.SECONDS, Schedulers.io())
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(tick -> {
        if (mp != null && mp.isPlaying()) {
            int pos = mp.getCurrentPosition();
            progress.setProgress(pos);
            currentTime.setText(formatTime(pos));
        }
    });

2. Использование LiveData с ViewModel

Для современной архитектуры (AndroidX):

java
public class MusicPlayerViewModel extends ViewModel {
    private MutableLiveData<Integer> currentPosition = new MutableLiveData<>();
    private MutableLiveData<String> currentTimeString = new MutableLiveData<>();

    public void updateProgress(int pos) {
        currentPosition.setValue(pos);
        currentTimeString.setValue(formatTime(pos));
    }
}

// В Activity
viewModel.currentPosition.observe(this, pos -> {
    progress.setProgress(pos);
});

viewModel.currentTimeString.observe(this, time -> {
    currentTime.setText(time);
});

Распространенные ошибки и их предотвращение

1. Утечки памяти

Будьте осторожны, чтобы не создавать утечки памяти, удерживая ссылки на Activities или Views. Всегда очищайте handlers и потоки в onDestroy():

java
@Override
protected void onDestroy() {
    super.onDestroy();
    if (progressHandler != null) {
        progressHandler.removeCallbacksAndMessages(null);
    }
    if (progressThread != null) {
        progressThread.quit();
    }
}

2. Условия гонки

Обеспечьте правильную синхронизацию при доступе к общим ресурсам:

java
private final Object lock = new Object();

synchronized(lock) {
    pos = appService.getSongPosition();
}

3. Блокировка UI-потока

Избегайте выполнения тяжелых операций в UI-потоке. Даже при использовании runOnUiThread() держите операции легкими:

java
// Хорошо - легкая операция
runOnUiThread(() -> currentTime.setText("0:00"));

// Плохо - тяжелая операция, которая может заблокировать UI
runOnUiThread(() -> {
    // Сложные вычисления или сетевые операции
    String result = performHeavyCalculation();
    currentTime.setText(result);
});

4. Правильная обработка ошибок

Реализуйте правильную обработку ошибок для операций, связанных с потоками:

java
try {
    progressHandler.post(() -> {
        try {
            currentTime.setText(time);
        } catch (Exception e) {
            Log.e("MusicPlayer", "Ошибка обновления UI", e);
        }
    });
} catch (Exception e) {
    Log.e("MusicPlayer", "Ошибка отправки в handler", e);
}

Понимая механизмы безопасности потоков в Android и реализуя правильные техники синхронизации, вы можете создавать надежные музыкальные плееры, которые корректно обрабатывают обновления UI из разных потоков.

Заключение

  • TextView вызывает CalledFromWrongThreadException, потому что он напрямую проверяет текущий поток против исходного UI-потока, в то время как SeekBar использует внутренние механизмы для обработки синхронизации потоков
  • Ключевое решение — использовать runOnUiThread(), Handler.post() или View.post() для обеспечения обновления TextView в главном потоке
  • Для реализации музыкальных плееров рассмотрите использование отдельных HandlerThread, CountDownTimer или современных компонентов архитектуры, таких как LiveData
  • Всегда правильно обрабатывайте жизненный цикл потоков для предотвращения утечек памяти и условий гонки
  • Выбирайте подходящий подход к потокам в зависимости от сложности вашего приложения и требований версии Android

Источники

  1. Android “Only the original thread that created a view hierarchy can touch its views” - Stack Overflow
  2. Resolving “Only the original thread that created a view hierarchy can touch its views” in Android - Repeato
  3. Android “Only the original thread that created a view hierarchy can touch its views” - SyntaxBook
  4. Why i’m getting exception ViewRootImpl$CalledFromWrongThreadException? - Stack Overflow
  5. r/androiddev on Reddit: Why no error : Only the original thread that created a view hierarchy can touch its views
  6. CalledFromWrongThreadException when adding TextView - Stack Overflow
  7. Android Non-UI to UI Thread Communications - Intertech
  8. Handler changing UI causes CalledFromWrongThreadException - Stack Overflow
Авторы
Проверено модерацией
Модерация