返回

修复Android Automotive协程UI刷新慢半拍问题

Android

别等了!修复 Android Automotive 协程请求后 UI 刷新慢半拍的问题

搞 Android Automotive 开发的时候,你可能遇到过这么个怪事:用 Kotlin 协程(Coroutine)从后台请求了点数据,比如用 Retrofit 去抓个网页内容,想着拿到数据后赶紧更新界面,给用户展示出来。代码里明明一切顺利,数据秒回,invalidate() 也乖乖调用了,可界面就是纹丝不动,非得等上个大概 10 秒钟,才慢悠悠地把新内容显示出来。这感觉,就像网购快递,明明显示“已签收”,但你还得等半天,快递小哥才敲门。

代码瞅着大概是这样婶儿的:先展示一个“加载中”的界面,然后启动协程去后台拉数据,拉到数据后切换到主线程,更新 Template 内容,最后调用 invalidate()

// Screen class
private var currentTemplate: Template? = null

override fun onGetTemplate(): Template {
    // 如果已经有更新好的 template,直接返回
    if (currentTemplate != null && screenState == ScreenState.Loaded) { // (假设有个 screenState 状态)
        return currentTemplate!!
    }

    // 初始加载状态
    val contentRow = Row.Builder()
        .setTitle(carContext.getString(R.string.loading))
        .build()
    val paneTemplate = PaneTemplate.Builder(
        Pane.Builder().addRow(contentRow).build()
    )
        .setHeaderAction(Action.BACK)
        .setTitle(carContext.getString(R.string.test))
        .build()

    // 只有在初始状态或者需要重新加载时才去获取数据
    if (currentTemplate == null) {
       currentTemplate = paneTemplate // 先把 Loading 界面存起来
       fetchContent() // 开始获取内容
    }

    return currentTemplate ?: paneTemplate // 返回当前的 template (可能是 loading 或已更新的)
}

// (状态管理的改进版本放后面讨论, 这里保持原始问题逻辑)
private fun fetchContent() {
    // 问题点:每次都创建新的 CoroutineScope
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val response = RetrofitClient.apiService.getGoogle() // 假设这是你的 Retrofit 请求
            if (response.isSuccessful) {
                val htmlContent = response.body() ?: "没拿到内容..."
                // 切换回主线程更新 UI 相关状态
                withContext(Dispatchers.Main) {
                    // 构建更新后的内容 Row
                    val updatedRow = Row.Builder()
                        .setTitle(carContext.getString(R.string.test)) // 假设标题也更新了
                        .addText(htmlContent) // 添加获取到的 HTML 文本
                        .build()

                    // 构建包含新 Row 的 Pane
                    val updatedPane = Pane.Builder()
                        .addRow(updatedRow)
                        .build()

                    // 创建最终的 PaneTemplate
                    val finalTemplate = PaneTemplate.Builder(updatedPane)
                        .setHeaderAction(Action.BACK)
                        .setTitle(carContext.getString(R.string.test))
                        .build()

                    // 更新 currentTemplate 引用
                    currentTemplate = finalTemplate
                    // screenState = ScreenState.Loaded // (配合状态管理)

                    // 通知系统模板内容变了,需要重新绘制
                    invalidate()
                    Log.d("ContentScreen", "内容已获取, invalidate() 已调用")
                }
            } else {
                // 处理请求失败的情况
                withContext(Dispatchers.Main) {
                    // ... 更新 Template 显示错误信息 ...
                    currentTemplate = buildErrorTemplate("请求失败: ${response.code()}")
                    invalidate()
                    Log.e("ContentScreen", "请求失败: ${response.code()}")
                }
            }
        } catch (e: Exception) {
            // 处理异常情况
            withContext(Dispatchers.Main) {
                // ... 更新 Template 显示错误信息 ...
                currentTemplate = buildErrorTemplate("发生错误: ${e.message}")
                invalidate()
                Log.e("ContentScreen", "发生异常", e)
            }
        }
    }
}

