安卓Mix Flip开发实录:原生安卓流程、选型与踩坑总结
Android 原生开发经验分享
近期有一个项目,要求 “使用 Android 原生开发”,本文将结合这一场景,分享我的开发流程、技术选型以及避坑经验。
首先需要明确的是,本次开发并不需要适配通用的安卓,只需要适配 Xiaomi Mix Flip 这款机型,且只需适配其外屏场景。
项目简介
这次我们要做的是一个「给护士使用」的业务 App,核心交互是:护士通过语音和 AI 对话,把“要做什么”直接说出来,由 AI 辅助完成各项安排与查询,并以流式方式返回结果(例如边生成边展示)。
从功能上看,本项目大致可以拆成三块能力:
-
• 语音输入:实时语音转文字(ASR),尽量接近“输入法”式体验; -
• AI 对话:需要承接流式返回(SSE),让回答更快可见; -
• 业务呈现:把医嘱/任务等列表与状态在外屏上清晰呈现,并保证整体交互稳定、统一。
技术选型
Java vs Kotlin
Google 明确了 Android 开发的官方语言——Java 和 Kotlin,其中 Java 因为甲骨文公司长期与 Google 打官司,所以 Google 一直在推进使用 Kotlin 的使用。
截至 2026 年,Android 下的 Kotlin 生态已高度成熟,新项目并不推荐选择 Java 进行开发,且 Kotlin 有更多比 Java 好用的特性、语法糖(如协程、闭包、Jetpack Compose 等),所以该项目选择 Kotlin 而不是 Java。
XML vs Jetpack Compose
传统的 Android 开发使用 XML 来描述布局、颜色和主题等元素,但在 Google 大力推动 Kotlin 发展的背景下,出现了新的技术栈——Jetpack Compose(下简称 Compose)。
传统的 Android 开发依赖 XML 描述布局,需要使用分布在 drawable/, layout/, themes.xml 和 colors.xml 等地方的文档来描述布局、主题和颜色,靠引用将它们连接在一起,代码结构分散且不好维护。
Compose 改变了这一现状。它采用了与 Flutter、React 类似的声明式 UI 范式,不仅极大提升了开发效率,还能深度契合 Kotlin 生态(如协程与状态流)。目前,国内大厂如腾讯(微信/QQ)、美团等已在大规模实践,其技术成熟度足以支撑生产环境。
在本项目中,时间成本极高,我们不希望在繁琐的资源组织和模板代码上耗费精力,因此 Compose 是唯一优选。
工具与 AI 辅助
除了语言与 UI 框架,工程效率也很大程度取决于“工具链是否顺手”。本项目的主要开发工具如下:
-
• IDE:Android Studio(负责代码编辑、调试、Profiler、Logcat、布局/Compose 预览等) -
• 设备联调:ADB / Logcat(外屏适配、语音与 SSE 场景都离不开真机日志)
另外,在开发过程中我也配合使用了 AI 作为辅助工具,主要用在这些地方:
-
• 根据 API 文档,生成样板代码与 API 调用骨架(例如协程/Flow/SSE 对接的常规结构),避免手写过多请求代码 -
• 生成常见 UI/组件,这样可以将精力放在业务逻辑上,而不是花在 “怎么设计一个九宫格手势解锁” 上
此外,本人也认为 ‘AI 生成模块 + 人工逻辑拼装’ 是当前大模型背景下兼顾开发效率与工程质量的最佳实践。可以避免 AI 全盘生成出不利于维护的代码,同时又能够借助 AI 来提高开发的效率。
开发与适配
这部分我们将由浅到深,从浅层的界面适配到深层的业务逻辑来讲讲。
小米 Mix Flip 的适配
Mix Flip 的外屏并不能打开未经适配的应用,所以我们需要让系统认为我们应用的某个 Activity 是适配外屏的。为了达成这一目标,我们需要在 Manifest 中为 Application 或者 Activity 加入部分属性。这里只讲比较重要的几条属性,其他属性在 HyperOS 的适配文档亦有提到且没什么意义,在这里就不多说了。
<property android:name="miui.continuity.policy" android:value="5" /><meta-data android:name="miui.supportFlipFullScreen" android:value="0" />
首先是 miui.continuity.policy 属性,该属性在 Mix Flip 的适配文档中并没有提到,所以特意在这里提一下。
若其值不为 5 会导致 Activity 无法在外屏加载,表现如下:

接下来讲 miui.supportFlipFullScreen 这一属性,这个属性控制了 Activity 在外屏是如何显示的,具体效果如下所示:



