Horus-VLM 安卓项目架构详解
本文以 Horus-VLM 这个真实项目为样本,从零讲清一个 Android App 的完整架构:分了哪些层、每层用什么语言、负责什么功能、数据如何传输、如何存储、异常如何处理,最后给出从零搭建安卓工程的实操路线。
目标读者:只做过 Java 后端(Spring / MyBatis / Maven 那一套)的同学。本文会把仓库的每一行目录结构拆开讲,也会讲清楚一个新安卓项目该怎么搭、怎么跑、怎么调。
目录
0. 先看全局:这个 App 是什么 Horus-VLM 是跑在车机上的视觉大模型对话应用 。硬件是「RK3588 主控芯片(跑 Android)+ RK1828 AI 协处理器(跑大模型)」,模型是 Qwen3-VL-4B / Qwen3.5-VL-4B(视觉语言模型,能看图 + 聊天)。
它的完整功能流程:
1 2 3 4 5 6 7 8 9 10 11 启动 App ──→ 发现 RKNN3 设备(自动选 PCIe 端点) ──→ 用户选择模型(Qwen3-VL 或 Qwen3.5-VL)并点「加载」 ──→ 首次使用时把 ~3.5GB 模型文件从 /sdcard 拷贝到 App 私有目录(本地化) ──→ 加载进 NPU(约 1~2 分钟) ──→ (可选)打开摄像头,勾选「画面附加」 ──→ 输入问题(或点预置问题)──→ 发「发送」 ──→ 若附画面:连拍 1~6 帧 JPEG ──→ 计算上下文预算,不够就阶梯式重载模型(1024→2048→4096→6144→8192) ──→ 阻塞式推理,同时每 80ms 轮询一次增量 token,流式刷新到聊天气泡 ──→ 推理中可随时点「停止」→ 中断,保留已生成的部分 ──→ 点「卸载」释放 NPU 内存
对比后端开发者的直觉,重要的差异点有四个:
没有服务器 。这是一个纯客户端程序,所有计算都在本机(而且大部分在协处理器上)。
没有数据库 。状态全在内存里(StateFlow),持久化的只有模型文件。
有 UI,而且 UI 绝不能卡 。后端一个接口慢 3 秒用户最多抱怨;Android 主线程阻塞 5 秒会直接 ANR(Application Not Responding)弹窗甚至被杀。
有原生(C/C++)代码 。大模型推理是 C++ 库,Kotlin 通过 JNI 调用它,类似 Java 通过 JNI 调 C 写的加密库,只是这里是项目自己的 C++ 桥接代码。
1. 心智模型转换:Java 后端 → Android 先把后端世界和 Android 世界做一次系统对照。
Java 后端概念
Android 对应物
说明
Spring Boot 工程
Android App 工程(Gradle 项目)
结构神似:都是 Gradle 构建、都有模块划分
Maven / Gradle
Gradle + AGP(Android Gradle Plugin)
AGP 是 Google 对 Gradle 的安卓定制插件
pom.xml / build.gradle
build.gradle.kts(Kotlin DSL 版)
本项目用 .kts 写法,能力没有区别
application.yml
AndroidManifest.xml + res/values/*.xml
声明式配置:权限、页面、主题、字符串
jar / war 包
APK (Android Package)
APK ≈ 可安装的运行单元,相当于 fat jar
java -jar app.jar
adb install app.apk + 用户点图标
安装到设备上运行
public static void main()
Activity.onCreate()
入口不是 main,是”页面被创建时”的回调
Tomcat 容器管理请求
Android 系统(AMS)管理 Activity 生命周期
代码被系统在特定时刻回调
Controller → Service → DAO
UI → ViewModel → Engine → Repository
移动端的标准分层,职责划分思想一致
HTTP 请求/响应 传输数据
层与层之间直接方法调用 + 回调/lambda
同一个进程内,没有网络序列化开销
Redis / 内存缓存存会话
ViewModel + StateFlow 存 UI 状态
ViewModel 随页面存活,页面销毁即回收
线程池 Executors.newFixedThreadPool
Kotlin 协程 (viewModelScope.launch)
协程 = 更轻的线程调度框架,支持挂起不阻塞
synchronized / volatile
一样有(Kotlin 里是 @Synchronized / @Volatile)
线程安全概念完全通用
JNI 调 C 库
一样是 JNI,但用 NDK + CMake 构建
本项目 80% 核心逻辑在 C++ 层
日志 logback / slf4j
android.util.Log(输出到 logcat )
adb logcat 相当于 tail -f app.log
单元测试 JUnit
JUnit + Android Instrumentation 测试
目录 test/(纯 JVM)与 androidTest/(需设备)
部署到 Linux 服务器
部署到 Android 设备(arm64-v8a)
CPU 架构固定为 ARM64,NDK 交叉编译
两个最重要的新概念,单独强调:
1.1 主线程(UI 线程):后端思维的第一道坎 Android 每个 App 默认只有一个”主线程”,它负责:
绘制界面、响应触摸
执行所有生命周期回调(onCreate 等)
任何耗时操作(读文件、网络、推理)放在主线程超过 ~5 秒,系统就弹 ANR 并可能杀掉 App。 所以 Android 开发者把”耗时活丢到后台线程”刻进了 DNA。本项目中所有重活都在 Dispatchers.IO 协程里执行。
拿 Spring 打个比方:整个应用只有一个线程,而且这个线程还要兼做页面渲染,所有 Service 方法都必须瞬间返回,慢活儿必须丢给”别的线程”。这就是 Android 的现实。
1.2 生命周期:对象不再由开发者创建和销毁 后端里 new Service() 是开发者自己 new 的;Android 里 Activity、ViewModel 由系统创建和销毁 :
用户把 App 切到后台、旋转屏幕、内存不足,系统都可能销毁页面,然后再重建它。
所以状态不能随便放在 Activity 的成员变量里 (重建即丢失),要放到 ViewModel(系统保证在页面重建时保留)或持久化存储里。
这类似后端的 Controller 对象,被框架随时销毁重建,所以状态要放到”框架能保住”的地方(session / Redis)。ViewModel 就是移动端的那个”框架能保住”的地方。
2. 分层架构总览 Horus-VLM 分成六层。自上而下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 ┌───────────────────────────────────────────────────────────────────┐ │ 用户(触摸屏幕 / 键盘输入) │ └──────────────────────────────┬────────────────────────────────────┘ ▼ ┌───────────────────────────────────────────────────────────────────┐ │ ① 表现层 UI Layer 语言:Kotlin + Jetpack Compose │ │ MainActivity.kt / VlmScreen.kt / Theme.kt │ │ 职责:把「状态」画成界面;把「用户操作」转成对 ViewModel 的方法调用 │ │ 禁止:业务计算、IO、任何耗时操作 │ └──────────────────────────────┬────────────────────────────────────┘ ▲ 订阅 StateFlow(只读) │ 调用 vm.send() / vm.loadModel() 等 │ ▼ ┌───────────────────────────────────────────────────────────────────┐ │ ② 状态编排层 ViewModel 语言:Kotlin + 协程 │ │ VlmViewModel.kt │ │ 职责:唯一的状态权威(Single Source of Truth)。把一次操作 │ │ 编排出正确的执行顺序(拍照→预算→加载→推理→轮询→收尾) │ │ 把结果写回 StateFlow,驱动 UI 自动刷新 │ └──────────────────────────────┬────────────────────────────────────┘ ▼ 普通方法调用(同进程) ┌───────────────────────────────────────────────────────────────────┐ │ ③ 引擎层 Engine 语言:Kotlin │ │ VlmEngine.kt / ModelLocator.kt / CameraController.kt │ │ 职责:模型生命周期(加载/卸载/切换)、NPU 设备内存、摄像头、 │ │ 模型文件的定位与拷贝(本地化) │ │ 特点:纯 Kotlin IO 逻辑,不碰 UI,不碰状态存储 │ └──────────────────────────────┬────────────────────────────────────┘ ▼ external fun(JNI 边界) ┌───────────────────────────────────────────────────────────────────┐ │ ④ JNI 桥接层 语言:Kotlin 声明 + C++ 实现 │ │ VlmNative.kt ↔ vlm_jni.cpp │ │ 职责:类型转换(String↔char*、数组↔指针)、句柄管理、 │ │ 按模型 family 分发到不同的 C++ 桥 │ │ 特点:极薄,不做业务逻辑 │ └──────────────────────────────┬────────────────────────────────────┘ ▼ C++ 函数调用(同进程) ┌───────────────────────────────────────────────────────────────────┐ │ ⑤ Native 推理层 语言:C++(C++11) │ │ qwen3_vlm_bridge.cc / qwen35_vlm_bridge.cc + RKNN3 SDK 源码 │ │ 职责:组织一次完整推理(vision 编码 + LLM prefill/decode)、 │ │ 流式 token 队列、推理中断协议、指标采集 │ └──────────────────────────────┬────────────────────────────────────┘ ▼ librknn3_api.so → 运行时 dlopen 后端 ┌───────────────────────────────────────────────────────────────────┐ │ ⑥ 系统/硬件层 │ │ librknn3_api_rkcp.so → socket → rknn3_transfer_proxy 守护进程 │ │ → PCIe → RK1828 协处理器(模型真正执行的地方) │ └───────────────────────────────────────────────────────────────────┘
层间通信规矩 (和后端分层架构一样严格):
方向
方式
例子
UI → ViewModel
直接调方法
按钮点击 vm::loadModel
ViewModel → UI
推送 状态(观察者模式)
StateFlow → Compose 自动重组
ViewModel → Engine
直接调方法(在协程里)
engine.generate(prompt, images) { ... }
Engine → Native
JNI 调用
VlmNative.nativeVlmGenerate(...)
Native → Engine
返回值 或 轮询 (不反向回调 JNI)
返回码 + pollTokens()
Engine → ViewModel
返回值 / lambda 回调
onLog("...")、onDelta(text)
注意最后两行:本项目没有让 C++ 反向调用 Kotlin (虽然 JNI 支持这么做)。取而代之的是”返回码 + 轮询”模式,原因在第 8 章 详细解释。
各层语言与行数占比(本项目的真实情况)
层
语言
文件
大致规模
表现层
Kotlin(Compose DSL)
ui/VlmScreen.kt + ui/Theme.kt
~1100 行
状态编排层
Kotlin(协程 + Flow)
vm/VlmViewModel.kt
~780 行
引擎层
Kotlin
engine/*.kt(4 个文件)
~600 行
JNI 桥接层
Kotlin 签名 + C++ 实现
VlmNative.kt(68 行)+ vlm_jni.cpp(353 行)
~420 行
Native 推理层
C++
两个 bridge + CMakeLists
~1000 行(不含 SDK 源码)
配置/资源
XML / KTS
AndroidManifest.xml、res/、*.gradle.kts
~200 行
3. 工程骨架:每个文件是什么 先看完整目录树(带注释),再看每个关键文件的职责:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 Horus-VLM/ # 一个独立的 Gradle 工程(相当于一个后端项目) ├── settings.gradle.kts # 声明本工程包含哪些模块(这里是 :app) ├── build.gradle.kts # 根构建脚本:声明"用哪些插件、什么版本" ├── gradle.properties # JVM 内存参数、全局开关 ├── gradlew / gradlew.bat # Gradle Wrapper 启动脚本(等价 mvnw) ├── gradle/wrapper/ # 锁定 Gradle 版本(8.7) │ └── gradle-wrapper.properties ├── local.properties # 本机 SDK/NDK 路径(不入库,每台机器不同) ├── README.md # 构建/部署/踩坑全记录(强烈建议通读) └── app/ # 唯一的功能模块(application 类型) ├── build.gradle.kts # ★ 模块级构建脚本:SDK 版本、依赖、NDK 配置 ├── proguard-rules.pro # 混淆规则(本工程 release 未启用混淆) └── src/main/ # main 源集(还有 test/ 和 androidTest/ 同理) ├── AndroidManifest.xml # ★ 应用清单:权限、组件、主题声明 ├── assets/ # 打包进 APK 的"原始资源"(不可编译) │ └── haystack.txt # 大海捞针测试的语料 ├── cpp/ # ★ C/C++ 源码 + CMake 构建脚本 │ ├── CMakeLists.txt # 把桥接层和 SDK 源码编成 libhsvlm_rknn3.so │ ├── vlm_jni.cpp # JNI 函数实现(Kotlin external 的对应物) │ ├── qwen3_vlm_bridge.cc/.h # Qwen3-VL 推理桥 │ └── qwen35_vlm_bridge.cc/.h # Qwen3.5-VL 推理桥 ├── java/com/horus/hsvlm/ # ★ 所有 Kotlin 源码(java/ 目录也能放 Kotlin) │ ├── MainActivity.kt # 入口页面(31 行) │ ├── engine/ # 引擎层 │ │ ├── VlmNative.kt # JNI 声明 │ │ ├── VlmEngine.kt # 模型生命周期 + 生成 │ │ ├── ModelLocator.kt # 模型文件定位/本地化 │ │ └── CameraController.kt # CameraX 封装 │ ├── model/UiModels.kt # 数据模型(纯数据类/枚举,无逻辑) │ ├── vm/VlmViewModel.kt # 状态编排层 │ └── ui/ # 表现层 │ ├── VlmScreen.kt # 全部 Compose 界面 │ ├── Theme.kt # 深色座舱主题(颜色常量) │ └── theme/ # 字体/形状定义 ├── jniLibs/arm64-v8a/ │ └── librknn3_api_rkcp.so # ★ 预编译的 vendor 后端库(手动拷贝,最易遗漏) └── res/ # 资源目录(可编译,有资源 ID) ├── values/strings.xml # 字符串资源 ├── values/themes.xml # 主题(XML 层) ├── drawable/ # 矢量图(启动图标背景/前景) └── mipmap-anydpi-v26/ # 自适应图标
3.1 Gradle 构建体系(对比 Maven) settings.gradle.kts 是工程的全集声明,相当于 Maven 聚合工程的 <modules>:
1 2 rootProject.name = "Horus-VLM" include(":app" )
上面还有两段 pluginManagement / dependencyResolutionManagement,作用是集中声明依赖仓库 (google / mavenCentral),并禁止各模块自己乱加仓库(FAIL_ON_PROJECT_REPOS)。相当于在父 POM 里统一 repositories,子模块只管用。
根 build.gradle.kts 只声明插件版本,apply false 表示”在这里只定义版本,不真正应用”:
1 2 3 4 plugins { id("com.android.application" ) version "8.5.2" apply false id("org.jetbrains.kotlin.android" ) version "1.9.24" apply false }
这是 Gradle 多模块的常见写法:根脚本管版本,模块脚本管启用。
app/build.gradle.kts 是真正干活的构建脚本,每一块都值得看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 android { namespace = "com.horus.hsvlm" compileSdk = 34 defaultConfig { applicationId = "com.horus.hsvlm" minSdk = 28 targetSdk = 34 versionCode = 1 versionName = "1.0.0" ndk { abiFilters += "arm64-v8a" } externalNativeBuild { cmake { cppFlags += "-std=c++11" arguments += "-DRKNN_MZ_DIR=$rknnModelZooDir " } } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; ... } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.14" } externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt" ); version = "3.22.1" } } packaging { jniLibs { useLegacyPackaging = true keepDebugSymbols += "**/librknn3_api_rkcp.so" } } } dependencies { val composeBom = platform("androidx.compose:compose-bom:2024.06.00" ) implementation(composeBom) implementation("androidx.core:core-ktx:1.13.1" ) implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3" ) implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3" ) implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3" ) implementation("androidx.activity:activity-compose:1.9.0" ) val cameraxVersion = "1.3.4" implementation("androidx.camera:camera-core:$cameraxVersion " ) implementation("androidx.camera:camera-camera2:$cameraxVersion " ) implementation("androidx.camera:camera-lifecycle:$cameraxVersion " ) implementation("androidx.camera:camera-view:$cameraxVersion " ) implementation("androidx.compose.ui:ui" ) implementation("androidx.compose.material3:material3" ) ... }
几个对后端开发者最有解释力的问题:
依赖从哪来? google()(AndroidX 库)和 mavenCentral()。androidx.* 就是 Google 官方的”标准库”。
BOM 是什么? compose-bom 是一张”版本对照表”,让一组 Compose 库版本彼此兼容,写法上只声明 group:artifact、不写版本。类比 Spring Boot 的 spring-boot-dependencies 父 POM。
.so 是什么? Linux 世界的动态链接库(相当于 Windows 的 .dll)。jniLibs/arm64-v8a/ 下的 .so 会被原样打进 APK。librknn3_api_rkcp.so 是 vendor(瑞芯微)提供的协处理器后端,因为它是运行时 dlopen 加载 的(没人显式链接它),AGP 不会自动收集,必须手动放进 jniLibs(这是本项目最大的一个坑,README 踩坑 #3 有完整记录)。
externalNativeBuild :告诉 AGP”C++ 源码怎么编译”。AGP 会调用 CMake + NDK 工具链,把 src/main/cpp/ 编译成 libhsvlm_rknn3.so 并打进 APK。
gradle.properties 是 Gradle 的全局配置(JVM 内存、缓存、并行开关):
1 2 3 4 5 org.gradle.jvmargs =-Xmx2560m -Dfile.encoding=UTF-8 # 给 Gradle 进程的堆内存 org.gradle.caching =true org.gradle.parallel =true android.useAndroidX =true # 使用 AndroidX(现代库) android.nonTransitiveRClass =true # R 资源类优化
Gradle Wrapper(gradlew + gradle/wrapper/) 和后端的 mvnw 一模一样:把 Gradle 版本锁在工程里,任何机器上执行 ./gradlew 都会自动下载并使用 Gradle 8.7 ,而不是本机碰巧装的版本。
3.2 AndroidManifest.xml:应用的”身份证 + 配置中心” Manifest 是 Android 特有的声明式配置(对比后端的 application.yml + web.xml 二合一)。本项目的完整清单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <manifest xmlns:android ="http://schemas.android.com/apk/res/android" > <uses-permission android:name ="android.permission.CAMERA" /> <uses-feature android:name ="android.hardware.camera.any" android:required ="false" /> <uses-permission android:name ="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name ="android.permission.MANAGE_EXTERNAL_STORAGE" /> <application android:allowBackup ="true" android:icon ="@mipmap/ic_launcher" <!-- @xxx 是资源引用语法 -- > android:label="@string/app_name" android:theme="@style/Theme.HSVlm"> <activity android:name =".MainActivity" <!-- . 开头 = 相对于 package 的全名 -- > android:exported="true" android:screenOrientation="landscape" android:configChanges="orientation|screenSize|keyboardHidden|density" android:windowSoftInputMode="adjustResize"> <intent-filter > <action android:name ="android.intent.action.MAIN" /> <category android:name ="android.intent.category.LAUNCHER" /> </intent-filter > </activity > </application > </manifest >
要点:
权限模型 :CAMERA 这类属于”危险权限”,要在 Manifest 声明 + 运行时弹窗请求(本项目只在用户主动开相机时才请求);MANAGE_EXTERNAL_STORAGE 在 Android 11+ 没有运行时弹窗,只能跳转到系统设置页让用户手动开(本项目做了引导对话框)。
intent-filter :声明这个 Activity 是应用入口。类比后端:@RequestMapping("/") 定义根路由。
本项目只有一个 Activity ,所有界面都在 Compose 内部切换,这是现代单页面应用(SPA)思路,用后端的说法就是”只有一个 index.html + 前端路由”。
3.3 res/ 与 assets/ 的区别(高频疑问)
res/
assets/
编译
会被 AAPT 编译,生成 R.xxx 资源 ID
原样打包 ,不编译
引用方式
@string/app_name、R.string.app_name
代码里 assets.open("haystack.txt")
适合放什么
字符串、颜色、主题、图标、布局
任意原始文件(txt/二进制/模型)
本项目实例
strings.xml(应用名)、themes.xml
haystack.txt(测试语料)
放到后端里:res/ ≈ src/main/resources/static(被构建处理过),assets/ ≈ 原封不动的 classpath 文件。
4. 表现层:UI 怎么画(Kotlin + Jetpack Compose) 4.1 先理解 Compose 的范式:UI = f(State) 老式 Android(2019 之前)和后端模板引擎(Thymeleaf/JSP)很像:写一份 XML 布局文件,用 findViewById 找到控件,再 setText 改内容。
本项目用的是Jetpack Compose ,Google 的现代 UI 框架,范式完全不同:
1 2 3 传统方式: 写 XML → 找到控件 → 手动 setText 更新 Compose: UI = f(State) 状态变了 → 框架自动重新执行"画界面函数" → 界面自动更新
这就像 React/Vue 的声明式渲染:只描述”界面长什么样(当前状态)”,不描述”怎么改界面”。
本项目里这个机制的落地点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Composable fun VlmScreen (vm: VlmViewModel = viewModel() ) { val state by vm.state.collectAsStateWithLifecycle() Column(...) { HeaderBar(state, vm) ModelBar(state, vm) Row(...) { Column(Modifier.weight(0.60f )) { ChatList(state, Modifier.weight(1f )) TokenTestPanel(state, vm) PresetQuestions(...) NeedleTestPanel(state, vm, ...) PerfStatsPanel(state, vm) } Box(Modifier.weight(0.40f )) { Column { CameraPanel(state, vm, permissionLauncher) LogEntryBar(state.logs, onOpen = {...}) } AnimatedVisibility(visible = logExpanded) { LogOverlayPanel(state.logs, ...) } } } InputBar(state, vm) } if (needleDialogExpanded) { NeedleTestDialog(...) } if (storageDialogVisible) { StoragePermissionDialog(...) } }
4.2 入口页面 MainActivity:只有 31 行 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class MainActivity : ComponentActivity () { override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false ) WindowInsetsControllerCompat(window, window.decorView).apply { systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE hide(WindowInsetsCompat.Type.systemBars()) } setContent { HsVlmTheme { VlmScreen() } } } }
对照后端理解:
onCreate 约等于 main():程序入口,但会被反复调用(每次页面重建)。
setContent {} 相当于”渲染视图层”。ComponentActivity 是 Compose 应用的标准 Activity 基类。
VlmScreen() 不传参数也能拿到 ViewModel(vm: VlmViewModel = viewModel() 是框架提供的默认值函数,负责创建/复用 ViewModel 实例)。
4.3 界面组件解剖
组件(Composable 函数)
对应界面区域
关键行为
HeaderBar
顶栏
状态圆点(推理中会呼吸闪烁)、设备 ID、上一轮耗时、”清空”按钮、错误横幅
ModelBar
模型栏
两个模型 chip 选择;按钮按状态三态变化:加载 / 卸载 / 切换到 xx
ChatList
聊天记录
气泡列表:用户气泡(可带图片缩略图)、助手气泡(流式中/已中断/模型名标签/指标块)
TokenTestPanel
定量测试
128/256/512/1024 token 快捷 chip,点击走 vm.sendTokenTest(n)
PresetQuestions
预置问题
5 条一条问 chip
NeedleTestPanel
大海捞针
打开配置对话框,跑网格测试
PerfStatsPanel
性能开关
开关”回答后附加性能统计块”
CameraPanel
相机面板
权限请求、预览、开关”画面附加”、采集张数选择(1~6)
LogEntryBar / LogOverlayPanel
日志
单行日志条 + 点击展开全屏日志浮层
InputBar
输入栏
文本框 + 发送/停止按钮(推理中变红色”停止”)
NeedleTestDialog
弹窗
配置测试长度/深度/针/问题
StoragePermissionDialog
弹窗
引导跳转”所有文件访问”授权页
UI 层的纪律 (本项目严格遵守):
只读状态、只调方法 。UI 里看不到任何业务逻辑,所有点击都只是 vm::xxx。
不做耗时操作 。拍照、推理全在 ViewModel 的协程里。
一切都是函数 。没有 findViewById,没有可变控件对象,界面是状态的纯函数。
4.4 事件如何从 UI 回到 ViewModel Compose 里事件回传就是传 lambda(Kotlin 的函数类型),等价于后端里传回调接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 Button(onClick = { vm.send(state.input) }) { Text("发送" ) } PresetQuestions(questions = vm.presetQuestions, onPick = { vm.send(it) }) OutlinedTextField( value = state.input, onValueChange = vm::setInput, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { vm.send(state.input) }) )
注意输入框是受控组件 :value = state.input(值从状态来),onValueChange = vm::setInput(变化写回状态)。绕一圈但确保了”状态是唯一真相”,类似 MVC 里的双向绑定,只是数据流是单向的。
4.5 主题:Theme.kt 1 2 3 4 5 6 7 8 9 10 11 12 val CabinBg = Color(0xFF08131A )val CabinPrimary = Color(0xFF2FE0C6 )val CabinText = Color(0xFFDCE9EC )val CabinWarn = Color(0xFFFFC46B )val CabinError = Color(0xFFFF6B6B ) ...@Composable fun HsVlmTheme (content: @Composable () -> Unit ) { MaterialTheme(colorScheme = VlmColors, content = content) }
颜色是 Color(0xFFRRGGBB)(ARGB 十六进制)。所有组件从这组常量取色,保证整体风格统一。相当于集中管理的 CSS 变量/主题文件。
4.6 权限请求:以相机为例 Android 的运行时权限是异步回调模型:
1 2 3 4 5 6 val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> vm.setCameraGranted(granted) }
像发起一次”需要用户确认的异步审批”,结果通过回调返回,期间发起方不能卡住。
4.7 错误横幅自动消失 1 2 3 4 5 6 7 LaunchedEffect(state.error) { if (state.error != null ) { delay(4000 ) vm.dismissError() } }
LaunchedEffect 是 Compose 的”副作用工具”:当 key(这里是 state.error)变化时,取消旧的、启动新的协程执行块。类似”监听某个配置变更事件后延迟执行清理任务”。
5. 状态编排层:ViewModel 如何指挥一切 如果说 UI 层是”脸”,那么 ViewModel 就是”大脑”。它是整个应用唯一的状态权威 ,也是所有业务流程的编排者。
5.1 状态容器:用 data class 描述整个界面 先从数据开始。整个界面的状态收敛在一个不可变数据类里(model/UiModels.kt):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 data class VlmUiState ( val phase: VlmPhase = VlmPhase.INITIALIZING, val selectedModel: VlmModel = VlmModel.QWEN3_VL, val loadedModel: VlmModel? = null , val deviceId: String = "" , val cameraGranted: Boolean = false , val cameraReady: Boolean = false , val cameraStatus: String = "未授权" , val attachCameraFrame: Boolean = false , val frameCaptureCount: Int = 1 , val perfStatsEnabled: Boolean = false , val input: String = "" , val messages: List<ChatMessage> = emptyList(), val error: String? = null , val lastMetricsMs: Long ? = null , val logs: List<LogEntry> = emptyList(), val needleProgress: NeedleTestProgress = ..., val needleResults: List<NeedleTestPoint> = ... ) { val ready: Boolean get () = phase == VlmPhase.READY val generating: Boolean get () = phase == VlmPhase.GENERATING val busy: Boolean get () = phase == VlmPhase.INITIALIZING || phase == VlmPhase.LOADING || phase == VlmPhase.GENERATING || phase == VlmPhase.NEEDLE_TESTING }
对照后端理解:
data class ≈ 一个带 equals/hashCode/toString/copy 的不可变 POJO(Lombok @Value 风格)。
整个 UI 状态是一个值对象 。任何修改都是 state.copy(xxx = newValue) 生成新对象,而不是就地修改。好处:不会出现”某个字段被悄悄改掉”的诡异 bug,状态变化可追溯。
VlmPhase 枚举是显式状态机 :
1 2 3 INITIALIZING ──→ UNLOADED ──→ LOADING ──→ READY ──→ GENERATING ──→ (回到 READY) │ │ └──→ ERROR ←───────────────────────────────────────────────┘
把 phase 想成订单状态机(待支付→已支付→已发货)。所有 UI 按钮的可用性都由 phase 推导,例如”发送”按钮只在 READY 时启用、”停止”按钮只在 GENERATING 时出现。
5.2 状态的发布:StateFlow = 后端的观察者模式 1 2 3 4 5 class VlmViewModel (app: Application) : AndroidViewModel(app) { private val _state = MutableStateFlow(VlmUiState()) val state = _state.asStateFlow() }
MutableStateFlow / StateFlow 是 Kotlin 协程库里的热数据流 :它永远持有”当前值”,任何订阅者(UI)都会立刻拿到最新值,之后每次 value 变化都会收到通知。
对照后端:StateFlow ≈ Redis pub/sub 或者 Spring 的 ApplicationEventPublisher:发布者只管更新,订阅者自动被通知。区别是 StateFlow 是进程内的、带当前值的 。
下划线命名 _state / state 是 Kotlin 惯例:私有可变版 + 公开只读版。
所有修改都通过 _state.update { it.copy(...) } 完成,update 是原子操作(对比后端的 AtomicReference.updateAndGet),保证多线程并发更新不丢数据。
UI 侧的订阅端(第 4 章见过):
1 val state by vm.state.collectAsStateWithLifecycle()
这就是整个数据流的”回流通道” :ViewModel 改 _state → StateFlow 通知 → Compose 重组 → 屏幕刷新。UI 永远不需要”手动刷新”。
5.3 流程编排:一次 send() 里到底发生了什么 send() 是整个项目最核心的方法,把”用户摁了发送”翻译成一整套有序操作。用简化代码展示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 fun send (rawPrompt: String ) { val current = _state.value if (current.phase == VlmPhase.GENERATING) { flashError("推理进行中,请先点击停止" ); return } if (current.phase != VlmPhase.READY) { flashError("模型未就绪" ); return } val prompt = rawPrompt.trim() if (prompt.isEmpty()) return _state.update { it.copy(input = "" , error = null , phase = VlmPhase.GENERATING) } sendJob = viewModelScope.launch(Dispatchers.IO) { val captured = mutableListOf<File>() if (wantFrames) { for (i in 0 until count) { runCatching { camera.captureJpeg(file, w, h) } .onFailure { log("WARN" , "帧捕获失败: ${it.message} " ) } .getOrNull()?.let(captured::add) if (i != count - 1 ) delay(FRAME_CAPTURE_INTERVAL_MS) } if (captured.isEmpty()) log("WARN" , "画面捕获失败,本轮退化为纯文本" ) } val required = promptTokens + imageCount * perImageTokens + TEMPLATE_TOKEN_SLACK if (!ensureContextFor(required)) { flashError("...超出模型上限(8192)..." ); return @launch } _state.update { it.copy(messages = it.messages + userMsg + assistantMsg) } log("INFO" , "提问: $promptPreview " ) val poller = launch { while (isActive) { engine.pollTokens().takeIf { it.isNotEmpty() } ?.let { appendToMessage(assistantId, it) } delay(POLL_INTERVAL_MS) } } runCatching { engine.generate(prompt, imagePaths) { final -> appendToMessage(assistantId, final ) } }.also { poller.cancel() } .fold( onSuccess = { result -> finishMessage(assistantId, cancelled = result.status == CANCELLED, null ) if (current.perfStatsEnabled && result.status != ERROR) { attachMetrics(...) } _state.update { it.copy(phase = VlmPhase.READY, lastMetricsMs = elapsed) } log("INFO" , "回答完成(${elapsed/1000 f} s)" ) }, onFailure = { error -> finishMessage(assistantId, cancelled = false , errorText = "推理异常:${error.message} " ) _state.update { it.copy(phase = VlmPhase.READY) } log("ERROR" , "推理异常: ${error.message} " ) } ) } }
两个协程的舞蹈 :
1 2 3 4 5 6 IO 调度器上有两个协程同时在跑: 协程 A(生成):engine.generate(...) ← 阻塞在这里几十秒,直到 native 返回 协程 B(轮询):每 80ms → pollTokens() → 把增量写进 StateFlow A 结束(完成/被中断/出错)→ poller.cancel() → 状态收尾
为什么不能让 C++ 直接回调 Kotlin 来推送 token?见第 8 章。这个”阻塞生成 + 并行轮询”是本项目对 vendor 限制的工程化绕行。
5.4 中断:从 UI 到 NPU 的完整链路 用户点”停止”(主线程):
1 2 3 4 5 6 7 8 fun stopGenerating () { if (_state.value.phase != VlmPhase.GENERATING) return if (engine.cancel()) { log("WARN" , "中断请求已发出(rknn3_session_stop),等待推理线程退出…" ) } else { log("WARN" , "中断未生效:推理可能刚好已结束" ) } }
这条链路的完整展开在第 9 章。这里记住一个铁律(代码注释原文):
Never cancel the generate coroutine itself — the blocking native call must unwind on its own after the session stop.
永远不要取消那个正在阻塞的协程;阻塞中的 native 调用必须靠”会话停止”自行退出。
为什么?如果直接 sendJob.cancel(),Kotlin 只是标记协程取消,但当前卡在 C++ 的线程并不会因此停止,反而会让”谁负责收尾”变得混乱。正确做法:让协程继续阻塞 → 调 native 的 stop → native 自己返回 → 协程正常走完收尾逻辑。
5.5 生命周期:onCleared = 告别仪式 1 2 3 4 5 override fun onCleared () { camera.shutdown() engine.close() super .onCleared() }
onCleared 在 ViewModel 真正销毁时调用(页面永久关闭),对应后端注解 @PreDestroy。资源必须在这里释放 :相机、NPU 模型句柄都不是垃圾回收能自动处理的(它们不是 JVM 对象,而是操作系统/硬件资源)。
5.6 日志系统:应用内日志面板 log() 把消息追加进状态里的日志列表(最多 200 条),UI 实时显示:
1 2 3 4 private fun log (level: String , message: String ) { val entry = LogEntry(clock.format(Date()), level, message) _state.update { it.copy(logs = (it.logs + entry).takeLast(MAX_LOG_ENTRIES)) } }
四级日志:INFO(正常流程)、WARN(可恢复问题)、ERROR(失败)、以及 native 层通过 logcat 输出的。用户不接 adb 也能从应用内面板看到全部关键事件,这是车机现场调试的实用设计。
6. 引擎层:VlmEngine / ModelLocator / CameraController 引擎层是”干实事的人”:它不知道 UI 长什么样,只负责三件事:管模型、管相机、管文件 。三个类对应三个文件。
6.1 VlmEngine:模型生命周期管理器 句柄模式(Handle Pattern) 1 2 3 4 5 class VlmEngine (private val context: Context) { @Volatile private var handle: Long = 0L @Volatile private var currentModel: VlmModel? = null val modelReady: Boolean get () = handle != 0L }
handle 是一个 Long,它是 C++ 侧对象的地址(指针)。这就是 handle 模式 :Java/Kotlin 不持有 C++ 对象,只持有一个不透明的数字句柄,所有操作都带着这个句柄去 native 找对象。
像数据库连接池里的 Connection 对象:持有的只是一个引用,真正的资源在别处。用完必须显式释放 (unload/close),JVM GC 管不到 C++ 内存。
@Volatile:保证多线程读到的 handle 是最新值(和 Java 的 volatile 完全同一个语义)。因为 handle 会被 IO 协程写、被 UI 线程读(modelReady 判断)。
加载模型(含”先卸后装”和内存测量) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Synchronized fun loadModel (model: VlmModel , maxContextLen: Int = 0 , onLog: (String ) -> Unit = {}) : Boolean { if (handle != 0L && currentModel == model) return true if (handle != 0L ) unloadInternal(onLog) check(VlmNative.available) { "native 库未加载 (libhsvlm_rknn3.so)" } val dir = ModelLocator.ensureLocal(context, model, onLog) val before = queryDeviceMemory() val newHandle = runCatching { VlmNative.nativeVlmInit(dir.absolutePath, model.familyId, maxContextLen) }.getOrDefault(0L ) if (newHandle == 0L ) { onLog("${model.displayName} 初始化失败" ); return false } handle = newHandle currentModel = model currentMaxContextLen = if (maxContextLen > 0 ) maxContextLen else DEFAULT_CONTEXT_LEN queryDeviceMemory()?.let { after -> ... onLog("NPU 内存占用:2781 MB(剩余 2339 / 5120 MB)" ) } return true }
细节沉淀:
@Synchronized = Java 的 synchronized 方法。加载/卸载/关闭都可能从不同协程发起,必须互斥。
check(cond) { msg } = Kotlin 版断言(抛 IllegalStateException)。这里用于”库没加载”等编程错误。
onLog: (String) -> Unit = {},Kotlin 的函数类型参数(高阶函数)。Engine 不直接写 UI 状态,而是把日志”回调”给 ViewModel。等价于注入一个 Consumer<String> 或回调接口。
内存测量思路:加载前后各查一次 NPU 设备内存,free 差值就是这个模型的占用,相当于”称重法”。
生成与取消 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 fun generate (prompt: String , imagePaths: List <String >?, onDelta: (String ) -> Unit ) : GenerateResult { check(handle != 0L ) { "模型未就绪" } check(prompt.isNotBlank()) { "问题为空" } val images = if (imagePaths.isNullOrEmpty()) { listOf(blankImage(currentModel).absolutePath) } else imagePaths val ret = VlmNative.nativeVlmGenerate(handle, images.toTypedArray(), prompt) onDelta(VlmNative.nativeVlmPollTokens(handle)) return when { ret == 1 || VlmNative.nativeVlmLastRunCancelled(handle) -> GenerateResult(GenerateStatus.CANCELLED, "" ) ret == 0 -> GenerateResult(GenerateStatus.COMPLETED, "" ) else -> GenerateResult(GenerateStatus.ERROR, "" ) } }fun cancel () : Boolean = handle != 0L && VlmNative.nativeVlmCancel(handle)
blankImage() 解决一个实际约束:视觉语言模型必须有图像输入 (架构限制),纯文本聊天时也要凑一张图。代码在 filesDir 里生成一张 640×512 的灰色 JPEG 缓存复用:
1 2 3 4 5 6 7 8 9 private fun blankImage (model: VlmModel ) : File { val file = File(context.filesDir, "blank_${model.visionWidth} x${model.visionHeight} .jpg" ) if (file.isFile && file.length() > 0 ) return file val bmp = Bitmap.createBitmap(model.visionWidth, model.visionHeight, Bitmap.Config.ARGB_8888) Canvas(bmp).drawColor(Color.rgb(120 , 120 , 120 )) file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90 , it) } bmp.recycle() return file }
注意 bmp.recycle():Android 的 Bitmap 内存(尤其大图)不吃 JVM 堆,必须手动回收,否则容易 OOM。这是移动端开发的常识性纪律。
6.2 ModelLocator:模型文件的”搬运工” 这个类解决一个物理问题:RKNN3 的权重加载器要用 DMA 直接把文件页搬运到协处理器,而 /sdcard 是 FUSE 模拟文件系统,DMA 搬不了 。所以模型必须待在真实文件系统(App 私有目录,f2fs/ext4)里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 enum class VlmModel ( val displayName: String, val dirName: String, val tag: String, val requiredFiles: List<String>, val familyId: Int , val visionWidth: Int , val visionHeight: Int ) { QWEN3_VL("Qwen3-VL 4B" , "Qwen3-VL-4B_512x640" , "Qwen3-VL-4B" , listOf( "Qwen3-VL-4B-llm.rknn" , "Qwen3-VL-4B-llm.weight" , "Qwen3-VL-4B-llm.embed.bin" , "Qwen3-VL-4B-llm.tokenizer.gguf" , "Qwen3-VL-4B-vision.rknn" , "Qwen3-VL-4B-vision.weight" ), 0 , 640 , 512 ), QWEN35_VL(...) }
本地化流程 (ensureLocal):
1 2 3 4 5 6 7 8 9 10 检查内部目录 filesDir/models/<dirName> 是否 6 个文件齐 ── 齐 ──→ 直接用 │ 不齐 ▼ 按优先级找第一个"文件齐全"的源目录: ① getExternalFilesDir("models") (App 专属外部目录) ② /data/local/tmp/hsvoice/models/ (调试常用) ③ /sdcard/hsvoice/models/ (量产推送路径) │ 找到 ▼ 逐文件拷贝到内部目录(1MB 缓冲;.part 临时文件 → 校验长度 → 原子改名)
拷贝逻辑里有两个值得学习的细节:
1 2 3 4 5 6 7 8 private fun copyChecked (src: File , dst: File , onLog: (String ) -> Unit ) { if (dst.isFile && dst.length() == src.length()) return val tmp = File(dst.parentFile, "${dst.name} .part" ) src.inputStream().use { input -> tmp.outputStream().use { input.copyTo(it, 1 shl 20 ) } } check(tmp.length() == src.length()) { "拷贝校验失败: ${src.name} " } if (dst.exists()) check(dst.delete()) { ... } check(tmp.renameTo(dst)) { ... } }
这和数据库写入的”先写临时文件 + rename 原子替换”是同一个思想:中途断电/失败,也不会留下一个”看起来完整实际残缺”的模型文件 。校验失败会抛异常(check),由上层捕获后把 App 置为 ERROR 状态。
6.3 CameraController:CameraX 相机封装 职责:连上相机 API → 持续拿”最新一帧”→ 按需把最新帧导出成品 JPEG(中心裁剪到模型需要的分辨率)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class CameraController (private val context: Context) { private val frameLock = Any() @Volatile private var latest: Bitmap? = null private val analysisExecutor = Executors.newSingleThreadExecutor() private val bindGeneration = AtomicInteger(0 ) fun bind (previewView: PreviewView , owner: LifecycleOwner , onEvent: (String ) -> Unit ) { val future = ProcessCameraProvider.getInstance(context) future.addListener({ if (generation != bindGeneration.get ()) return @addListener runCatching { val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() analysis.setAnalyzer(analysisExecutor) { proxy -> processFrame(proxy, ...) } cp.bindToLifecycle(owner, selector, preview, analysis) }.onFailure { onEvent("相机打开失败: ${it.message} " ) } }, ContextCompat.getMainExecutor(context)) } }
关键设计:
STRATEGY_KEEP_ONLY_LATEST :相机帧来得比处理快时,丢旧帧只处理最新帧。对照后端:一个永远只保留最新消息的队列,因为”对话看的是当前画面”,旧帧没有价值。
bindToLifecycle(owner, ...) :把相机的生命周期绑到页面。页面退到后台时相机自动暂停,回来自动恢复,这是 CameraX 自动完成的,不用手动管理。
bindGeneration 代数号 :相机初始化是异步的(future.addListener 回调可能在用户已经点”关闭”之后才到)。每次 bind/unbind 都让代数号 +1,回调时核对代数号,不匹配就丢弃,防止”幽灵连接”。类似用乐观锁版本号丢弃过期回调。
帧的持有策略 :分析线程把最新的 Bitmap 存进 latest(锁保护,替换时 recycle 旧的);captureJpeg() 时先”复制一份”再在锁外做裁剪缩放,避免长时间占锁。内存纪律贯穿始终 :每个临时 Bitmap 用完都 recycle()。
导出一帧的完整加工链:
1 2 3 4 YUV 帧 → toBitmap() → rotate(旋转角度) → 深拷贝(脱离 gralloc 缓冲) → 存入 latest (提问时) → copy(latest) → 中心裁剪到 640×512 → 缩放 → 压缩 JPEG(质量 90) → cacheDir/vlm_frame_*.jpg
裁剪成模型分辨率的理由:模型视觉输入是固定尺寸,预处理阶段迟早要 resize;在拍照时直接按目标比例中心裁剪,既省后续处理也避免画面被拉伸变形。
7. JNI 桥接层:Kotlin 与 C++ 的边境口岸 JNI(Java Native Interface)是 JVM 调用本地代码的标准通道。对本项目来说,这里是”上层应用逻辑”和”底层推理引擎”的分界 :向上提供干净的 Kotlin 方法,向下调用 C++。
7.1 Kotlin 侧:external 声明(VlmNative.kt) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 object VlmNative { @Volatile var available: Boolean = false private set init { available = runCatching { System.loadLibrary("hsvlm_rknn3" ) }.isSuccess } external fun nativeConfigureDevice (preferredDeviceId: String ? = null ) : String? external fun nativeQueryDeviceMemory () : LongArray? external fun nativeVlmInit (modelDir: String , family: Int , maxContextLen: Int = 6144 ) : Long external fun nativeVlmGenerate (handle: Long , imagePaths: Array <String >, prompt: String ) : Int external fun nativeVlmPollTokens (handle: Long ) : String external fun nativeVlmCancel (handle: Long ) : Boolean external fun nativeVlmIsGenerating (handle: Long ) : Boolean external fun nativeVlmLastRunCancelled (handle: Long ) : Boolean external fun nativeVlmGetLastMetrics (handle: Long , out : LongArray ) : Boolean external fun nativeVlmCountTokens (handle: Long , text: String ) : Int external fun nativeVlmDestroy (handle: Long ) }
要点:
object 是 Kotlin 的单例(相当于 Java 的 static 成员集合 + 私有构造)。
external fun 声明”实现不在这,在 native 库”。对照 Java:public native long xxx();。
System.loadLibrary("hsvlm_rknn3") :加载 libhsvlm_rknn3.so(自动补 lib 前缀和 .so 后缀)。这个库由项目的 CMake 编译产出。
runCatching 包裹加载 :库加载失败(比如架构不匹配)不让 App 直接崩,而是把 available 标记为 false,上层探测后优雅报错。这是第一道容错防线 。
7.2 C++ 侧:函数命名规则 JNI 函数名是有严格规则的,格式为:
1 2 3 4 5 Java_<包名点换成下划线>_<类名>_<方法名> Kotlin: com.horus.hsvlm.engine.VlmNative.nativeVlmInit ↓ 转换 C++ 函数名: Java_com_horus_hsvlm_engine_VlmNative_nativeVlmInit
所以 vlm_jni.cpp 里的实现就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 JNIEXPORT jlong JNICALL Java_com_horus_hsvlm_engine_VlmNative_nativeVlmInit ( JNIEnv* env, jobject, jstring jModelDir, jint jFamily, jint jMaxContextLen) { const char * model_dir = env->GetStringUTFChars (jModelDir, nullptr ); void * state = nullptr ; if (jFamily == VLM_FAMILY_QWEN35_VL) { state = qwen35_vlm_create (model_dir, static_cast <int32_t >(jMaxContextLen)); } else { state = qwen3_vlm_create (model_dir, static_cast <int32_t >(jMaxContextLen)); } env->ReleaseStringUTFChars (jModelDir, model_dir); if (!state) return 0 ; VlmHandle* handle = new VlmHandle (); handle->family = jFamily == VLM_FAMILY_QWEN35_VL ? VLM_FAMILY_QWEN35_VL : VLM_FAMILY_QWEN3_VL; handle->state = state; return reinterpret_cast <jlong>(handle); }
7.3 类型映射表(Kotlin ↔ JNI ↔ C++)
Kotlin/Java 类型
JNI 类型
C/C++ 侧
备注
String
jstring
const char*(经 GetStringUTFChars)
用完必须 ReleaseStringUTFChars ,否则内存泄漏
Int
jint
int32_t
直接值传递
Long
jlong
int64_t / 指针
本项目所有句柄都是 jlong 装的指针
Boolean
jboolean
0/1
返回时用 JNI_TRUE / JNI_FALSE
Array<String>
jobjectArray
const char**
逐元素 GetObjectArrayElement + GetStringUTFChars,逐个释放
LongArray
jlongArray
int64_t*
用 SetLongArrayRegion 批量回写
null 返回
nullptr
无
Kotlin 侧声明为可空类型 String?
7.4 句柄模式:双模型分发(本项目特色) C++ 侧用一个小结构体作为所有句柄的载体:
1 2 3 4 5 6 7 8 9 10 11 12 13 enum VlmFamily { VLM_FAMILY_QWEN3_VL = 0 , VLM_FAMILY_QWEN35_VL = 1 , };struct VlmHandle { int family = VLM_FAMILY_QWEN3_VL; void * state = nullptr ; };inline VlmHandle* as_handle (jlong raw) { return reinterpret_cast <VlmHandle*>(raw); }
于是一套 JNI 函数服务两个模型 :每个入口先取句柄,再按 family 分派:
1 2 3 4 const int n = handle->family == VLM_FAMILY_QWEN35_VL ? qwen35_vlm_poll_tokens (reinterpret_cast <Qwen35VlmState*>(handle->state), buffer, sizeof (buffer)) : qwen3_vlm_poll_tokens (reinterpret_cast <Qwen3VlmState*>(handle->state), buffer, sizeof (buffer));
这是典型的多态分发 :换成 Java 的写法是 iface = family == A ? new ImplA() : new ImplB(); iface.poll();,C++ 里没有共同基类,就用枚举 + 分支模拟。Kotlin 侧只要给 nativeVlmInit 传不同的 family 数字即可选不同模型。
7.5 JNI 层的两条铁律(写错了会闪退)
JNIEnv 不能跨线程用 。JNIEnv* 只在”函数被调用的那个线程”里有效。vendor 的回调线程不是 JVM 线程,想在回调线程里调 Kotlin 代码必须 AttachCurrentThread,但本项目干脆不做 :native 只把 token 存进 C++ 队列,Kotlin 侧主动轮询取走。这就是”轮询架构”的根源。
局部引用要释放 。GetStringUTFChars 拿到的 char*、GetObjectArrayElement 拿到的 jstring,不释放会耗尽 JNI 引用表。看 nativeVlmGenerate 里成对的获取/释放和异常路径的兜底释放(extract_ok 标志 + 统一释放循环),写得很严谨。
8. Native 推理层:C++ 桥与 RKNN3 SDK 这是模型真正跑起来的地方。文件组成:
1 2 3 4 5 6 7 8 app/src/main/cpp/ ├── CMakeLists.txt # 构建脚本:把下面所有源码编成一个 libhsvlm_rknn3.so ├── vlm_jni.cpp # JNI 入口(第 7 章) ├── qwen3_vlm_bridge.cc/.h # Qwen3-VL 推理桥(约 487 行) └── qwen35_vlm_bridge.cc/.h # Qwen3.5-VL 推理桥(同构,接口对齐) ↑ 上面这两个桥直接复用(#include + 编译)RKNN-SDK_1.1.0 的示例源码: RKNN-SDK_1.1.0/rknn/rknn3-model-zoo/examples/Qwen3_VL/cpp/... RKNN-SDK_1.1.0/rknn/rknn3-model-zoo/examples/Qwen3_5_VL/cpp/...
8.1 CMakeLists.txt:一个 .so 是怎么编出来的 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 cmake_minimum_required (VERSION 3.22 .1 )project (hsvlm_rknn3)set (CMAKE_CXX_STANDARD 11 )set (QWEN3_VL "${RKNN_MZ_DIR}/examples/Qwen3_VL/cpp" )set (QWEN35_VL "${RKNN_MZ_DIR}/examples/Qwen3_5_VL/cpp" )set_source_files_properties (${QWEN35_VL} /qwen3_5_vl.cc PROPERTIES COMPILE_DEFINITIONS "init_internal_share=qwen35_init_internal_share;..." )add_subdirectory (${RKNN_MZ_DIR} /3 rdparty ${CMAKE_BINARY_DIR} /3 rdparty.out)add_subdirectory (${RKNN_MZ_DIR} /utils ${CMAKE_BINARY_DIR} /utils.out)add_library (hsvlm_rknn3 SHARED vlm_jni.cpp qwen3_vlm_bridge.cc qwen35_vlm_bridge.cc ${QWEN3_VL} /qwen3_vl.cc ${QWEN3_VL} /llm/rknn_qwen3_vl_llm.cc ${QWEN3_VL} /vision/rknn_qwen3_vl_vision.cc ${QWEN35_VL} /...(同理) )target_include_directories (hsvlm_rknn3 PRIVATE ... ${LIBRKNNAPI_INCLUDES} ...)target_link_libraries (hsvlm_rknn3 imageutils fileutils timeutils ${LIBRKNNAPI} ${LIBTOKENIZER} ${LIBTOKENIZER_CPP} ${LIBTOKENIZER_C} dl log android )
理解这套东西对后端开发者的关键点 :
CMake 之于 C++ ≈ Maven 之于 Java:声明”哪些源文件编成一个目标(库/可执行)、依赖哪些库、头文件在哪”。
add_library(... SHARED ...) = 产出动态库(.so)。对照 Maven 就是设置 <packaging>jar</packaging> 并 mvn package。
${LIBRKNNAPI} 这种变量来自 SDK 的 CMake 文件,最终指向 librknn3_api.so(305KB 的薄分发层 )。它被显式链接 → AGP 自动打进 APK。而它运行时还会 dlopen 一个 9.6MB 的 librknn3_api_rkcp.so(协处理器后端)。这个没人链接,AGP 看不到,必须手动放 jniLibs/ 。这就是 README 踩坑 #3 的完整原理。
dl log android 是 Android 系统的自带库(动态加载器、日志、Android 运行时)。
8.2 桥的四大职责(以 qwen3_vlm_bridge.cc 为例) 职责一:补上 SDK 缺失的全局符号
1 2 3 4 5 const char * system_prompt = "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" ;const char * prompt_prefix = "<|im_start|>user\n" ;const char * prompt_postfix = "<|im_end|>\n<|im_start|>assistant\n" ;
对照后端:像引用了一个需要宿主容器注入的 Bean,官方示例的主程序里定义了,换个宿主就得自己补。
职责二:流式 token 队列(跨线程的核心数据结构)
1 2 3 4 5 6 7 8 9 10 struct Qwen3VlmState { ... std::atomic<bool > generating{false }; std::atomic<bool > cancel_requested{false }; std::atomic<bool > stop_observed{false }; std::atomic<bool > last_run_cancelled{false }; std::mutex token_mutex; std::deque<std::string> pending_pieces; };
生产/消费两端:
1 2 3 4 5 生产端(vendor 回调线程,不能碰 JNI): resultCallback() → 拿到新 token → 加锁 → pending_pieces.push_back(token) 消费端(Kotlin 轮询,每 80ms 一次): qwen3_vlm_poll_tokens() → 加锁 → 把队列整体倒出(拷贝清空)→ 返回字符串
std::atomic ≈ Java 的 AtomicBoolean:无锁并发读写布尔标志;std::mutex + std::deque ≈ Java 的 synchronized + ArrayDeque。所有并发工具的概念和 Java 一一对应,只是语法不同。
职责三:可中断推理协议
1 2 3 4 5 6 7 int qwen3_vlm_cancel (Qwen3VlmState* state) { }
中断语义:返回 1 = 被中断(部分输出仍可取),返回 0 = 正常完成,负数 = 出错 。三条信息全部通过 C++ 返回值 + 少量 is_xx() 查询函数回传,没有任何”反向回调”。
职责四:指标采集
1 2 3 4 5 6 7 struct Qwen3VlmMetrics { int64_t vision_latency_us; int64_t llm_latency_us; int64_t ttft_us; int32_t prefill_tokens; int32_t decode_tokens; };
这些指标在 vlm_jni.cpp 里被打包成 5 元 LongArray 回传给 Kotlin(nativeVlmGetLastMetrics),最终渲染成聊天气泡下方的性能统计块。
8.3 为什么不从 C++ 反向回调 Kotlin?(重要设计决策) JNI 其实支持 C++ 调用 Kotlin(需要持有 JavaVM* + 回调 methodID,在非 JVM 线程还得 AttachCurrentThread)。本项目刻意不用 ,改为”队列 + 轮询”:
方案
优点
缺点
本项目适配度
C++ 回调 Kotlin
推送实时(0 延迟)
vendor 回调线程未 attach JVM,需手动管理 attach/detach;回调时机在 vendor 线程,容易踩线程安全坑
低
队列 + 轮询(本项目)
简单可靠;轮询方完全掌控线程模型;与”可中断”设计天然契合
最坏 80ms 显示延迟(肉眼不可感)
高
这就像”消息队列”vs”同步 RPC 回调”的取舍。选队列是因为生产端(vendor 线程)属于不受控的第三方:在不受控的线程上做危险操作是大忌 。
9. 数据流动全链路:一次提问的旅行 前面各章把每层拆开看过了,现在把它们串起来。
9.1 一次完整提问(含画面 + 中断)的时序 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 【用户】在输入框打字,按回车 │ ▼ ① UI 事件 【VlmScreen / InputBar】KeyboardActions(onSend = { vm.send(state.input) }) │ ▼ ② ViewModel 收编 【VlmViewModel.send()】 ├─ 校验 phase == READY ├─ 立即 _state.update { phase = GENERATING } ──────→ UI 按钮变红"停止"(状态回流,秒级响应) └─ viewModelScope.launch(Dispatchers.IO) { ... } ────→ 进入后台线程 │ ▼ ③ 拍照(可选) 【CameraController.captureJpeg()】最新帧 → 中心裁剪 640×512 → JPEG → cacheDir │ ▼ ④ 上下文预算 【VlmEngine.countTokens(prompt)】→ JNI → C++ Tokenizer required = promptTokens + N帧×320 + 256(模板余量) required > 当前 maxContextLen ? → ensureContextFor() → 阶梯重载(卸载→loadModel(更大值)) │ ▼ ⑤ 插入聊天气泡 + 启动轮询协程 _state.update { messages += 用户气泡 + 空助手气泡 } poller = launch { while(active) { pollTokens → appendToMessage; delay(80ms) } } │ ▼ ⑥ 阻塞式推理(一整个方法调用跨过四层) 【VlmEngine.generate()】 → 【VlmNative.nativeVlmGenerate()】──JNI 边界──→ 【vlm_jni.cpp】取 VlmHandle,按 family 分发 → 【qwen3_vlm_bridge.cc】qwen3_vlm_generate_multi() ├─ read_image 读 JPEG → vision 模型编码(约几百 ms,不可中断) ├─ LLM prefill(prompt + 图像嵌入塞进 KV cache) └─ 解码循环:每个 token → vendor 回调 → 加锁 push 进 pending_pieces 队列 │ ▼ 通过 librknn3_api → rkcp → transfer_proxy → PCIe 【RK1828 协处理器】真正算 token 的地方 │ ▼ ⑦ 轮询协程(与 ⑥ 并行,每 80ms 一圈) 【VlmViewModel poller】engine.pollTokens() → JNI → qwen3_vlm_poll_tokens() → 加锁倒出队列 → 返回新 token 字符串 → appendToMessage(assistantId, delta) → _state.update { messages 更新 } → 【Compose 自动重组】用户看到文字一个个蹦出来
中断分支(用户点”停止”):
1 2 3 4 5 6 7 8 9 10 11 【用户】点红色"停止"按钮(主线程) → 【VlmViewModel.stopGenerating()】→ 【VlmEngine.cancel()】 → 【VlmNative.nativeVlmCancel()】──JNI──→ 【qwen3_vlm_cancel()】 → rknn3_session_stop(LLM session) → 【vendor worker 线程】收到 RKLLM_RUN_STOP → ⑥ 中阻塞的 qwen3_vlm_generate_multi() 立即返回 1 → 桥调用 rknn3_session_clear_kvcache() 清 KV cache → 【VlmEngine.generate()】返回 GenerateResult(CANCELLED) → 【VlmViewModel】poller.cancel() → finishMessage(cancelled = true) → 气泡标记"已中断",部分输出保留 → 下一轮提问从干净的 KV cache 重新开始
9.2 数据形态在各层的变换(同一次提问)
阶段
数据形态
所在位置
用户输入
String(Kotlin)
UI 层
状态提交
String → 参数进入 send()
ViewModel
拍照
ImageProxy(YUV) → Bitmap → 裁剪缩放 → JPEG 文件
CameraController
图像传参
List<String>(文件路径,不传图像数据本身!)
Engine → JNI
JNI 转换
jobjectArray(jstring[]) → const char*[]
vlm_jni.cpp
解码读图
char* 路径 → read_image() → image_buffer_t(内存像素缓冲)
C++ 桥
视觉编码
像素缓冲 → vision 模型 → 图像嵌入张量(float16)
RKNN3
LLM 输入
文本 token + 图像嵌入拼接 → rknn3_llm_multimodal_tensor
RKNN3
流式输出
每个 token:char* → std::string → 队列
C++ 桥
轮询回传
队列倒出 → jstring(一批 token 拼接后的字符串)
JNI
状态更新
String delta → ChatMessage.text += delta
ViewModel
渲染
ChatMessage → 气泡文本
Compose
一个关键设计 :图像以文件路径 而非字节数据跨层传输。各层只传”去哪找”,需要的层自己读。像服务间传 S3 路径而不是上传文件流。
9.3 数据流的三个”不寻常”之处
UI 永不主动拉数据 。所有更新都是 ViewModel 推状态 → UI 被动重组。UI 代码里没有一个”刷新”方法。
native 结果不推送、靠拉取 。80ms 轮询是对第三方线程模型的妥协(第 8.3 节)。
同一个状态对象驱动一切 。按钮可用性、文字颜色、气泡状态,全是 VlmUiState 的纯函数推导,没有第二条数据通道。
10. 数据存储:模型放哪、缓存放哪、配置放哪 Android 给每个 App 分配了一块沙箱目录 (其他 App 和未授权用户都看不到),这是天然的”私有数据空间”。对照后端:相当于分到一个专属的 /data/<app> 目录,还有几种不同生命周期的子目录。
10.1 Android 的几种存储位置(本项目的选择)
位置
API
生命周期
本项目用途
类比
内部私有目录
context.filesDir
卸载才清除
模型文件 (6 个文件 ×2 模型,每个 ~3.5GB)、灰色底图
服务器本地磁盘 /opt/app/data
内部缓存目录
context.cacheDir
空间不足时系统可清理
连拍帧 vlm_frame_*.jpg
临时目录 /tmp
外部专属目录
context.getExternalFilesDir()
卸载才清除
本地化的候选源目录 之一
挂载的附加盘
打包资源
assets/、res/
随 APK
haystack.txt 语料、字符串、图标
classpath 静态资源
共享存储
/sdcard/hsvoice/models/
用户管理
模型分发入口 (开发者 push 到这里,App 拷走)
共享的上传目录
10.2 模型文件:特别的”数据” 模型不是配置,是几 GB 的二进制权重。它的存储流转值得单独说清:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 【开发者/产线】adb push → /sdcard/hsvoice/models/Qwen3-VL-4B_512x640/ (6 个文件:.rknn/.weight/.embed.bin/.tokenizer.gguf/...) │ 首次点"加载"时 ▼ 【App】ModelLocator.ensureLocal() 拷贝到 filesDir/models/Qwen3-VL-4B_512x640/ │ (为什么必须拷?/sdcard 是 FUSE 模拟文件系统,DMA 无法 mmap) ▼ 【加载】nativeVlmInit() 让 RKNN3 从内部目录流式加载权重到 RK1828 │ ▼ 【驻留】权重常驻协处理器内存(约 2.7GB),句柄在 VlmEngine.handle │ 点"卸载" ▼ 【释放】nativeVlmDestroy() → 协处理器内存回收,内部目录文件保留(下次秒加载)
三个持久化策略要点:
只拷一次 :ensureLocal 检查内部目录已齐全就直接用,不重复拷贝。
手动清理 :模型升级时旧文件不会自动覆盖(copyChecked 对”大小一致”的文件跳过)。README 明确写了处理方式:adb shell pm clear com.horus.hsvlm 或手动 rm -rf 内部模型目录。
没有数据库 。聊天记录、设置(开关状态)全在内存 StateFlow 里,杀进程即丢。这是刻意的:车机场景每次上车重新对话,无需持久化历史。
10.3 配置文件:res/ 与 Manifest 本项目的”配置”极少,因为大多数行为由代码中的常量定义(如 DEFAULT_CONTEXT_LEN = 6144、POLL_INTERVAL_MS = 80):
配置
位置
内容
应用名
res/values/strings.xml
“视觉大模型对话”
主题(XML 层)
res/values/themes.xml
Theme.HSVlm
权限/组件声明
AndroidManifest.xml
相机 + 存储权限、MainActivity
构建参数
app/build.gradle.kts
SDK 版本、依赖、NDK 配置
运行时常量
Kotlin companion object
轮询间隔、日志上限、上下文默认值
对照后端:相当于把 application.yml 拆成了”清单(Manifest)+ 资源(res)+ 构建脚本(gradle)+ 代码常量”四块。Android 圈不太流行外部配置文件(普通用户设备上改起来太麻烦),能写死在代码里的都写死。
10.4 跨进程场景的补充(了解即可) 本项目是单进程 应用。但同一仓库的兄弟工程(AgentService、VoiceService)演示了 Android 的跨进程方案:AIDL ,一种接口定义语言,自动生成跨进程调用的代理(类似 gRPC 的 .proto + 生成代码)。当一个 App 想调用另一个 App 的能力(如 Agent 调语音识别服务)时使用。相当于微服务之间的 RPC 调用,只是都在一台设备上。
11. 异常处理:从 native 返回码到用户看到的红色提示 Android 的异常处理和 Java 后端语法完全一样 (都是 JVM 那套 try/catch/throw),区别在策略 :移动端格外强调”任何错误都不能让 App 崩溃”,因为用户面前没有运维,崩了就是事故。
11.1 本项目的四道防线 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 第 1 道:编译期/加载期防御 └─ VlmNative 初始化时 runCatching 包住 System.loadLibrary → 库加载失败 → available = false(App 还能跑,只是不能用模型功能) 第 2 道:调用前检查(Fail Fast with Clear Message) └─ check(VlmNative.available) { "native 库未加载 (libhsvlm_rknn3.so)" } check(handle != 0L) { "模型未就绪" } check(prompt.isNotBlank()) { "问题为空" } → 立刻抛 IllegalStateException,消息本身就是给开发者/用户的诊断信息 第 3 道:调用时兜底(不让异常穿透线程边界) └─ runCatching { VlmNative.nativeVlmInit(...) }.getOrDefault(0L) // 异常 → 0(失败值) runCatching { camera.captureJpeg(...) }.onFailure { log("WARN", ...) }.getOrNull() → 每条日志都带上下文("第 2/3 帧捕获失败: xxx") 第 4 道:收尾兜底(folding 成状态) └─ runCatching { engine.generate(...) }.fold( onSuccess = { result -> 按 COMPLETED/CANCELLED/ERROR 三态收尾 }, onFailure = { error -> finishMessage(errorText = "推理异常:${error.message}") } ) → 无论如何 phase 都回到 READY,绝不让 UI 卡死在"推理中"
11.2 异常处理的完整链路(举例:native 初始化失败) 1 2 3 4 5 6 7 8 9 【C++】qwen3_vlm_create() 返回 nullptr(权重文件损坏等) → vlm_jni.cpp: if (!state) return 0; → 【Kotlin】nativeVlmInit 返回 0L → 【VlmEngine.loadModel】if (newHandle == 0L) { onLog("xx 初始化失败"); return false } → 【VlmViewModel.loadModelInternal】 ok = false _state.update { phase = ERROR, error = "xx 加载失败,详见日志" } → 【UI】红色横幅 "⚠ xx 加载失败,详见日志" + 状态点变红 → 【UI】4 秒后横幅自动消失(LaunchedEffect),用户可点"加载"重试
注意每一层的分工:C++ 只负责如实报告失败(返回码/nullptr),Kotlin Engine 把失败变成 false + 日志,ViewModel 把失败变成状态,UI 把状态变成颜色和文字 。错误一路向上”翻译”,每层都补上自己能提供的上下文。
11.3 错误值约定(不用异常穿越 JNI 边界) 跨 JNI 传异常很麻烦(env->ThrowNew 后 C++ 还得处理 pending exception)。本项目的约定是用返回值表达错误 :
native 方法
失败表达
Kotlin 侧处理
nativeConfigureDevice
返回 null
check(!deviceId.isNullOrBlank()) → 抛异常
nativeQueryDeviceMemory
返回 null
?: return null,UI 显示 “—“
nativeVlmInit
返回 0L
if (newHandle == 0L) return false
nativeVlmGenerate
返回负数
when { ret == 0 -> ...; ret == 1 -> ...; else -> ERROR }
nativeVlmPollTokens
返回 ""
空字符串视为”无新数据”,跳过
nativeVlmGetLastMetrics
返回 false
用默认空指标 VlmMetrics()
对照后端:这就像”Feign 客户端把 HTTP 异常转成返回值”,让 JNI 边界保持简单。所有 check 抛出、error 抛出的异常,都由 ViewModel 最外层的 runCatching/fold 接住 。不存在”没人接的野异常”。
11.4 日志:三套并行的日志系统 车机项目调试环境不友好(现场没有 IDE),所以本项目日志做到了”三重可见”:
系统
实现
给谁看
查看方式
logcat
android.util.Log.i(TAG, ...)(Kotlin)+ __android_log_print(C++)
开发者
adb logcat -s VlmEngine hsvlm_jni RKNNAPI
应用内日志面板
log(level, msg) → StateFlow → UI 滚动列表
现场测试人员
App 右栏”日志”浮层
聊天气泡标记
ChatMessage.modelName / metricsText / cancelled
最终用户
气泡上的徽标与统计块
C++ 侧的日志宏:
1 2 3 #define LOG_TAG "hsvlm_jni" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
设备上实际抓到的日志长这样(真实样例):
1 2 3 4 5 I hsvlm_jni: device[0] id=0003:31:00.0 type=PCIE sys_free=0 I hsvlm_jni: device[1] id=18086D6F... type=USB_DEVICE sys_free=0 I hsvlm_jni: pin VL (0xff) to device=0003:31:00.0 (devices=2) W RKNNAPI : rknn3_init, find 2 devices, but user not specify init_extend, so use the first device W RKNNAPI : No exact kvcache group id found for max_context_len=1024
11.5 兜不住的错误:进程级崩溃(重要认知) 有些失败任何 try/catch 都接不住 ,因为它是 C++ 层面把整个进程带崩的。本项目真实遇到过(README 有完整记录):
1 2 3 4 场景:模型文件混批次(新 .rknn 配旧 .weight) → rkcp 后端读权重越界(Weight offset out of range) → 它内部的错误处理有 bug:std::thread 未 join 即析构 → std::terminate → 【SIGABRT】整个 Android 进程被杀,Kotlin 侧没有任何机会写日志
教训与对策 :
进程级崩溃的唯一证据在 logcat / tombstones (adb logcat | grep -i "signal\|abort"),App 内日志面板什么都不会有。
对这类问题,防御手段是使用前的校验 (确保 6 个模型文件来自同一批次),而不是 try/catch。
.so 库内部的 bug(vendor 提供)无法修改,只能靠”规范操作流程 + 现场排查日志”规避。
对照后端:类似 JVM 里调 JNI 库 segfault:JVM 也拦不住,进程直接死,只能看 core dump。这是所有”托管语言调本地代码”体系的共同边界。
11.6 业务级容错设计(能降级就降级) 不是所有错误都要”终止流程”,本项目有多处优雅降级:
场景
降级策略
相机帧捕获失败
本轮退化为纯文本(log WARN,继续推理)
连拍 3 帧只成功 2 帧
用 2 帧继续(有几帧用几帧)
用户拒绝相机权限
纯文本对话完全不受影响(只有”画面附加”不可用)
上下文超出上限
中止本轮并明确提示”请减少采集张数或缩短提问”,状态回到 READY
设备内存查询失败
统计块少显示一行,不影响主流程
原则:核心链路(模型加载 + 推理)失败才进 ERROR 状态;辅助功能(相机、统计)失败只降级 。
12. 线程模型:后端开发者最容易踩的坑 后端对线程的态度是”多开几个池撑并发”;Android 对线程的态度是”主线程神圣不可侵犯,其余线程各司其职”。本节把本项目的全部线程讲清楚。
12.1 涉及的角色们
线程/调度器
谁在用
干什么
禁忌
主线程(UI 线程)
Activity、Compose、所有 vm.xxx() 直接调用
绘制界面、响应点击/输入
严禁 任何耗时操作(>5s 杀 App)
Dispatchers.IO(协程池)
send()、loadModel()、ensureContextFor()
文件拷贝、推理阻塞调用、token 轮询
可以阻塞,不该做 UI 操作
CameraX analysisExecutor(单线程)
CameraController.processFrame
帧转 Bitmap、存 latest
不碰 UI 状态
vendor worker 线程(C++ 内部)
RKNN3 SDK
跑模型、回调 token
不能碰 JNI (未 attach JVM)
native 调用线程
就是发起 nativeVlmGenerate 的 IO 线程
阻塞等待推理
不能取消它
12.2 两条最重要的线程规则(本项目反复强调) 规则一:主线程只做”变更状态”和”读取状态”两件事。
1 2 3 4 5 6 fun send (rawPrompt: String ) { ...校验... _state.update { ... } sendJob = viewModelScope.launch(Dispatchers.IO) { ... } }
链式调用直到 UI 刷新怎么保证只在主线程?答案是 Compose 的 collectAsStateWithLifecycle,它自动把状态更新调度回主线程执行重组,开发者不用手动 runOnUiThread(老 Android 写法的痛点)。
规则二:阻塞中的协程不许取消,只能用”软中断”让它自己退出。
1 2 3 4 5 6 sendJob?.cancel()fun stopGenerating () { engine.cancel() }
像后端的”优雅停机”(graceful shutdown):发信号让服务自己退出,而不是 kill -9。
12.3 跨线程的数据共享对策表
共享数据
位置
保护手段
类比 Java
handle(模型句柄)
VlmEngine
@Volatile
volatile long
latest(相机最新帧)
CameraController
synchronized(frameLock)
synchronized + 锁对象
bindGeneration(代数号)
CameraController
AtomicInteger
AtomicInteger
pending_pieces(token 队列)
C++ 桥
std::mutex
synchronized
generating 等布尔标志
C++ 桥
std::atomic<bool>
AtomicBoolean
_state(UI 状态)
ViewModel
MutableStateFlow.update(CAS 原子操作)
AtomicReference.updateAndGet
sendJob / needleTestJob
ViewModel
仅主线程访问(约定)
单线程访问约定
这张表就是并发编程的”工具箱选型”现场 :能用无锁原子变量就用原子变量;复合结构用锁;状态广播用 Flow。概念和后端一模一样,只是 Kotlin/C++ 的语法外壳不同。
12.4 一个真实竞态场景的解剖 相机绑定与关闭的竞态(bindGeneration 要解决的问题):
1 2 3 4 5 时刻 T1:用户点"开启相机" → bind() 启动异步初始化,generation = 1 时刻 T2:用户等不及,点"关闭" → unbind(),generation = 2,并立即解绑 时刻 T3:T1 的异步回调终于回来了…… if (generation != bindGeneration.get()) return@addListener // 1 != 2 → 直接返回,不会把已经关掉的相机又打开
对照后端:这就是”用版本号/epoch 丢弃过期异步回调”的标准套路(类似分布式锁的 fencing token)。异步世界里,回调永远可能比状态变化慢一步 。这是所有端上开发者的必修课。
13. 从零开始搭建一个安卓项目 理论讲完,这一章是动手路线图:假设一个没写过 Android 的 Java 后端,怎么从装的第一个软件走到一个能跑起来的 App 。
13.1 第一步:装工具(30 分钟)
工具
作用
备注
Android Studio
官方 IDE(基于 IntelliJ IDEA,用惯的 IDEA 快捷键都通用)
从官网下载,安装时勾选 Standard 安装
JDK 17
Android Studio 自带 JBR(JetBrains Runtime),一般无需另装
本项目 sourceCompatibility = 17
Android SDK
编译用:目标平台 API(如 API 34)
首次启动 AS 时按向导下载
NDK + CMake
只有需要写 C/C++ 时才装
本项目需要:NDK r26 + CMake 3.22.1
adb
命令行部署/调试工具(在 SDK 的 platform-tools 里)
相当于后端的 ssh + scp + tail 三合一
一台设备/模拟器
运行目标
普通 App 用模拟器即可;本项目因要 NPU 必须真实车机
对照后端:Android Studio ≈ IDEA + Maven + Postman + 一堆插件的合集;Android SDK ≈ JDK(提供标准库和编译目标)。
13.2 第二步:创建工程(5 分钟) Android Studio → New Project → 模板选 Empty Activity (带 Compose 的 “Empty Activity”,不是 “Empty Views Activity”)→ 填写:
1 2 3 4 5 6 Name: MyFirstApp Package name: com.example.myfirstapp ← 反向域名,全球唯一(发布后不可改) Save location: 任意空目录 Language: Kotlin ← 本教程全程 Kotlin Minimum SDK: API 28 (Android 9.0) ← 本项目同款 Build configuration language: Kotlin DSL ← 生成 .kts 脚本
点 Finish,一个可以立即运行的 App 就绪了。它生成的结构和 Horus-VLM 完全同构 ,只是没有多层分包:
1 2 3 4 5 6 7 8 9 10 11 12 MyFirstApp/ ├── settings.gradle.kts # 已配置好仓库(google + mavenCentral) ├── build.gradle.kts # 插件版本声明 ├── gradle.properties ├── gradle/wrapper/ # Gradle 版本锁 └── app/ ├── build.gradle.kts # SDK 版本 + 依赖(模板已填好 Compose 全套) └── src/main/ ├── AndroidManifest.xml ├── java/com/example/myfirstapp/ │ └── MainActivity.kt # 一个 Compose "Hello World" └── res/values/ # strings.xml / themes.xml
13.3 第三步:跑起来(10 分钟,第一次同步会下载几百 MB) 1 2 3 4 5 6 7 8 9 10 cd MyFirstApp .\gradlew.bat :app:assembleDebug adb devices .\gradlew.bat :app:installDebug adb shell am start -n com.example.myfirstapp/.MainActivity
对应本项目的构建命令(README 原文):
1 2 3 4 5 .\gradlew.bat :app:assembleDebug adb push ..\rk_1.1.0 \rknn3_transfer_proxy /vendor/bin/ adb shell appops set com.horus.hsvlm MANAGE_EXTERNAL_STORAGE allow adb install -r app\build\outputs\apk\debug\app-debug .apk adb shell am start -n com.horus.hsvlm/.MainActivity
13.4 第四步:模仿 Horus-VLM 长出自己的分层(路线图) 在模板工程上按以下顺序演进(每一步都能独立运行验证,不要一步到位):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Step 1 改 MainActivity:把 "Hello World" 换成随便一句文字 (体会:Compose 里 UI 就是函数调用,改文字=改参数) Step 2 建 model/UiState.kt:定义 data class MyUiState(val count: Int = 0, val input: String = "") 建 vm/MyViewModel.kt:class MyViewModel : ViewModel() { MutableStateFlow + 方法 } (体会:状态集中管理,参考本项目的 VlmUiState/VlmViewModel 骨架) Step 3 UI 订阅状态:val state by vm.state.collectAsStateWithLifecycle() 按钮点击 → vm.increment() → 状态变化 → 数字自动刷新 (体会:UI = f(state) 的完整体现,这就是第 4/5 章的全部核心) Step 4 加 IO 操作(读一个大文件/模拟耗时):viewModelScope.launch(Dispatchers.IO) {} (体会:主线程不阻塞,耗时活丢线程池) Step 5 按需申请权限(相机/存储):Manifest 声明 + rememberLauncherForActivityResult (体会:第 4.6 节) Step 6 (进阶)加 JNI:src/main/cpp/ 放 .cpp + CMakeLists.txt app/build.gradle.kts 加 externalNativeBuild.cmake Kotlin 侧 object + System.loadLibrary + external fun (体会:第 7 章;JNI 函数命名规则要背下来)
13.5 每个 Gradle 文件该改哪里(速查)
想做什么
改哪里
改应用名
res/values/strings.xml 的 app_name
改包名
app/build.gradle.kts 的 namespace + applicationId(官方重构工具更安全)
加依赖库
app/build.gradle.kts 的 dependencies {}(如 implementation("androidx.xxx:xxx:1.0.0"))
提高/降低支持的系统版本
app/build.gradle.kts 的 minSdk / targetSdk
加权限
AndroidManifest.xml 的 <uses-permission>
加新页面/组件
AndroidManifest.xml 注册 <activity>(如果用多 Activity)
加 C++ 代码
src/main/cpp/ + externalNativeBuild 配置
改内存/并行参数
gradle.properties
13.6 调试三板斧(对照后端的手段)
后端手段
Android 对应
命令/操作
tail -f app.log
logcat 实时日志
adb logcat -s VlmEngine VlmViewModel hsvlm_jni(-s 只看指定 TAG)
断点调试
同样支持,IDE 直接下断点
AS 里 Debug ▶ 运行
jstack 看线程
adb shell 进设备看进程
adb shell top -H -p <pid>
看数据库中数据
看 App 私有文件
adb shell run-as com.horus.hsvlm ls files/models/(debug 包可 run-as)
curl 测接口
模拟 UI 操作
无通用方案,靠 App 内日志/测试按钮(本项目做了大量测试面板就是为此)
部署脚本
adb 脚本
README 里的完整部署命令链
logcat 是最重要的工具 。本项目 native 层的报错(如 RKNNAPI : input_tokens > max_position_embeddings)只会出现在 logcat,App 内不可见,这也是为什么第 11 章强调”三套日志系统并行”。
13.7 常见初学坑(对照本项目踩过的坑)
坑
症状
解法
在主线程里做耗时操作
ANR 弹窗 / 界面卡死
一切耗时进 Dispatchers.IO
忘了运行时权限
相机/文件操作直接抛 SecurityException
Manifest 声明 + 运行时请求(第 4.6 节)
.so 没打进 APK
运行时 UnsatisfiedLinkError 或功能静默失效
放进 src/main/jniLibs/arm64-v8a/(注意是 src/main 下,不是模块根目录)
异步回调晚到引发的状态错乱
界面出现”幽灵”状态
版本号/代数号模式(第 12.4 节)
忘了释放 native 资源
越用越卡 / 热重启后异常
ViewModel onCleared 里 close/unload(第 5.5 节)
Bitmap 内存暴涨
OOM
及时 recycle(),控制尺寸(第 6.1/6.3 节)
库版本不匹配
运行时各种奇怪崩溃
三件套版本锁(README:APK 库 + 设备守护进程 + 固件必须同版本)
14. 附录:关键文件速查 & 术语表 14.1 关键文件速查表(本项目)
文件
层
一句话职责
settings.gradle.kts
构建
工程包含哪些模块、依赖从哪下
build.gradle.kts(根)
构建
插件与版本声明
app/build.gradle.kts
构建
SDK 版本、依赖、NDK/CMake、打包规则
AndroidManifest.xml
配置
权限、入口 Activity、主题声明
MainActivity.kt
UI
入口:设沉浸式窗口 + setContent 挂载 Compose
ui/VlmScreen.kt
UI
全部界面 + 事件转发到 ViewModel
ui/Theme.kt
UI
座舱深色主题色板
vm/VlmViewModel.kt
编排
状态权威 + 全流程编排(send/load/cancel/needle 测试)
model/UiModels.kt
数据
VlmUiState、ChatMessage、VlmPhase 等值对象
engine/VlmEngine.kt
引擎
模型生命周期(load/unload/switch)+ 可取消生成
engine/ModelLocator.kt
引擎
VlmModel 枚举(模型文件清单)+ 定位/本地化拷贝
engine/CameraController.kt
引擎
CameraX 预览 + 最新帧捕获导出 JPEG
engine/VlmNative.kt
JNI
external fun 声明 + 库加载容错
cpp/vlm_jni.cpp
JNI
JNI 实现:类型转换 + family 分发 + 设备选择
cpp/qwen3_vlm_bridge.cc
Native
Qwen3-VL 推理桥:队列/中断/指标
cpp/qwen35_vlm_bridge.cc
Native
Qwen3.5-VL 推理桥(同构)
cpp/CMakeLists.txt
Native
构建 libhsvlm_rknn3.so(含 SDK 源码共编 + 符号重命名)
jniLibs/arm64-v8a/librknn3_api_rkcp.so
Native
vendor 协处理器后端(手动拷贝,必须同版本)
assets/haystack.txt
资源
长文本测试语料
14.2 术语表(后端视角)
术语
一句话解释
APK
Android 应用的安装包,≈ 可执行 fat jar
AGP
Android Gradle Plugin,Gradle 的安卓定制插件
Activity
一个”页面”的宿主容器,由系统调度生命周期
Compose
声明式 UI 框架,UI = f(State)
Composable
标了 @Composable 的 UI 函数
ViewModel
页面状态持有者,页面重建时由系统保留
StateFlow
带当前值的热数据流,≈ 进程内的 pub/sub
协程(Coroutine)
Kotlin 轻量并发原语,用起来像同步代码的异步
Dispatchers.IO
协程的”IO 线程池”调度器
JNI
Java Native Interface,JVM 调本地代码的标准通道
NDK
Native Development Kit,把 C/C++ 编成 Android 能用的 .so 的工具链
CMake
C/C++ 的构建脚本系统,≈ Maven 之于 Java
ABI / arm64-v8a
CPU 指令集架构,本项目只打包 64 位 ARM
logcat
Android 的系统日志总线,adb logcat 查看
ANR
Application Not Responding:主线程卡住超时,系统弹窗
沙箱
每个 App 独立的文件系统与权限空间
filesDir / cacheDir
私有持久目录 / 可被系统清理的缓存目录
AIDL
Android 跨进程接口定义语言(本项目各工程间协作用)
Handle 模式
用不透明数字(jlong)代表 native 对象,用完须显式释放
DMA
直接内存访问:硬件绕过 CPU 搬数据;本项目要求模型在真实文件系统上
14.3 本项目依赖清单(app/build.gradle.kts 原文)
依赖
用途
androidx.core:core-ktx
Kotlin 扩展的标准库
androidx.lifecycle:lifecycle-runtime-ktx
协程与生命周期集成
androidx.lifecycle:lifecycle-runtime-compose
collectAsStateWithLifecycle
androidx.lifecycle:lifecycle-viewmodel-compose
Compose 里获取 ViewModel
androidx.activity:activity-compose
Compose 版 Activity 支持
androidx.camera:camera-*(4 个)
CameraX 相机预览/分析/生命周期绑定
androidx.compose.ui / material3 / icons-extended
UI 框架与组件库
(BOM)androidx.compose:compose-bom
Compose 全家版本统一
14.4 推荐阅读顺序(给不同目的的读者)
只想看懂这个项目 :第 0 章 → 第 2 章 → 第 9 章(数据流)→ 回看各层细节。
想学 Android 开发 :第 1 章 → 第 4/5 章(UI 与状态)→ 第 13 章(动手)→ 其余按需。
接手本项目开发 :第 3 章(工程骨架)→ 第 5~8 章(核心链路)→ 第 11/12 章(避坑)→ 通读仓库 README.md(含大量一手踩坑记录)。