// 假设的错误模板构建函数
private fun buildErrorTemplate(errorMessage: String): Template {
     val errorRow = Row.Builder()
        .setTitle("出错啦")
        .addText(errorMessage)
        .build()
    return PaneTemplate.Builder(Pane.Builder().addRow(errorRow).build())
        .setHeaderAction(Action.BACK)
        .setTitle(carContext.getString(R.string.test))
        .build()
}


// Retrofit 相关设置 (仅为示例)
interface ApiService {
    @GET("/") // 假设获取 Google 首页 HTML
    suspend fun getGoogle(): Response<String>
}

object RetrofitClient {
    val apiService: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl("https://google.com")
            .addConverterFactory(ScalarsConverterFactory.create()) // 直接获取 String
            .build()
            .create(ApiService::class.java)
    }
}

// (假设的状态枚举)
enum class ScreenState { Loading, Loaded, Error }
private var screenState: ScreenState = ScreenState.Loading // 初始状态

试了各种招,什么 invalidate()、数据回来后 push 一个新界面再 pop 掉旧的,好像都不管用,那 10 秒的延迟跟定了你似的。

一、这该死的 10 秒延迟,到底哪来的?

这锅啊,还真不能全甩给你的协程或者 Retrofit。它们很可能干得挺好,数据嗖嗖地就回来了,主线程切换也没毛病。问题主要出在 Android Automotive 对界面刷新的特殊处理机制上。

核心原因:Android Automotive 系统对 invalidate() 的调用做了“节流”(Throttling)。

你想想,汽车中控屏不是手机,它的首要任务是保证行车安全。如果应用动不动就疯狂刷新界面,不仅可能影响系统性能,更重要的是,频繁变化的界面会分散驾驶员的注意力,带来安全隐患。

为了防止这种情况,Android Automotive 系统主机(Host)会对来自应用 Screeninvalidate() 请求进行限制。它不会在你调用 invalidate() 后立马就回来调用你的 onGetTemplate() 方法去获取新的 Template。而是会“攒”一下,或者说设置一个时间间隔(根据观察,这个间隔常常在 10 秒左右,但也可能因车型、系统版本而异),在这个间隔内即使你多次调用 invalidate(),系统也可能只响应一次,或者干脆等到间隔时间到了才来取新的界面内容。

这就是为啥你的日志里明明看到 invalidate() 执行了,数据也准备好了,但界面就是死活不动,得等那么一会儿才更新。它在等系统的“刷新许可”。

二、咋办?几种姿势解决或绕过它

知道了原因,就好对症下药了。下面提供几种思路:

方案一:拥抱现实,优化加载状态逻辑(推荐)

既然系统有限制,强扭的瓜不甜。最符合 Automotive 设计理念的方式是接受这个延迟,并确保你的加载状态和最终内容状态能够正确、平滑地展示。

原理:
onGetTemplate() 首次被调用时(或者需要展示加载状态时),立即返回一个明确的“加载中” Template。后台协程负责获取数据。数据获取成功后,更新内部持有的 Template 实例(或者更优的是,更新状态变量),然后调用 invalidate()。当系统因为 invalidate() 的调用(并且满足了它的节流条件)再次调用 onGetTemplate() 时,这次你的方法就能根据更新后的状态或实例,返回包含实际数据的 Template 了。

代码示例(改进版):

import androidx.lifecycle.lifecycleScope // 需要添加 lifecycle-runtime-ktx 依赖

// Screen class
// 使用 StateFlow 来管理状态和数据,更健壮
private val _screenState = MutableStateFlow<ScreenState>(ScreenState.Loading) // 初始为加载中
private val screenStateFlow: StateFlow<ScreenState> = _screenState.asStateFlow() // 对外暴露不可变 Flow

// 可以用来存储最终获取到的内容,避免每次在 onGetTemplate 中重复构建
private var loadedTemplate: Template? = null
private var loadingTemplate: Template? = null // 缓存加载模板

