上一篇文章发布后,收到了不少反馈。有人认可这个方向的价值,也有人直接提出了一个尖锐的问题:
“你让一个农村老人去 HuggingFace 下载 2.4GB 的模型文件,然后用 SAF 文件选择器导入?你认真的吗?恐怕连他会上网的儿子女儿都不一定会操作吧?是不是该有个按钮直接下载。”
我得承认,上一个版本的体验确实存在明显的门槛。整个流程对用户而言过于复杂:
- 打开浏览器,搜索 HuggingFace
- 找到对应模型仓库
- 下载一个约2.9GB的
.task 文件到手机
- 回到 App,点击“选择模型文件”
- 在系统文件管理器里找到刚才下载的文件
- 等待复制和加载
整整六个步骤,任何一步都足以劝退一位六十多岁的老人,甚至包括他的子女。
这次更新,我把这一切简化成了一步:打开 App,点击“一键下载 AI 大脑”。 不仅如此,现在AI生成回复后,会自动进行语音播报,并且语速经过调整,更适合老年人收听。

三个关键升级
升级一:App 内直接下载模型
这是本次迭代最核心的改进。用户不再需要了解 HuggingFace 是什么,无需通过浏览器下载,也跳过了文件选择器导入的步骤。
打开 App 后,如果检测到本地没有模型,首页会显示一个醒目的提示卡片——“需装配本村 AI 大脑才能看病”,下方则是一个大大的按钮——“一键下载 AI 大脑”。
点击按钮后,会弹出模型选择面板,提供两个选项:
- 极速推荐版(Gemma 3,约 1.5GB):体积小,回复快,适合配置较新的手机。
- 完整兼容版(Gemma 4 E2B,约 2.4GB):回答更聪明、更全面,兼容大多数老设备。
用户只需选择一个版本,下载便会自动开始。进度条实时更新,下载完成后模型自动加载。整个过程支持断点续传,避免了因网络中断而前功尽弃的情况。下载完成后,所有功能即可离线使用。
整个流程,用户要做的仅仅只是按下一个按钮。
技术细节:一个不那么简单的下载器
你可能会觉得,下载文件有什么难的?用 DownloadManager 或者 OkHttp 几行代码不就搞定了吗?但实际开发中遇到了不少具体问题。
问题 1:HuggingFace 的重定向链
HuggingFace 的模型下载链接并非直链。当你请求一个 URL 时,它通常会返回 302 重定向到 CDN,而 CDN 可能还会再次 307 重定向到实际的存储节点。我遇到的最多情况是连续跳转 3 次才到达真正的文件下载地址。
HttpURLConnection 的 instanceFollowRedirects 属性默认会自动跟随重定向,但它存在一个问题:在跨协议(HTTP → HTTPS)或跨域重定向时,可能会丢失 Range 请求头。这将直接导致断点续传功能失效。
因此,我关闭了自动重定向,改为手动处理每一次跳转:
connection.instanceFollowRedirects = false
val responseCode = connection.responseCode
if (responseCode == 302 || responseCode == 307 || responseCode == 308) {
val newUrl = connection.getHeaderField("Location")
if (newUrl != null && redirects < 10) {
finalUrl = newUrl
redirects++
continue
}
}
手动跟随重定向的好处在于,每次建立新连接时,都可以确保 Range 头被正确地重新设置。
问题 2:2GB 文件的断点续传
农村的网络环境并不稳定,Wi-Fi 信号时好时坏,下载中途中断是常有的事。如果每次中断都要从头开始下载 2.4GB,那么这个功能就形同虚设。
实现断点续传的核心在于 HTTP 的 Range 请求头:
val destinationFile = File(context.filesDir, fileName)
var downloadedBytes = if (destinationFile.exists()) destinationFile.length() else 0L
if (downloadedBytes > 0) {
connection.setRequestProperty("Range", "bytes=$downloadedBytes-")
}
如果服务器支持,会返回 206 Partial Content,从断点处继续传输。如果返回的是 200 OK,则说明服务器不支持断点续传,此时必须删除已下载的部分文件,重新开始,否则文件会损坏。
还有一个边界情况:如果文件已经完整下载,再次发送带 Range 头的请求,服务器会返回 416 Range Not Satisfiable。这并非错误,意味着文件已下载完毕,直接走成功逻辑即可。
问题 3:仅限 Wi-Fi 下载
2.4GB 的模型如果使用移动数据下载,很容易耗光用户一个月的流量套餐。因此,在开始下载前会进行网络类型判断:
private fun isWifiConnected(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false
val cap = cm.getNetworkCapabilities(network) ?: return false
return cap.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
如果不是 Wi-Fi 环境,则会提示用户“建议在 Wi-Fi 环境下下载模型”,并阻止下载开始。
问题 4:进度反馈的节流
如果在下载过程中每读取一个 8KB 的缓冲区就更新一次 UI 进度,界面刷新将过于频繁。这里采用了时间节流策略,确保每 500 毫秒最多更新一次进度:
val currentTime = System.currentTimeMillis()
if (totalBytes > 0 && currentTime - lastEmitTime > 500) {
lastEmitTime = currentTime
val percent = (downloadedBytes.toFloat() / totalBytes) * 100f
emit(DownloadState.Progress(percent, downloadedBytes, totalBytes))
}
整个下载器使用 Kotlin Flow 进行封装,状态流转清晰:Idle → Progress → Success / Error。调用方只需 collect 状态流,即可获得实时下载进度,实现了 UI 层与下载逻辑的零耦合。
上个版本使用的是 MediaPipe LLM Inference 引擎,模型格式为 .task。这次我们迁移到了 Google 最新的 LiteRT-LM 引擎,模型格式也相应变更为 .litertlm。
为什么要更换引擎?主要有三个原因。
1. 原生多模态支持
MediaPipe 的 LLM Inference API 本质上是“文本进,文本出”。若要进行图片理解,需要开发者自行处理视觉特征提取等步骤。
而 LiteRT-LM 原生支持文本、图片、音频三种输入模态:
// 文本推理
val contents = Contents.of(Content.Text("你好"))
// 图片推理——拍照识药功能就依赖于此
val contents = Contents.of(
Content.ImageBytes(imageBytes),
Content.Text("这是什么药?怎么吃?")
)
// 音频推理——语音可直接送给模型,无需先转文字
val contents = Contents.of(
Content.AudioBytes(audioBytes),
Content.Text("请分析这段语音描述的症状")
)
请特别注意最后一项——音频推理。这意味着老人的语音不再需要先经过 ASR(语音转文字)模块处理,而是直接将 PCM 音频数据送入大模型。模型自己“听”,自己理解。
这样做有两个好处:一是链路更短,延迟更低;二是避免了 ASR 转写过程中可能丢失的信息,例如语气、停顿以及方言特征。
2. 更灵活的后端配置
LiteRT-LM 允许为不同的计算模态配置不同的硬件后端:
val config = EngineConfig(
modelPath = modelPath,
backend = Backend.CPU(), // 文本推理走 CPU
visionBackend = Backend.GPU(), // 图片推理走 GPU
audioBackend = Backend.CPU(), // 音频推理走 CPU
maxNumTokens = 2048,
cacheDir = context.cacheDir.absolutePath
)
为什么文本和音频推理使用 CPU,而图片处理使用 GPU?这是经过实际测试踩坑后得出的经验。Gemma 4 模型在某些设备的 GPU 上进行文本推理时可能出现数值精度问题,但使用 GPU 处理图片反而更加稳定。目前的这个组合经过了多台设备的验证,是相对最稳定的方案。
3. 更优雅的会话管理
LiteRT-LM 提供了原生的 Conversation 概念,支持多轮对话和系统指令设置:
val conversation = engine.createConversation(
ConversationConfig(
systemInstruction = Contents.of(Content.Text(systemPrompt))
)
)
这对于问诊场景非常重要。老人在一次咨询中可能会说多句话,上下文是连贯的。例如,他先说“我头疼”,AI 追问几个问题后他又说“还有点想吐”。有了会话管理,模型就能将这些信息串联起来进行综合分析,而不是每次都将用户的输入当作一个全新的、孤立的问题。
升级三:TTS 语音播报——AI 不光能“看”,还能“说”
这个功能描述起来很简单:AI 分析完症状后,将结果自动朗读出来。
但对于我们的目标用户群体而言,这可能是最关键的一个功能。
试想一下:一位七十岁的老人,患有老花眼,看清手机屏幕上的小字非常吃力。即使你把文字调得再大,他阅读起来也可能很困难。但如果手机能用清晰的人声把结果读出来,他通过“听”就能理解所有信息。
实现上的几个细节
中文语音引擎的适配
Android 系统自带的 TextToSpeech 看似简单,但在各品牌、各型号的国产手机上,其中文语音支持情况千差万别。有些手机默认只安装了英文语音引擎,有些手机的中文区域设置(Locale)并非标准的 Locale.CHINESE。
为此,我们实现了一个兜底检测链:依次尝试 Locale.CHINESE、Locale.CHINA、Locale.SIMPLIFIED_CHINESE,哪个能用就用哪个:
val locales = listOf(Locale.CHINESE, Locale.CHINA, Locale.SIMPLIFIED_CHINESE)
for (loc in locales) {
val result = tts?.setLanguage(loc)
if (result != TextToSpeech.LANG_MISSING_DATA &&
result != TextToSpeech.LANG_NOT_SUPPORTED) {
supported = true
break
}
}
如果这三个都不支持怎么办?我们会弹出一个 Toast 提示,引导用户去系统设置中下载中文语音数据包。这虽然不是最完美的方案,但至少保证了应用不会因此而崩溃。
语速调整
默认的 TTS 语速对老年人来说可能太快了。我们将语速设置为 0.8 倍:
tts?.setSpeechRate(0.8f)
0.8 这个值是经过实际测试的——比正常语速稍慢,有助于理解,但又不会慢到让人感到不耐烦。
初始化时序问题
TextToSpeech 的初始化是异步的。调用 new TextToSpeech(context, listener) 后,引擎并非立即可用,需要等待 onInit 回调。
这就产生了一个问题:如果 AI 推理速度极快,在 TTS 引擎尚未初始化完成时就已经生成了结果文本,该怎么办?
我们采用了一个待播报队列(pending queue)来解决。如果 TTS 未就绪,先将待播报的文本缓存起来。等到初始化回调 onInit 成功后,立即播报缓存的内容:
fun speak(text: String) {
if (!isReady) {
pendingText = text // 先缓存起来
return
}
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "village_doc_tts")
}
// onInit 回调成功后
pendingText?.let {
speak(it)
pendingText = null
}
随时可以“再听一遍”
在历史对话界面中,每一条 AI 回复的气泡都可以被点击以重新朗读。我们在气泡右下角设计了一个小喇叭图标,老人只需点一下图标,就能再听一遍。这个交互远比让他们回忆如何上滑查找文字,或者长按复制要简单和直观得多。
双引擎架构的进化
整体架构也进行了升级。现在的双引擎不再是 MediaPipe 与 ML Kit 的搭配,而是 LiteRT-LM 与 ML Kit AICore 的组合:
┌──────────────────────────────────────────────┐
│ GemmaManager │
│ (统一推理接口,自动选择) │
├──────────────────┬───────────────────────────┤
│ ML Kit AICore │ LiteRT-LM Engine │
│ │ │
│ Pixel 9+ / S25 /│ 所有其他 Android 手机 │
│ OPPO Find X8 等 │ │
│ │ │
│ 模型自动下载 │ App 内一键下载 │
│ 零配置 │ 断点续传、Wi-Fi only │
│ 最佳性能 │ 通用兼容 │
│ │ │
│ 文本 + 图片 │ 文本 + 图片 + 音频 │
│ (音频降级为文本) │ (原生多模态) │
└──────────────────┴───────────────────────────┘
GemmaManager 为上层提供了一个统一的、透明的接口。UI 层只需调用 inferText()、inferWithImage()、inferWithAudio() 等方法,完全无需关心底层具体使用了哪个引擎。引擎的选择在初始化时自动完成:优先尝试使用性能更佳的 ML Kit AICore(如果设备支持),若不支持则自动回退到兼容性更广的 LiteRT-LM 引擎。
语音问诊的完整链路
将以上所有升级串联起来,现在一次完整的“语音问诊”链路如下所示:
老人按住按钮说话
↓
AudioRecorderService 录制 16kHz PCM 音频
↓
自动添加 WAV 头(LiteRT-LM 需要标准音频格式)
↓
GemmaManager.inferWithAudio()
↓
LiteRT-LM 原生音频理解(不需要 ASR 中间步骤)
↓
返回结构化 JSON(症状分析、危险等级、建议)
↓
JsonParser 解析出 displayText
↓
TtsHelper.speak() 自动语音播报结果
↓
同时存入 Room 数据库(ChatHistory)
↓
UI 显示对话气泡,点击可重新朗读
整个过程,老人需要进行的操作只有一步:按住按钮说话。剩下的所有步骤——录音、推理、播报、存储——全部自动完成。
录音模块的实现细节
音频录制看起来是基础功能,但也有几个值得分享的实现细节。
采样率选择 16kHz 而非 44.1kHz。语音识别不需要 CD 级别的音质,16kHz 的采样率已经足够覆盖人声的主要频率范围。这样做的好处是数据量只有 44.1kHz 的三分之一左右,在进行模型推理时能有效减少内存压力。
最短录音保护。考虑到老人可能因手抖而误触,按下录音按钮后立即松开,这样录制到的音频可能只有零点几秒。过短的音频会让模型难以进行有效分析。因此,我们设置了一个 1.5 秒的最低门槛——如果录音长度不足 1.5 秒,会自动用静音数据(零填充)补齐:
val minPcmSize = sampleRate * 1 * 2 * 1.5 // 48000 bytes
if (pcmData.size < minPcmSize) {
val paddedPcm = ByteArray(minPcmSize.toInt())
System.arraycopy(pcmData, 0, paddedPcm, 0, pcmData.size)
pcmData = paddedPcm
}
手动拼接 WAV 文件头。LiteRT-LM 的 Content.AudioBytes 要求输入标准格式的 WAV 文件(包含 44 字节的 RIFF 头),而不是原始的 PCM 数据。因此,在录音结束后,我们需要手动为 PCM 数据拼接一个 WAV 文件头。这 44 个字节需要严格按照格式逐个填写——方法虽然看起来有点“笨”,但确保了绝对的可靠性和兼容性。
灵活的模型管理
下载模型只是开始。用户可能下载多个模型进行尝试(比如先试试极速版,又下载完整版),因此需要能够灵活切换、管理乃至删除模型。
我们在应用的设置页面中实现了一个简单的模型管理器:
- 自动扫描
filesDir 和 filesDir/models 目录下所有的 .litertlm 模型文件。
- 以列表形式展示,显示文件名和文件大小。
- 点击列表项即可切换激活该模型。
- 支持左滑删除模型文件。
当前激活的模型路径会保存在 SharedPreferences 中(key: active_model_path)。下次打开应用时,会优先加载上次激活的模型,无需用户再次手动选择。
尚未完成的工作
每次列出“待办事项”时,清单似乎总比“已完成”的还要长。目前主要的待优化点包括:
- 方言适配:目前的 TTS 播报使用的是标准普通话。但许多农村老人的日常交流语言是方言,标准的普通话对他们来说可能不够亲切。我们曾考虑接入方言 TTS 引擎,但尚未找到理想的离线解决方案。
- 语音唤醒:目前仍需“按住按钮”才能录音。理想状态是老人喊一声“村医”就能激活应用。然而,在离线环境下实现高精度、低误触率的语音唤醒,本身就是一个技术难题。
- 模型微调:通用的 Gemma 模型对中文医疗术语的理解还不够精准。考虑使用专业的中文医疗对话数据对模型进行 LoRA 微调,但这需要领域专家(医生)的参与进行数据标注。
- 下载速度优化:当前的下载器是单线程的
HttpURLConnection。对于 2.4GB 的大文件,实现多线程分片下载可以显著提升速度。不过,这也会增加实现的复杂度,目前暂未实施。
写在最后
时常有人问我,花费大量时间精力去开发一个“可能用户量不大”的 App,是否值得?
我是这样理解的。
技术领域每天都在热烈讨论 AGI、百万级上下文窗口、多模态统一模型。这些宏大的议题固然重要,但距离普通人的具体生活,似乎又有些遥远。
而对于一位身在乡村的老人来说,当他晚上感到胸口发闷时,他需要的不是 AGI——他需要的只是一部能告诉他“你这个情况,最好赶紧让家里人带你去医院看看”的手机。
这一句简单提示的背后,是 2.4GB 的模型权重、16kHz 的 PCM 音频采样、0.8 倍速的 TTS 语音合成、一个需要手动跟随三层 302 重定向的下载链接,以及一行 if (pcmData.size < minPcmSize) 的静音填充代码。
技术未必总要致力于解决“最宏大”的问题。有时候,能切实解决“最贴近”的需求,便已足够。
项目已在 开源实战 社区发布,欢迎对移动端AI应用、边缘计算感兴趣的朋友在 云栈社区 交流探讨。