视频已解码却黑屏3秒?一个apply()在onDestroy()中的致命陷阱

33 阅读7分钟

引言

在Android车机开发中,我们遇到了一个看似简单却颇为隐蔽的问题:节日彩蛋视频播放时,CSD(中控屏)没有立即显示画面,用户看到了约3秒的黑屏。这个问题的表面现象很简单,但背后的原理涉及Android的SharedPreferences机制、QueuedWork工作队列、以及视频渲染管道。

本文将详细记录这个问题的完整分析过程,包括我最初的错误判断和最终的正确诊断,希望能给大家提供一个真实的问题排查思路。

问题现象

问题描述: 播放节日彩蛋视频时,CSD没有立即进入播放界面,出现黑屏现象
发生时间: 2026-01-04 16:22左右
问题频率: 偶现
影响范围: 用户体验受损,彩蛋功能效果大打折扣

初步分析(错误的方向)

拿到这个问题后,我的第一反应是查看日志中是否有重复调用或者Activity生命周期异常。通过grep搜索日志,我发现了这样的信息:

# 搜索SurpriseManager的executeSurprise调用
grep "executeSurprise" aplog* | grep -i "16:22"

日志显示在短时间内executeSurprise被调用了4次,每次都会创建DialogActivity。基于此,我得出了初步结论:

错误结论:

  • 根因:SurpriseManager缺少去重/防抖机制
  • 现象:DialogActivity被重复创建4次,覆盖了CSDVideoActivity
  • 解决方案:在SurpriseManager中添加防重复执行逻辑

这个分析看起来很合理,DialogActivity确实被多次创建了,但问题是——这不是根本原因

关键证据浮现

在深入思考调整分析方向后,找到了关键的日志证据:

01-04 16:22:38.777 16942 16942 W .auto.eastereg: Long monitor contention with owner queued-work-looper (17018)
    at void android.app.QueuedWork.processPendingWork()(QueuedWork.java:273)
    waiters=0 in void android.app.QueuedWork.processPendingWork() for 2.746s

01-04 16:22:38.800 16942 16942 I Choreographer: Skipped 162 frames!
    The application may be doing too much work on its main thread.

这两条日志彻底颠覆了我的分析。让我们来仔细解读:

关键信息解读

  1. Long monitor contention: 主线程在QueuedWork.processPendingWork()处被阻塞了2.746秒
  2. Skipped 162 frames: Choreographer跳过了162帧
  3. 数学验证: 2.746秒 × 60fps = 164.76帧 ≈ 162帧 ✓

这个计算完美吻合!说明主线程确实被阻塞了将近3秒。

正确的根因分析

QueuedWork阻塞机制

让我们通过流程图来理解这个问题:

case7-queuedwork-blocking-diagram.png

图表说明:展示了从DialogActivity.onDestroy()调用到主线程阻塞的完整流程

问题代码定位

// DialogActivity.java - 问题代码
@Override
protected void onDestroy() {
    super.onDestroy();
    SharedPreferences prefs = getSharedPreferences("easter_egg", MODE_PRIVATE);
    SharedPreferences.Editor editor = prefs.edit();
    editor.putBoolean("played", true);
    editor.apply();  // ❌ 致命问题:在onDestroy中使用apply()
}

SharedPreferences.apply()的工作机制

SharedPreferences.apply()的设计初衷是异步写入,避免阻塞主线程:

// Android源码简化版
public void apply() {
    final MemoryCommitResult mcr = commitToMemory();

    // 1. 立即更新内存
    mcr.setDiskWriteResult(true, true);

    // 2. 异步写入磁盘
    QueuedWork.addFinisher(writtenToDiskLatch);
    Runnable awaitCommit = new Runnable() {
        public void run() {
            mcr.writtenToDiskLatch.await();  // 等待写入完成
        }
    };
    QueuedWork.queue(awaitCommit, false);
}

看起来很美好,但陷阱在这里:

// ActivityThread.java
private void handleDestroyActivity(IBinder token, boolean finishing, ...) {
    // ... Activity销毁逻辑

    // 关键:强制等待所有pending工作完成!
    QueuedWork.waitToFinish();  // ← 这里会阻塞主线程!
}

这就是问题所在:当Activity销毁时,Android Framework会调用QueuedWork.waitToFinish(),强制等待所有pending的异步操作完成,包括我们的apply()!

视频渲染管道分析

为什么视频已经解码了,却无法显示呢?让我们看看视频渲染的架构:

case7-video-pipeline.png

图表说明:视频解码在后台线程正常运行,但帧渲染必须由主线程的Choreographer执行

关键点:

  • 视频解码: 在后台线程执行(MediaCodec),不受主线程阻塞影响 ✓
  • 帧渲染: 必须由主线程的Choreographer执行,需要在vsync信号时渲染 ✗
  • 问题根源: 主线程被阻塞,无法执行Choreographer的vsync回调,导致解码完成的帧无法送显