// 标记是否是首次加载,避免重复请求
private var isInitialLoad = true

// 在 Screen 创建时准备好加载模板
init {
    loadingTemplate = buildLoadingTemplate()
}

override fun onGetTemplate(): Template {
    // lifecycleScope 会在 Screen 销毁时自动取消协程
    // collect 只在 Activity/Fragment 的 resumed 状态执行,这里需要考虑 Screen 生命周期
    // 但 onGetTemplate 本身就是系统驱动的,我们可以在这里判断状态

    // 如果是首次加载且数据还没回来,显示加载中
    // 如果之后 invalidate 了,系统再次调用 onGetTemplate 时,状态可能已经改变
    if (isInitialLoad && screenStateFlow.value == ScreenState.Loading) {
        // 触发数据获取 (只在首次需要加载时触发)
        fetchContentIfNeeded()
        return loadingTemplate!! // 直接返回预先构建的加载模板
    }

    // 根据当前状态返回模板
    return when (screenStateFlow.value) {
        ScreenState.Loading -> loadingTemplate!! // 如果还在加载中 (例如网络慢),继续显示加载
        ScreenState.Loaded -> loadedTemplate ?: buildErrorTemplate("内容丢失") // 正常加载完成,返回已加载的模板
        ScreenState.Error -> loadedTemplate ?: buildErrorTemplate("加载出错") // 加载出错,显示错误模板 (也可以专门做个错误模板)
    }
}

// 只有在需要时才获取内容
private fun fetchContentIfNeeded() {
    if (isInitialLoad) {
        isInitialLoad = false // 标记已开始加载,防止重复调用
        // 使用 lifecycleScope,它与 Screen 的生命周期绑定
        lifecycleScope.launch(Dispatchers.IO) {
            Log.d("ContentScreen", "开始获取内容...")
            try {
                val response = RetrofitClient.apiService.getGoogle()
                if (response.isSuccessful) {
                    val htmlContent = response.body() ?: "没拿到内容..."
                    // 构建最终 Template (可以在这里构建,也可以惰性构建)
                    val finalTemplate = buildContentTemplate(htmlContent)

                    // 更新状态和数据,然后通知系统
                    withContext(Dispatchers.Main.immediate) { // 使用 immediate 避免不必要的调度延迟
                        loadedTemplate = finalTemplate
                        _screenState.value = ScreenState.Loaded // 更新状态
                        invalidate() // 通知系统:嘿,我的内容变了!
                        Log.d("ContentScreen", "内容获取成功, invalidate()")
                    }
                } else {
                     // 请求失败,更新状态和错误信息模板
                    val errorTemplate = buildErrorTemplate("请求失败: ${response.code()}")
                    withContext(Dispatchers.Main.immediate) {
                        loadedTemplate = errorTemplate // 用错误模板作为 "loaded" 状态的结果
                        _screenState.value = ScreenState.Error // 更新为错误状态
                        invalidate()
                         Log.e("ContentScreen", "请求失败: ${response.code()}, invalidate()")
                    }
                }
            } catch (e: Exception) {
                // 异常处理
                val errorTemplate = buildErrorTemplate("发生错误: ${e.message}")
                withContext(Dispatchers.Main.immediate) {
                    loadedTemplate = errorTemplate
                     _screenState.value = ScreenState.Error
                    invalidate()
                    Log.e("ContentScreen", "发生异常, invalidate()", e)
                }
            }
        }
    }
}

// 专门构建加载模板的函数
private fun buildLoadingTemplate(): Template {
    val contentRow = Row.Builder()
        .setTitle(carContext.getString(R.string.loading))
        .setIsLoading(true) // Automotive 提供专门的加载状态 Row
        .build()
    return PaneTemplate.Builder(Pane.Builder().addRow(contentRow).build())
        .setHeaderAction(Action.BACK)
        .setTitle(carContext.getString(R.string.test))
        .build()
}