当属性为 2 时,效果与未设置相同。其中 2 与 1 的区别是我们的界面被应用了一个缩放值(观察我们文字的字体可看出来)。
同时,若我们的 miui.supportFlipFullScreen 为 0,我们需要手动控制整个外屏的显示内容(控制界面应该显示在左侧还是右侧),因此我们需要根据上下旋转来调整侧边栏的显示,但在旋转检测上同样存在坑。
在使用常见的几个检测旋转方案时,我们发现在熄屏解锁的时候是无法读取到正确的旋转状态的。

但是在 Mix Flip 的外屏中,因为有摄像头的存在,我们可以读取到 Cutout,我们只需要判断 Cutout 在左上角还是右下角就能够识别旋转状态。
界面风格
Compose 原生的组件是 Google 的 Material Design 3 Expressive,这些组件本身已经封装好了,自带了一些 Padding 或者 Color 之类的属性,不好二次封装,所以我们可以引入 Compose Unstyled 库来方便自定义:
implementation("com.composables:composeunstyled:1.49.6")
我们就可以使用 Unstyled 中的组件来定制风格并封装,以按钮为例:
Button( onClick, modifier = modifier, shape = CircleShape, backgroundColor = backgroundColor, contentPadding = contentPadding, content = content)
Unstyled 中同样包含了如文本输入等其他的组件可供二次封装,因在项目中我们并不需要用到太多复杂的组件,直接使用 Unstyled 库可以减少我们在组件样式调整上花费的时间。

异步图像加载
因为项目中有从服务器拉取用户头像并显示的需求,在这里我们直接使用 Coil 库来实现它。Coil 原生支持 Compose,所以我们可以直接使用 Coil 中的 AsyncImage 和 SubcomposeAsyncImage 组件。

我们使用了一份全局的 Store,直接储存了当前登录的用户信息,其中包含了用户的头像地址,只需要直接传入即可。

