
1. 项目概述为什么“把语音模型塞进手机”这件事比听起来难得多“把语音模型塞进手机”——这句标题乍看像一句技术圈的玩笑话实则精准戳中了当前端侧语音智能落地最硬的那块骨头。它不是简单地把训练好的 PyTorch 模型导出成 ONNX 再扔进 Android 工程里就完事而是要在4GB 内存、2W TDP、无 GPU 加速、无 Python 运行时、无完整 Linux 用户态环境的移动设备上让一个原本在服务器上跑得飞起的语音识别ASR或语音合成TTS模型做到启动快、响应稳、耗电低、不卡顿、不崩溃、不报错。sherpa-onnx 正是为解决这一系列“不可能三角”而生的工具链它不是框架不是 SDK而是一套经过千锤百炼的“端侧语音工程化方案”。我最早接触这个需求是在帮某高校实验室做一款离线方言识别 App。他们用 Whisper-large-v3 训练出了一个粤语识别模型精度高达 92%但一放到安卓真机上光模型加载就要 18 秒识别一句话平均延迟 4.7 秒发热明显电池掉电速度是普通 App 的 3 倍。后来换成 sherpa-onnx kaldi-native-fbank whisper.cpp 的轻量组合同样模型结构下启动时间压到 1.2 秒单句识别耗时稳定在 320ms 以内连续运行 2 小时机身温升不到 3℃。这不是参数调优的结果而是整个计算路径、内存布局、线程调度、JNI 交互方式被彻底重写后的工程胜利。核心关键词“sherpa-onnx”背后藏着三个不可绕过的硬核事实第一它不依赖 Python所有推理逻辑用 C 编写通过 JNI 暴露极简接口彻底规避 Android 上 Python 解释器带来的内存膨胀与 GC 不可控问题第二它不绑定特定模型格式而是以 ONNX Runtime 为底座但做了深度定制——禁用所有非必要算子、强制启用内存池、重写 tensor 生命周期管理让一个 120MB 的 Whisper ONNX 模型在 Android 上实际驻留内存仅 86MB实测 Nexus 5X第三它把“语音前端”当作一等公民kaldi-native-fbank 不是可选插件而是默认集成模块连梅尔频谱的窗长、帧移、FFT 点数都预设为移动端友好值25ms/10ms/512避免开发者自己踩“频谱对不齐导致识别率断崖下跌”的经典坑。适合谁来读这篇如果你正在开发一款需要离线语音能力的 Android App——比如车载助手、老年健康监测终端、工业巡检记录仪、或是教育类点读 App且你已具备基础 Android 开发能力能写 Activity、调 JNI、配 CMakeLists.txt但对语音模型部署毫无头绪或者你是个算法工程师刚训好一个 ASR 模型却卡在“怎么让客户手机上跑起来”这一步那么这篇就是为你写的。它不讲论文、不推公式只讲你在 Android Studio 里敲下的每一行代码、遇到的每一个 SIGSEGV、以及为什么某个 .so 文件大小多出 23KB 就会导致低端机闪退。2. 整体设计思路拆解为什么不用 PyTorch Mobile为什么不用 TensorFlow Lite2.1 为什么坚决放弃 PyTorch MobilePyTorch Mobile 看似是“官方亲儿子”但用在语音场景里它是一把双刃剑且刀刃全朝向开发者。我试过三种主流路径直接 torchscript 导出、使用 TorchScript quantization-aware training、以及用 torch.compile 预编译。结果全部失败原因非常具体内存碎片灾难PyTorch Mobile 的 tensor allocator 在 Android 的 bionic libc 下无法有效回收小块内存。一次 10 秒语音识别会触发约 1700 次 tensor 分配/释放低端机如 Redmi Note 8在第 3 次识别后就会因malloc失败而 crash。我们抓取的 tombstone 日志显示libtorch_cpu.so中at::native::empty_strided_cuda注意这是 CPU 版本反复申请 4KB~16KB 的小内存块最终耗尽 heap arena。量化陷阱PyTorch 的 dynamic quantization 对语音模型几乎无效。Whisper 的 encoder 层中大量存在LayerNormGELU组合其输出分布高度非高斯dynamic quantization 后 WER词错误率从 8.2% 暴涨至 31.7%。而 static quantization 又要求你提供真实语音数据集做 calibration这在隐私敏感场景如医疗问诊录音根本不可行。JNI 接口臃肿PyTorch Mobile 的 Java API 设计面向通用模型Module.forward(IValue...)接口需将 float[] 打包成 IValue 列表再经 JNI 转换为 ATensor。一次 5 秒语音采样率 16kHz需传入 8 万个浮点数光序列化/反序列化开销就占总耗时 18%。更致命的是IValue 的生命周期由 Java GC 管理而 native 层 tensor 又由 PyTorch 自己管两者 GC 时间点错位极易引发 use-after-free。提示有团队尝试用torch::jit::load()直接加载.pt文件结果发现 Android 上std::ifstream读取大文件时bionic 的read()系统调用会因 page cache 不足而频繁阻塞实测 120MB 模型加载耗时波动达 ±3.2 秒完全不可控。2.2 TensorFlow Lite 的语音适配性短板TFLite 在图像领域很成熟但语音是另一套逻辑。它的核心瓶颈在于前端处理缺失。TFLite 模型输入要求是“已预处理好的梅尔频谱图”这意味着你必须在 Java/Kotlin 层完成音频解码 → 重采样 → 预加重 → 分帧 → 加窗 → FFT → 梅尔滤波器组 → 对数压缩。这一整套流程用 Java 实现精度差、性能低用 JNI 写又重复造轮子。我们曾用 TFLite 官方提供的AudioProcessor示例结果发现其窗函数用的是矩形窗而非标准汉明窗导致高频信息严重泄露WER 直接上升 12%。更关键的是TFLite 的 delegate 机制在语音场景下形同虚设。GPU delegate 对 Conv1D 支持极差大部分语音模型的 encoder 是基于 Transformer 的其核心是MatMulSoftmax而 GPU delegate 的MatMul实现针对的是 2D 矩阵对(batch, seq_len, hidden)这种三维张量需手动 reshape极易出错。NNAPI delegate 在高通芯片上表现尚可但联发科平台如 Dimensity 810的 NNAPI 实现存在 bug对Transpose算子支持不全导致模型加载即 crash。2.3 sherpa-onnx 的三层架构设计哲学sherpa-onnx 的成功源于它把“端侧语音”拆解为三个正交层并为每层定制了最小可行方案第一层模型执行引擎ONNX Runtime 定制版它不是直接用 onnxruntime-android 的 aar而是 fork 了 onnxruntime v1.16.3 源码禁用了所有非必要组件onnxruntime_providers_shared、onnxruntime_training、onnxruntime_test_utils全部移除onnxruntime_providers_cpu中删掉了contrib_ops和ml_ops最关键的是重写了SequentialExecutionPlan强制所有 operator 在单个 thread pool 中顺序执行杜绝多线程竞争导致的 cache thrashing。编译出的libsherpa-onnx-core.so体积仅 4.2MB对比原版 onnxruntime-android 的 18.7MB且内存占用稳定在模型大小的 1.3 倍以内。第二层语音前端kaldi-native-fbank这是 sherpa-onnx 最被低估的亮点。它把 Kaldi 中最稳定的 fbank 提取逻辑ComputeFbankFeatures用纯 C 重写不依赖 OpenBLAS 或 FFTW而是用 hand-written FFTCooley-Tukey 算法针对 512 点优化所有数组分配均使用std::vector的reserve()预分配避免 runtime realloc。其输出与 Kaldi 官方脚本compute-fbank-feats的结果逐点一致我们用 Python numpy.allclose 验证过atol1e-6这意味着你在服务器上用 Kaldi 提取特征训练的模型拿到手机上无需任何适配即可直接推理。第三层JNI 胶水层极简状态机sherpa-onnx 的 Java API 只有 4 个核心类OnlineRecognizer流式识别、OfflineRecognizer整句识别、TextToSpeechTTS、FeatureExtractor前端。每个类的方法数不超过 5 个且全部是同步阻塞调用。例如OfflineRecognizer.recognize(float[] waveform)输入是原始 PCM 数据int16 转 float32输出是String。没有 callback、没有 listener、没有 lifecycle 回调——因为语音识别本身就是一个 request-response 场景强行加异步只会增加复杂度和出错概率。这种设计牺牲了“灵活性”换来了“确定性”。当你的 App 在后台被系统杀掉进程时sherpa-onnx 不会留下任何未清理的 native thread 或 dangling pointer当用户快速连续点击识别按钮时不会出现IllegalStateException: Recognizer is busy这类 Java 层异常——因为 C 层根本没状态每次recognize()调用都是全新实例。3. 核心细节解析与实操要点从模型准备到 APK 打包的 7 个生死关3.1 模型选择不是所有 ONNX 模型都能塞进手机sherpa-onnx 官方推荐的模型如sherpa-onnx-whisper-tiny-en固然稳妥但实际项目中你往往需要用自己的模型。这里有两个硬性门槛算子兼容性清单sherpa-onnx 当前v1.7.0仅支持 ONNX opset 15 及以下且明确禁用以下算子ScatterND,GatherND,Loop,If,NonMaxSuppression,RoiAlign。如果你的模型含GatherND常见于动态 batch 处理必须用 onnx-simplifier 重写。实测命令python -m onnxsim input.onnx output.onnx --skip-optimization --input-shape input_features:1,80,3000注意--input-shape必须指定否则 simplifier 会因 shape 推导失败而报错。权重数据类型强制要求所有Constant节点的value必须是float32。很多训练框架如 HuggingFace Transformers导出时默认用float16存 weight这在 PC 上没问题但在 ARMv7 的低端 Android 设备上float16load 会触发SIGILL。验证方法用 Netron 打开 ONNX 文件右键任意Constant节点 → “View node attributes”检查value的data_type是否为FLOAT对应 1。若为FLOAT16对应 10需用 Python 脚本转换import onnx model onnx.load(model.onnx) for node in model.graph.node: if node.op_type Constant: if node.attribute[0].t.data_type 10: # FLOAT16 import numpy as np arr np.frombuffer(node.attribute[0].t.raw_data, dtypenp.float16) node.attribute[0].t.raw_data arr.astype(np.float32).tobytes() node.attribute[0].t.data_type 1 onnx.save(model, model_fp32.onnx)3.2 Android NDK 配置为什么必须用 r21e而不是最新的 r25cNDK 版本选择是第一个“静默杀手”。sherpa-onnx 的 C 代码大量使用std::filesystem用于模型路径解析和std::span用于零拷贝音频传递这两个特性在 NDK r21e 中通过c_sharedSTL 完整支持。但 r22 版本因 ABI 兼容性调整std::filesystem::path在某些设备上会因 locale 设置异常而 crash尤其在印度、中东地区机型。我们做过覆盖测试在 23 款主流机型从 Galaxy S8 到 Pixel 7上r21e 编译的 so 文件 100% 通过r23b 编译的版本在 3 款三星机型SM-G950F, SM-J730GM上FeatureExtractor.create()调用时抛std::system_errorr25c 更糟在所有搭载 Android 10 的设备上dlopen即失败logcat 显示dlopen failed: cannot locate symbol __cxa_throw_bad_array_new_length。因此app/build.gradle中必须锁定android { ndkVersion 21.4.7075529 // r21e exact version defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a } } }注意abiFilters仅保留arm64-v8a和armeabi-v7a。x86/x86_64 在现代 Android 设备上占比不足 0.3%且 sherpa-onnx 未针对 x86 做 SIMD 优化编译出来性能反而更差。3.3 CMakeLists.txt 关键配置如何让链接器不“吃掉”关键符号sherpa-onnx 的 JNI 方法名遵循Java_com_k2fsa_sherpa_onnx_XXX规范但 Android 的链接器ld默认会 strip 所有未被 Java 层显式引用的符号。如果你在 Java 中只调用了OnlineRecognizer而没碰TextToSpeech那么libsherpa-onnx-tts.so中的 JNI 函数会被 linker 丢弃导致运行时UnsatisfiedLinkError。解决方案是在CMakeLists.txt中添加--no-as-needed并显式-u引用# 在 target_link_libraries 之前 set(CMAKE_SHARED_LINKER_FLAGS ${CMAKE_SHARED_LINKER_FLAGS} -Wl,--no-as-needed) # 假设你同时用 ASR 和 TTS target_link_libraries(your-lib sherpa-onnx-core sherpa-onnx-asr sherpa-onnx-tts -u Java_com_k2fsa_sherpa_onnx_asr_OnlineRecognizer_create -u Java_com_k2fsa_sherpa_onnx_tts_TextToSpeech_create ${log-lib} )这个-u参数强制链接器保留指定符号哪怕它们看起来“没被用到”。这是 Android NDK 工程中少有人知但至关重要的技巧。3.4 模型文件部署assets vs. files 目录的功耗博弈模型文件通常 50~120MB放哪直觉是放src/main/assets/但这是个巨大误区。Android 的 assets 是只读 zip 归档每次AssetManager.open()都要解压到内存对于大文件这会触发频繁的 GC 和内存抖动。我们实测从 assets 读取 86MB 的whisper-tiny-en.onnx平均耗时 1.8 秒且伴随 3 次 full GC。正确做法是首次启动时将模型从 assets复制到应用私有目录getFilesDir()下后续直接读取该路径。关键代码private File copyModelToFilesDir(String assetName) throws IOException { File dest new File(getFilesDir(), assetName); if (dest.exists()) return dest; try (InputStream is getAssets().open(assetName); OutputStream os new FileOutputStream(dest)) { byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { os.write(buffer, 0, len); } } // 关键设置文件权限防止被其他 App 读取 dest.setReadable(true, false); return dest; }setReadable(true, false)确保只有本 App 可读符合 Google Play 的隐私政策。此方案首次启动多花 2 秒但后续所有识别请求节省 1.8 秒且内存平稳——这是典型的“启动换运行”的端侧工程智慧。3.5 JNI 线程模型为什么必须用std::thread而非std::asyncsherpa-onnx 的 Java API 是同步的但底层 C 会启动线程做特征提取和推理。这里有个深坑std::async(std::launch::async, ...)在 Android 的 bionic libc 下其线程栈默认只有 1MB而语音模型推理常需 2~3MB 栈空间尤其 Transformer 的 attention mask 计算。一旦栈溢出直接SIGSEGV且无任何 Java 层异常捕获。sherpa-onnx 的解法是手动创建std::thread并显式设置栈大小。查看其源码sherpa-onnx/csrc/online-recognizer.cc关键片段// 创建线程时指定栈大小为 4MB std::thread t([this]() { // 推理逻辑 }, std::stacksize(4 * 1024 * 1024));如果你自己封装 JNI务必效仿。在 Java 层这意味着你不能用ExecutorService提交Runnable因为其线程池的栈大小不可控。必须用new Thread(...).start()并在 C 层严格管理线程生命周期。3.6 内存泄漏排查FeatureExtractor的隐藏引用计数FeatureExtractor类看似简单但它内部持有一个kaldi::OnlineFbank实例而后者又持有kaldi::MelBanks和kaldi::SplitVector等资源。如果 Java 层忘记调用destroy()这些 native 资源永不释放。sherpa-onnx 的设计是FeatureExtractor构造时newdestroy()时delete。但 Android 的 finalize 机制不可靠GC 时间点不确定。我们的实测发现在低端机上若用户快速切换 Activity如从识别页跳到设置页FeatureExtractor的finalize()可能数分钟才触发期间内存持续增长。终极方案在 Activity 的onPause()中强制 destroyOverride protected void onPause() { super.onPause(); if (featureExtractor ! null) { featureExtractor.destroy(); // 立即释放 native 资源 featureExtractor null; } }同时在FeatureExtractor的 Java 构造函数中用PhantomReference做双重保险public FeatureExtractor(...) { this.nativeHandle createNativeObject(...); // 注册虚引用确保 finalizer 失效时仍有兜底 ReferenceQueueFeatureExtractor queue new ReferenceQueue(); new PhantomReference(this, queue) { Override public void clear() { if (nativeHandle ! 0) { destroyNative(nativeHandle); // native 方法 } } }; }3.7 APK 体积控制如何把 120MB 模型压缩进 50MB 安装包Google Play 要求 APK ≤ 150MB但用户更在意安装包大小。sherpa-onnx 模型本身无法压缩但你可以利用 Android App BundleAAB的动态分发能力在build.gradle中启用 AABandroid { bundle { abi { enableSplit true } } }将模型文件放入src/main/assets/不要放在jniLibs下那是给 so 文件用的。使用android.bundle.enableUncompressedNativeLibsfalse默认这样 assets 中的模型文件在安装时不会被二次压缩读取更快。最终生成的 AAB 上传 Google Play 后系统会为不同 ABIarm64-v8a/armeabi-v7a生成独立的 APK用户下载的只是自己设备所需的那一份。实测一个含 86MB 模型的 AAB上传后 Google Play Console 显示 “Estimated download size” 仅为 42MBarm64-v8a 版本远低于 150MB 限制。4. 实操过程与核心环节实现从零开始搭建一个离线语音记事本4.1 环境准备Android Studio 与 NDK 的精确匹配我们使用的环境组合经过 17 次失败验证是目前最稳定的Android Studio Giraffe | 2022.3.1 Patch 2最新稳定版Gradle Plugin 8.1.4com.android.tools.build:gradle:8.1.4Gradle 8.0gradle-8.0-bin.zipNDK r21eandroid-ndk-r21e-linux-x86_64.zipCMake 3.22.1cmake-3.22.1-linux-x86_64.tar.gz特别注意Gradle Plugin 8.1.x 要求 Gradle 8.0而 NDK r21e 与 Gradle 8.0 兼容性最佳。若用 Gradle 8.2NDK 编译会报CMake Error: Could not create named generator这是 Gradle 的 bug已确认。4.2 创建 C 模块CMakeLists.txt 的逐行注释新建app/src/main/cpp/目录放入CMakeLists.txt# 第1行声明最低 CMake 版本必须 ≥ 3.10sherpa-onnx 要求 cmake_minimum_required(VERSION 3.10.2) # 第2行项目名必须与 gradle 中的 externalNativeBuild.targetName 一致 project(sherpa-onnx-demo) # 第3行设置 C 标准sherpa-onnx 用 C17不可降级 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 第4行查找 Android NDK 提供的 log 库用于 native 日志 find_library(log-lib log) # 第5行添加 sherpa-onnx 的 C 源码假设你已 git clone 到 ../sherpa-onnx # 注意路径必须是相对路径且不能有空格 add_subdirectory(../sherpa-onnx sherpa-onnx-build) # 第6行创建你的 JNI 库名字叫 libsherpa-demo.so add_library(sherpa-demo SHARED # 你的 JNI 入口文件 sherpa_jni.cc ) # 第7行链接 sherpa-onnx 的核心库和 ASR/TTS 库 # 注意顺序很重要被依赖的库放后面 target_link_libraries(sherpa-demo sherpa-onnx-core sherpa-onnx-asr sherpa-onnx-tts # 强制保留 JNI 符号见 3.3 节 -u Java_com_example_demo_SherpaJni_createRecognizer -u Java_com_example_demo_SherpaJni_destroyRecognizer ${log-lib} ) # 第8行包含头文件路径让编译器能找到 sherpa-onnx 的头 target_include_directories(sherpa-demo PRIVATE ../sherpa-onnx/csrc ../sherpa-onnx/third_party/kaldi-native-fbank/src )这个 CMakeLists.txt 的每一行都有其不可替代的作用。漏掉add_subdirectory编译直接失败target_include_directories路径错一个字符#include sherpa-onnx/csrc/online-recognizer.h就报错-u参数缺失运行时找不到方法。4.3 JNI 入口编写sherpa_jni.cc的安全实践sherpa_jni.cc是 Java 与 C 的唯一桥梁必须极度谨慎#include jni.h #include string #include memory #include sherpa-onnx/csrc/online-recognizer.h #include sherpa-onnx/csrc/feature-extractor.h // 全局变量存储 recognizer 实例避免 Java 层传 handle static std::unique_ptrsherpa_onnx::OnlineRecognizer g_recognizer nullptr; extern C { // Java 层调用createRecognizer(String modelPath, String tokensPath) JNIEXPORT jlong JNICALL Java_com_example_demo_SherpaJni_createRecognizer(JNIEnv *env, jobject thiz, jstring model_path, jstring tokens_path) { // 1. 将 jstring 转为 C string必须用 GetStringUTFChars不能用 GetStringRegion const char *model_cstr env-GetStringUTFChars(model_path, nullptr); const char *tokens_cstr env-GetStringUTFChars(tokens_path, nullptr); // 2. 构建配置对象这里用最简配置 sherpa_onnx::OnlineRecognizerConfig config; config.feat_config.sampling_rate 16000; config.feat_config.feature_dim 80; config.model_config.encoder std::string(model_cstr) /encoder.onnx; config.model_config.decoder std::string(model_cstr) /decoder.onnx; config.model_config.tokens std::string(tokens_cstr); // 3. 创建 recognizer用 make_unique 确保异常安全 auto recognizer std::make_uniquesherpa_onnx::OnlineRecognizer(config); // 4. 检查是否创建成功失败则抛 Java 异常 if (!recognizer-IsValid()) { env-ThrowNew(env-FindClass(java/lang/RuntimeException), Failed to create OnlineRecognizer); return 0; } // 5. 释放 jstring必须成对调用 env-ReleaseStringUTFChars(model_path, model_cstr); env-ReleaseStringUTFChars(tokens_path, tokens_cstr); // 6. 返回裸指针Java 层用 long 存储这是 JNI 标准做法 return reinterpret_castjlong(recognizer.release()); } // Java 层调用destroyRecognizer(long handle) JNIEXPORT void JNICALL Java_com_example_demo_SherpaJni_destroyRecognizer(JNIEnv *env, jobject thiz, jlong handle) { if (handle ! 0) { // 用 unique_ptr 接管自动 delete auto *p reinterpret_castsherpa_onnx::OnlineRecognizer*(handle); std::unique_ptrsherpa_onnx::OnlineRecognizer ptr(p); // 析构函数会自动释放所有资源 } } // Java 层调用recognize(long handle, float[] waveform) JNIEXPORT jstring JNICALL Java_com_example_demo_SherpaJni_recognize(JNIEnv *env, jobject thiz, jlong handle, jfloatArray waveform) { if (handle 0) return env-NewStringUTF(); auto *recognizer reinterpret_castsherpa_onnx::OnlineRecognizer*(handle); // 1. 获取 waveform 数组长度 jsize len env-GetArrayLength(waveform); // 2. 创建本地 float 数组避免 Java 数组被 GC 移动 std::vectorfloat samples(len); env-GetFloatArrayRegion(waveform, 0, len, samples.data()); // 3. 调用识别返回结果字符串 auto result recognizer-Decode(samples.data(), len); // 4. 转为 jstring 返回 return env-NewStringUTF(result.text.c_str()); } } // extern C这段代码体现了端侧 JNI 的核心原则一切以安全为先宁可多几行代码绝不省一个检查。env-GetFloatArrayRegion而非GetFloatArrayElements是因为后者返回的指针可能失效std::vector临时存储而非直接传jfloat*是为了避免 native 层修改 Java 数组reinterpret_cast后立即用unique_ptr管理确保异常时资源不泄漏。4.4 Java 层封装SherpaJni.java的健壮性设计Java 层不是简单包装而是要处理所有边界情况public class SherpaJni { static { // 1. 加载 so 库必须在 static 块中且名字与 CMakeLists.txt 一致 System.loadLibrary(sherpa-demo); } // 2. 持有 native handle用 volatile 保证可见性 private volatile long mHandle 0; // 3. 构造函数传入模型路径 public SherpaJni(String modelPath, String tokensPath) { mHandle createRecognizer(modelPath, tokensPath); if (mHandle 0) { throw new RuntimeException(Failed to initialize Sherpa recognizer); } } // 4. 识别方法带超时保护 public String recognize(float[] waveform) { if (mHandle 0) return ; // 超时控制避免 native 层死锁导致 ANR final long start System.currentTimeMillis(); final String result recognizeNative(mHandle, waveform); if (System.currentTimeMillis() - start 10000) { // 10秒超时 android.util.Log.w(SherpaJni, Recognition took too long: (System.currentTimeMillis() - start) ms); } return result; } // 5. 必须提供 destroy 方法且标记为 synchronized 防止多线程并发 public synchronized void destroy() { if (mHandle ! 0) { destroyRecognizer(mHandle); mHandle 0; } } // 6. native 方法声明注意参数类型和名字必须与 C 严格一致 private static native long createRecognizer(String modelPath, String tokensPath); private static native void destroyRecognizer(long handle); private static native String recognizeNative(long handle, float[] waveform); }这个封装类的关键在于volatile保证 handle 变更对所有线程可见synchronized destroy()防止两个线程同时调用destroy()导致 double freerecognize()中的超时日志是线上问题排查的第一手线索。4.5 Activity 集成MainActivity.java的生产级写法最后一步把语音能力接入 UIpublic class MainActivity extends AppCompatActivity { private SherpaJni mSherpa; private AudioRecord mAudioRecord; private boolean mIsRecording false; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 1. 复制模型到 files 目录见 3.4 节 copyModelToFilesDir(); // 2. 初始化 Sherpa路径指向 files 目录 File modelDir new File(getFilesDir(), sherpa-model); mSherpa new SherpaJni(modelDir.getAbsolutePath(), new File(getFilesDir(), tokens.txt).getAbsolutePath()); // 3. 初始化 AudioRecord用最低延迟参数 int minBufferSize AudioRecord.getMinBufferSize(16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mAudioRecord new AudioRecord(MediaRecorder.AudioSource.MIC, 16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, minBufferSize * 2); // 缓冲区放大一倍防丢帧 } // 4. 录音并识别的完整流程 public void onRecordClick(View view) { if (!mIsRecording) { mAudioRecord.startRecording(); mIsRecording true; new Thread(this::recordAndRecognize).start(); } } private void recordAndRecognize() { short[] buffer new short[1600]; // 100ms 数据 ListShort allSamples new ArrayList(); while (mIsRecording) { int read mAudioRecord.read(buffer, 0, buffer.length); if (read 0) { for (short s : buffer) { allSamples.add(s); } } } // 转为 float[]归一化到 [-1.0, 1.0] float[] waveform new float[allSamples.size()]; for (int i 0; i allSamples.size(); i) { waveform[i] allSamples.get(i) / 32768.0f; } // 调用识别 String text mSherpa.recognize(waveform); // 更新 UI必须在主线程 runOnUiThread(() - { TextView tv findViewById(R.id.result_text); tv.setText(text); }); } Override protected void