// 专门构建内容模板的函数
private fun buildContentTemplate(htmlContent: String): Template {
    val updatedRow = Row.Builder()
        .setTitle(carContext.getString(R.string.test))
        .addText(htmlContent)
        .build()
    val updatedPane = Pane.Builder().addRow(updatedRow).build()
    return PaneTemplate.Builder(updatedPane)
        .setHeaderAction(Action.BACK)
        .setTitle(carContext.getString(R.string.test))
        .build()
}

// (ScreenState 枚举 和 RetrofitClient, ApiService 同上)
enum class ScreenState { Loading, Loaded, Error }

// 在 Screen 销毁时,lifecycleScope 会自动取消协程,无需手动管理 (如果 Screen 是 LifecycleOwner)
// 如果 Screen 不是 LifecycleOwner,则需要手动创建和取消 Scope
// private val screenScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
// override fun onDetached() {
//     super.onDetached()
//     screenScope.cancel() // 手动取消
// }

优点:

  • 符合 Android Automotive 的设计哲学,对系统友好。
  • 用户体验流畅,明确的加载状态->内容状态转换。
  • 代码结构更清晰,状态管理更可靠(特别是用了 StateFlow)。
  • 使用了 lifecycleScope,协程生命周期管理更安全。

注意事项:

  • 你需要接受这个延迟,设计好加载界面,让等待过程不那么突兀。
  • Row.BuildersetIsLoading(true) 方法,可以显示一个标准的加载指示器,比单纯显示“加载中”文字体验更好。

方案二:用 ScreenManager.push() 强制刷新(谨慎使用)

如果你实在无法忍受那个延迟,或者有特殊场景需要立即更新,可以考虑用 ScreenManager 来“作弊”。

原理:
调用 invalidate() 是告诉系统:“我当前这个 Screen 的内容变了,请在方便的时候更新一下。” 而 ScreenManager.push(newScreen) 是告诉系统:“我要导航到一个全新的 Screen 实例。” 后者通常会立即执行,因为它是一个导航操作,优先级更高。

做法:

  1. 初始时,你推到栈顶的是一个 LoadingScreen (或者就是你当前的 ContentScreen 显示加载状态)。
  2. fetchContent() 拿到数据后,在主线程里:
    • 创建一个新的 ContentScreen 实例,并将获取到的数据(例如 HTML 内容)通过构造函数或者 Intent 传递给它。
    • 调用 screenManager.push(newContentScreen)
    • 你可能还需要调用 screenManager.pop() 来移除之前的加载界面(如果它是单独的 Screen)。或者,如果 ContentScreen 既能显示加载又能显示内容,那你就在 push 新实例之前 pop 掉旧的 ContentScreen 实例。

代码示例(概念性):

// 在 fetchContent() 的 withContext(Dispatchers.Main.immediate) 块内
withContext(Dispatchers.Main.immediate) {
    val htmlContent = response.body() ?: "没拿到内容..."

    // 创建一个新的 Screen 实例,假设构造函数可以接收内容
    val newContentScreen = ContentScreen(carContext, htmlContent) // 假设构造函数或 Intent 传递数据

    // 在 push 新界面之前,可以选择 pop 掉当前界面
    // 这取决于你的导航逻辑,是否希望用户能返回到加载前的状态
    // 如果是 Loading -> Content 的单向流程,通常会 pop
    screenManager.pop() // 移除当前的 Loading 或旧 ContentScreen

    // 推入新的带有内容的 Screen 实例
    screenManager.push(newContentScreen)

    Log.d("ContentScreen", "内容获取成功, push 新 Screen")

    // 注意:这里不再需要调用 invalidate(),因为我们是用新屏幕替换旧的
}

优点:

  • 通常能实现近乎即时的界面更新。