特殊输入设备的适配
甲方给了我们一个需求,希望能够使用一个脚踏板来控制开始/结束录音,所以我们需要对这个脚踏板进行适配。
因为这个脚踏板是可编程的,可以自定义踩下后的动作,所以与甲方沟通后我们决定将这个动作定义为音量加减,一方面是在没有脚踏板的场景我们可以直接按音量加减来对话,另一方面也是便于适配。
在这里我们只需要覆写 onKeyDown(keyCode: Int, event: KeyEvent?): Boolean 方法即可:
overridefun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { return when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { // 这里放置你的动作... true } else -> super.onKeyDown(keyCode, event) }}
在接收到 KeyEvent.KEYCODE_VOLUME_UP 或者 KeyEvent.KEYCODE_VOLUME_DOWN 事件时,我们触发开始或结束录音的动作,同时返回 true 避免事件继续传播,触发真正的音量调整动作。
ViewModel 的使用
讲完了表层的 UI 界面,我们开始涉及到 UI 状态相关的部分了。
Compose 的核心是“状态驱动 UI”,这意味着我们需要一个可靠的状态承载点:既能让 UI 随状态变化而刷新,又能在配置变化/Activity 重建时尽量不丢失关键数据与订阅。
ViewModel 正好承担了这个职责:它能延长数据的生命周期,避免因为旋转屏幕或其他原因导致 Activity 重建时数据丢失,同时也提供了 viewModelScope 让协程与数据流有清晰的归属。
本次项目中,我们在必要时将 nested type ViewModelImpl 加入 Activity 中 ,作为该 Activity 的 ViewModel 类型,随后在 Activity 中我们能够使用 Kotlin 的 by 语法自动管理 ViewModel
import androidx.lifecycle.ViewModelimport androidx.activity.viewModelsclass FoobarActivity : AppCompatActivity() { // ... private val viewModel: ViewModelImpl by viewModels() // ... @OptIn(ExperimentalCoroutinesApi::class) class ViewModelImpl : ViewModel() { // ... }}
在这里需要注意,ViewModel 的生命周期比 Activity 长(Activity 可能会在配置变更的时候被销毁重建,但 ViewModel 还是那个 ViewModel),所以我们在 ViewModel 中不能引用 Activity 中的数据,避免内存泄漏的发生。

Kotlin Flow 的使用
相比 LiveData,Kotlin 下还可以使用 Flow 数据流来将 UI 对接到数据源。
Flow 可以使数据如流水线一般,经过各层转换算子,且可以在其依赖项改变的时候或被订阅的时候(即 Lazy)才进行求值,同时 Compose 支持订阅 Flow 的数据作为状态,使用 Flow 可以简化我们的项目复杂度,不需要在需要重新求值的时候手动调用求值函数重新请求、处理和重新呈现数据。
以项目中需要拉取医嘱的场景为例,使用 Flow 来进行拉取:
val infusionListUpdated = _infusionListUpdated.asSharedFlow()val infusionTaskFilter = MutableStateFlow(InfusionTaskFilter())val infusionList = infusionPaginator.combine(infusionListRebuild) { it, _ -> it }.flatMapLatest { flow { emit(it.map { it?.list }) } }.stateIn( scope = viewModelScope, Lazily, Result.success(null) )
首先,我们的 UI 层需要更新 infusionTaskFilter 来触发其他 Flow 的 collect 逻辑,重新从服务器拉取新的数据,完成了 “重新请求数据” 这一步骤。
然后,infusionPaginator 负责将多个页面合成为一个大列表供 LazyColumn 使用,且我们希望能够手动触发 Rebuild (为了节约资源,部分地方我们直接更新了 data class 的属性而不是 copy,引用未变,故我们需要手动触发 Rebuild),所以 combine 了一个 SharedFlow infusionListRebuild,在这一过程中完成了 “数据处理” 这一步骤,将请求得到的原始数据转换为方便 UI 层呈现的数据。
同时我们不希望这一个 Flow 在某处 collect 中都进行求值,所以我们使用 stateIn 来缓存。
最后,在设计好 Flow 后,我们便可以在 Compose 中订阅某个 Flow 取其值:
val data = viewModel.infusionList.collectAsState()
在 Flow 的内容变化的时候该状态会自动更新,进一步带动 UI 呈现的更新,完成 “重新呈现” 这一步骤。
总体来讲,我们只需要更新过滤器即可,Flow 可以自动去请求并计算出新的医嘱列表。
与 ViewModel 的关系
在上文的 ViewModel 中,我们提到了
viewModelScope这一 CoroutineScope。在 Flow 中将 scope 指定为viewModelScope,可使得即使 Activity 被重建了,我们的请求、数据处理等代码仍然在持续运行。

讯飞 ASR 语音识别对接
讲完了与 UI 打交道的状态管理,接下来就要讲讲更深层的功能实现了,比如我们的 ASR 功能是如何实现的。
因为我们在界面中需要做一个按钮,让护士可以像对讲机一样按住该按钮用语音给 AI 下达命令,所以我们需要做出录音实时转写的模块,在这里我们直接使用讯飞开放平台提供的 Realtime ASR。
讯飞的 ASR 要求我们提供 16K 采样率、16 Bit、单声道的 PCM 流,而不是录制成 m4a、wav、mp3 等格式后再上传转写,所以在这里我们只能使用 Android 中偏底层的 AudioRecord 而不是 MediaRecorder(MediaRecorder 无法提供 PCM流)
注意
查阅 Android 文档,我们发现即使文档中指出采样率并不能随便指定,需要硬件支持,但实测 Mix Flip 中直接传 16K(16000) 是可以的。
官方文档中指出采样率不可随意指定
如果希望在 ASR 的同时能够将录音保存为录音文件,可以自行封装一个编码器来将 PCM 流编码为如 mp3 之类的音频文件,在我们项目中就是实现了个 PcmEncoder 将 PCM 流编码为 mp3。

SSE 请求
在护士讲完后,我们能够取得转写好的文本内容,接下来我们要拿着这些文本内容去请求 AI 了。
AI 的请求涉及到流式输出,所以这里要讲讲 SSE。
HTTP 协议本质上支持持久连接,允许服务器通过分块传输编码(Chunked Transfer Encoding)实现流式响应(无论是基于 TCP 的 HTTP/1.1、HTTP/2,还是基于 QUIC 的 HTTP/3,其应用层协议特性都允许通过长连接实现服务端推送,从而支撑 SSE),所以在响应的时候服务器可以不用一次性将全部的数据都响应,而可以在长连接中一点一点的响应,这样就可以实现服务器向客户端单向发送流式数据了。
例如对于某个请求 GET /some-sse-endpoint,我们有响应:
Content-Type: text/event-streamdata: {...}data: {...}data: {...}data: {...}data: {...}data: {...}data: {...}...
这就构成一个 SSE 请求,data: 的数据并不是一口气传输完的,而是流式传输的。客户端维持长连接,接收 SSE 发来的数据,解析每行 data 数据即可。
现在我们回到 AI 调用这方面,因为 SSE 请求使得服务器可以流式向客户端发送数据,所以 AI 的流式输出就是使用 SSE 实现的。
流式生成会将模型生成的每部分内容作为单独的 data: Chunk 向客户端发送(推理毕竟也是一个一个 Token 的推理),每个 Chunk 包含新生成的内容,客户端只需要把那部分内容拼接到原先已经生成好的部分后面即可做到 AI 流式生成的效果。
我们发现常用的 OkHttp 居然对 SSE 的支持不完整,好在在 Kotlin 生态中,我们还有 Ktor 可用。Ktor 完整支持了 SSE,且能够配合 Kotlin 中的协程(Coroutine)使用,所以我们使用 Ktor 来处理需要 SSE 的场景:
val ktorClient = HttpClient(CIO) { install(SSE)}// ...ktorClient.sse({ method = HttpMethod.Post url("$apiRoot/*****") contentType(ContentType.Application.Json) setBody(payload) header("Authorization", "Bearer ******")}) { Log.d(ApiClient.TAG, "SSE established") incoming.collect { event -> val data = event.data ?: return@collect val event = json.decodeFromString<AiStreamEvent>(data) // 将数据拼接并在 UI 层呈现 ... }}
在这里我们接收服务器发来的每个 SSE 事件,从中提取 Chunk,将其拼接到原先已经生成好的内容后面并在 UI 层呈现。
因为这里涉及一个需求,一些指令需要弹出 UI 进行手动确认才能执行,所以我们使用一个 “✅” Emoji 来代表某条指令需要在客户端二次确认,若接收到这个 Emoji 则会弹出确认框,用户在点击确认或者取消后会再往服务端发送如 ”确认该操作“ 之类的文本。

这个二次确认的实现好吗?
说实话这个二次确认的实现并不是很好,因为受到模型幻觉或者其他因素影响,有时候模型会忘记触发二次确认,这种情况只能靠提示词工程的老师施法了。
另一种方式是专门使用一个 个位数B参数量的小模型(如
Qwen2.5-7B之类的模型) 做一个独立的工作流用于检测指令是否需要二次确认,确认后再将指令传到真正负责工作的 AI 中,在检测速度极快且省 Token 的同时能提高准确率,但我们目前仍然使用的是 Emoji 作为关键字的方案。后续若有需要我们会继续讨论其他方案,这里不多概述。
部分异步相关的坑
这类问题通常出现在主线“跨线程/跨协程/跨数据流”时:代码看似在协程里跑,但实际线程与生命周期边界并不会自动修正。
即便在 viewModelScope 中启动协程,默认情况下它运行在 Dispatchers.Main(主线程)。对于耗时操作,必须显式使用 withContext(Dispatchers.IO) 切换调度器,以避免阻塞 UI。
newTaskCount.emit(withContext(Dispatchers.IO) { ApiClient.hwClient.hwGetWarnStatus( currentRoomAsInt )})
网络请求库(如 Ktor, OkHttp)通常自带 IO 线程池调度,但如果涉及复杂的本地数据计算或 Room 数据库操作,手动切换 Dispatchers 是必不可少的。
小结
通过本次小米 Mix Flip 外屏适配的 Android 原生开发项目,我们成功构建了一个面向护士的语音交互 AI 助手应用。项目聚焦于高效的技术栈选型:采用 Kotlin 作为开发语言,利用 Jetpack Compose 提升 UI 开发效率,并借助 AI 工具辅助代码生成和组件设计,显著缩短了开发周期。
在适配层面,我们深入探索了 HyperOS 的外屏特性,通过关键 Manifest 属性和摄像头 Cutout 检测实现了稳定的外屏显示控制。状态管理方面,ViewModel 与 Kotlin Flow 的结合确保了数据流的响应性和生命周期管理;异步图像加载则依赖 Coil 库简化了用户头像的处理。
核心功能实现包括讯飞 Realtime ASR 的语音转写,通过 AudioRecord 提供 PCM 流以满足实时性需求,以及 Ktor 支持的 SSE 流式 AI 对话,确保了边生成边展示的用户体验。同时,我们在异步编程中注意线程调度,避免了常见的数据流边界问题。
尽管过程中遇到了一些适配和异步处理的坑,但通过查阅文档、实测验证和 AI 辅助,我们逐一化解。这些经验不仅提升了项目的质量,也为未来类似的项目提供了宝贵参考。整体而言,现代 Android 开发工具链与 AI 的协同使用,让我们在有限时间内交付了一个稳定、高效的应用。

夜雨聆风