完整时间线

case7-timeline.png

图表说明:展示2.746秒阻塞期间的关键时间点

解决方案

方案1: 移到后台线程(不推荐)

@Override
protected void onDestroy() {
    super.onDestroy();
    new Thread(() -> {
        SharedPreferences prefs = getSharedPreferences("easter_egg", MODE_PRIVATE);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putBoolean("played", true);
        editor.commit();  // 在后台线程使用commit()
    }).start();
}

问题:

  • Activity已销毁,Context可能失效
  • 线程管理增加复杂度
  • 无法保证一定写入成功

方案2: 提前保存(推荐)

@Override
protected void onPause() {
    super.onPause();
    // ✅ 在onPause中保存,此时apply()是安全的
    SharedPreferences prefs = getSharedPreferences("easter_egg", MODE_PRIVATE);
    SharedPreferences.Editor editor = prefs.edit();
    editor.putBoolean("played", true);
    editor.apply();  // 安全使用apply()
}

优点:

  • onPause()时Activity仍然活跃,apply()异步写入不会被强制等待
  • 符合Android生命周期最佳实践
  • 代码简洁,无需额外线程管理

方案3: 使用DataStore(长期方案)

// 使用Kotlin Coroutines + DataStore
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "easter_egg")

class DialogActivity : AppCompatActivity() {
    override fun onPause() {
        super.onPause()
        lifecycleScope.launch {
            dataStore.edit { preferences ->
                preferences[booleanPreferencesKey("played")] = true
            }
        }
    }
}

优点:

  • 完全异步,基于Kotlin协程
  • 不会阻塞主线程
  • 类型安全
  • Google官方推荐的替代方案

经验总结

技术要点

  1. SharedPreferences.apply()陷阱:

    • 设计上是异步的,但在Activity生命周期关键点会被强制同步
    • 永远不要在onDestroy()中使用apply()
    • 推荐在onPause()或更早的时机保存数据
  2. 视频渲染原理:

    • 视频解码(后台线程) != 视频渲染(主线程)
    • Choreographer负责帧渲染,依赖vsync信号
    • 主线程阻塞会导致已解码帧无法送显
  3. QueuedWork机制:

    • Android的内部工作队列
    • 在特定生命周期点会强制等待所有pending工作
    • 是性能问题的常见隐藏点

问题排查思路

  1. 不要被表象迷惑: 我最初关注DialogActivity重复创建,但这不是根本原因
  2. 关注关键日志: "Long monitor contention"和"Skipped frames"是性能问题的明确信号
  3. 数学验证: 2.746秒 × 60fps ≈ 162帧,数据吻合证明分析正确
  4. 理解系统机制: 了解SharedPreferences、QueuedWork、Activity生命周期的内部实现

最佳实践

// ✅ 好的实践
class MyActivity extends AppCompatActivity {
    @Override
    protected void onPause() {
        super.onPause();
        // 在onPause保存状态
        savePreferences();
    }

    private void savePreferences() {
        SharedPreferences.Editor editor = prefs.edit();
        editor.putBoolean("key", value);
        editor.apply();  // 安全
    }
}

// ❌ 坏的实践
class MyActivity extends AppCompatActivity {
    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 危险!可能阻塞主线程
        prefs.edit().putBoolean("key", value).apply();
    }
}

常见问题

Q: 为什么不直接用commit()而用apply()?

A: commit()是同步的,会立即阻塞当前线程直到写入完成。apply()在onPause()等安全时机使用是最优选择,既保证写入,又不阻塞UI。

Q: 所有生命周期方法都不能用apply()吗?

A: 不是。onPause(), onStop()中使用是安全的。危险的是onDestroy()和某些特殊场景(如进程即将被杀死时)。

Q: DataStore比SharedPreferences好在哪?

A: DataStore基于Kotlin协程和Flow,完全异步,不会有阻塞问题。它是Google官方推荐的替代方案,但需要引入额外依赖。

总结

这个问题给我们的启示是:

  1. 性能问题往往隐藏在不起眼的API调用中 - 谁能想到一个简单的apply()会导致3秒黑屏?
  2. 理解底层机制至关重要 - 如果不了解QueuedWork和Activity生命周期的关系,很难定位这类问题
  3. 日志是最好的证据 - "Long monitor contention"和"Skipped frames"直接指向了根本原因
  4. 遵循最佳实践 - 在正确的生命周期方法中保存数据,避免在onDestroy()做耗时操作

希望这个实战案例能帮助大家在遇到类似问题时,快速定位根因并找到解决方案!

更多实战案例


如有疑问或想深入讨论,欢迎留言交流!

本文基于真实案例整理,部分敏感信息已脱敏处理。