缺点与风险 (非常重要):

  • 破坏导航栈: 滥用 pushpop 可能搞乱用户的导航预期,用户按返回键时的行为可能不符合直觉。
  • 性能开销: 创建和销毁 Screen 实例比简单地更新 Template 内容开销更大。频繁这样做可能导致性能问题或界面卡顿、闪烁。
  • 可能不符合 Automotive 规范: 这种做法有点像在“钻空子”,过度使用可能在未来的系统版本中遇到兼容性问题或行为变更。
  • 复杂性增加: 需要更小心地管理 Screen 实例和它们之间的状态传递。

安全建议:

  • 非必要不使用: 仅在你确认标准 invalidate() 方式无法满足核心功能需求时才考虑。
  • 谨慎管理导航栈: 清楚每次 pushpop 的效果,确保返回逻辑正确。
  • 测试充分: 在不同设备和系统版本上广泛测试,确保没有引入新的问题。

方案三:优化协程作用域管理(通用改进)

虽然这不是解决 10 秒延迟的直接方法,但原始代码里每次 fetchContent 都创建一个新的 CoroutineScope(Dispatchers.IO) 是个坏习惯。

问题:

  • 资源浪费: 每次都创建新 Scope 和线程(如果用 IO)。
  • 内存泄漏风险: 如果 Screen 在协程完成前被销毁,这个协程仍然会继续执行,并可能持有 ScreenCarContext 的引用,导致内存泄漏。它还可能在 Screen 销毁后尝试更新 UI,引发崩溃。

改进:
使用与 Screen 生命周期绑定的 CoroutineScope

代码示例:

  • 推荐:使用 lifecycleScope (如果 ScreenLifecycleOwner)

    import androidx.lifecycle.lifecycleScope // 需要添加 lifecycle-runtime-ktx 依赖
    
    // 在 Screen 类中
    private fun fetchContentIfNeeded() {
        // ...
        lifecycleScope.launch(Dispatchers.IO) { // 使用 lifecycleScope
            // ... 你的网络请求和 UI 更新逻辑 ...
        }
        // ...
    }
    

    lifecycleScope 会在 Screen (作为 LifecycleOwner) 进入 DESTROYED 状态时自动取消所有启动的协程。

  • 手动管理 Scope (如果 Screen 不是 LifecycleOwner)

    import kotlinx.coroutines.*
    
    // 在 Screen 类中
    private val screenScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) // 创建 Scope
    
    private fun fetchContentIfNeeded() {
        // ...
        screenScope.launch(Dispatchers.IO) { // 使用 screenScope
            // ...
            withContext(Dispatchers.Main.immediate) { // 切换回主线程 (因为原始 scope 是 Main)
                 if (isActive) { // 在更新 UI 前检查 Scope 是否还在活动
                    // ... 更新状态和调用 invalidate() ...
                 }
            }
            // ...
        }
        // ...
    }
    
    // 你需要找到 Screen 生命周期结束的回调方法,例如 onDetached 或类似的方法
    override fun onDetached() { // 假设有这个方法
        super.onDetached()
        screenScope.cancel() // 在 Screen 销毁时取消 Scope 中的所有协程
        Log.d("ContentScreen", "Screen detached, canceling coroutines.")
    }
    

    使用 SupervisorJob 可以让一个子协程失败时不影响其他子协程。使用 Dispatchers.Main.immediate 可以尝试立即在当前主线程执行,避免一次额外的调度。在 withContext 块内更新 UI 前检查 isActive 是个好习惯。

优点:

  • 避免内存泄漏。
  • 更高效地使用资源。
  • 代码更健壮。

总的来说,面对 Android Automotive 中 invalidate() 的延迟,首选方案是接受它,并优化你的加载状态处理逻辑(方案一) 。这通常是最稳妥、最符合平台设计的方式。只有在极少数特殊情况下,才考虑使用 ScreenManager.push() 强制刷新(方案二),并且要非常清楚其潜在的副作用。同时,别忘了优化你的协程管理(方案三),这是保证应用稳定性的基本功。