面向 Java 开发的安卓实战入门指南 | Horus-VLM 安卓项目架构详解

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 内存

对比后端开发者的直觉,重要的差异点有四个:

  1. 没有服务器。这是一个纯客户端程序,所有计算都在本机(而且大部分在协处理器上)。
  2. 没有数据库。状态全在内存里(StateFlow),持久化的只有模型文件。
  3. 有 UI,而且 UI 绝不能卡。后端一个接口慢 3 秒用户最多抱怨;Android 主线程阻塞 5 秒会直接 ANR(Application Not Responding)弹窗甚至被杀。
  4. 有原生(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") // 本工程只有一个模块 :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 // AGP
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 // 用哪个版本的 Android SDK 编译
defaultConfig {
applicationId = "com.horus.hsvlm" // 安装到设备上的唯一标识(相当于应用身份证)
minSdk = 28 // 最低支持的 Android 版本(9.0)
targetSdk = 34 // 目标版本:声明"已适配到 34"
versionCode = 1 // 版本号(整数,用于升级判断)
versionName = "1.0.0" // 版本名(给用户看)
ndk { abiFilters += "arm64-v8a" } // 只打包 ARM64 架构(车机芯片)
externalNativeBuild { cmake { // 传给 CMake 的参数
cppFlags += "-std=c++11"
arguments += "-DRKNN_MZ_DIR=$rknnModelZooDir" // SDK 路径变量
} }
}
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; ... } // Java 17
buildFeatures { compose = true } // 启用 Jetpack Compose
composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
externalNativeBuild { // ★ 告诉 AGP:C++ 代码用这个 CMakeLists 构建
cmake { path = file("src/main/cpp/CMakeLists.txt"); version = "3.22.1" }
}
packaging { // 打包细节
jniLibs {
useLegacyPackaging = true // .so 不压缩,DMA 需要真实字节
keepDebugSymbols += "**/librknn3_api_rkcp.so" // 不 strip
}
}
}

dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom) // Compose 的"依赖版本统一单"
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") // collectAsStateWithLifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") // viewModel()
implementation("androidx.activity:activity-compose:1.9.0")
val cameraxVersion = "1.3.4"
implementation("androidx.camera:camera-core:$cameraxVersion") // CameraX 相机库
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("androidx.compose.ui:ui") // Compose 不写版本号
implementation("androidx.compose.material3:material3") // 由 BOM 统一决定
...
}

几个对后端开发者最有解释力的问题:

  • 依赖从哪来? 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" />
<!-- 存储权限:用于把模型从 /sdcard 拷进私有目录 -->
<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()) {
// 1. 订阅 ViewModel 的状态(只读快照)
val state by vm.state.collectAsStateWithLifecycle()

// 2. 状态驱动的界面:state 变了,下面的 UI 描述会自动重新执行
Column(...) {
HeaderBar(state, vm) // 顶栏:状态点 + 清空按钮
ModelBar(state, vm) // 模型栏:选择/加载/卸载
Row(...) {
Column(Modifier.weight(0.60f)) { // 左栏(60% 宽度)
ChatList(state, Modifier.weight(1f)) // 聊天记录
TokenTestPanel(state, vm) // 定量 Token 测试 chips
PresetQuestions(...) // 预置问题 chips
NeedleTestPanel(state, vm, ...) // 大海捞针测试入口
PerfStatsPanel(state, vm) // 性能统计开关
}
Box(Modifier.weight(0.40f)) { // 右栏(40% 宽度)
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 { // ★ 从"Activity 世界"进入"Compose 世界"
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 层的纪律(本项目严格遵守):

  1. 只读状态、只调方法。UI 里看不到任何业务逻辑,所有点击都只是 vm::xxx。
  2. 不做耗时操作。拍照、推理全在 ViewModel 的协程里。
  3. 一切都是函数。没有 findViewById,没有可变控件对象,界面是状态的纯函数。

4.4 事件如何从 UI 回到 ViewModel

Compose 里事件回传就是传 lambda(Kotlin 的函数类型),等价于后端里传回调接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
// UI 侧:把"点击事件"翻译成"调用 vm 的方法"
Button(onClick = { vm.send(state.input) }) { Text("发送") }

// 预置问题 chip
PresetQuestions(questions = vm.presetQuestions, onPick = { vm.send(it) })

// 输入框:回车直接发送
OutlinedTextField(
value = state.input,
onValueChange = vm::setInput, // :: 是 Kotlin 方法引用,等价 v -> vm.setInput(v)
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
// 座舱调色板(与 HSAE-Voice 共享):暖黑底 + 青色强调
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) } // 用户点了允许/拒绝 → 写回 ViewModel

// 某个按钮里:permissionLauncher.launch(Manifest.permission.CAMERA)

像发起一次”需要用户确认的异步审批”,结果通过回调返回,期间发起方不能卡住。

4.7 错误横幅自动消失

1
2
3
4
5
6
7
// state.error 变化时启动一个"副作用":等 4 秒后清掉错误
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, // NPU 上真正驻留的模型
val deviceId: String = "", // RKNN3 设备 ID
val cameraGranted: Boolean = false, // 相机权限
val cameraReady: Boolean = false,
val cameraStatus: String = "未授权",
val attachCameraFrame: Boolean = false, // 下一条提问是否带画面
val frameCaptureCount: Int = 1, // 连拍张数(1~6)
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(), // 应用内日志(最多 200 条)
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
// ── 前置校验(UI 已禁用按钮,这里再兜底)──
if (current.phase == VlmPhase.GENERATING) { flashError("推理进行中,请先点击停止"); return }
if (current.phase != VlmPhase.READY) { flashError("模型未就绪"); return }
val prompt = rawPrompt.trim()
if (prompt.isEmpty()) return

// ── 立刻切状态:UI 马上显示"推理中"(发送按钮变红"停止")──
_state.update { it.copy(input = "", error = null, phase = VlmPhase.GENERATING) }

// ── 启动后台协程干重活,绝不阻塞主线程 ──
sendJob = viewModelScope.launch(Dispatchers.IO) {

// ① 可选:连拍 N 帧摄像头画面(按驻留模型的分辨率中心裁剪)
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) // 500ms 间隔
}
if (captured.isEmpty()) log("WARN", "画面捕获失败,本轮退化为纯文本")
}

// ② 上下文预算:prompt tokens + 图像 tokens + 模板余量,不足则阶梯重载
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")

// ④ 启动"轮询协程":每 80ms 把 native 队列里的新 token 追加到助手气泡
val poller = launch {
while (isActive) {
engine.pollTokens().takeIf { it.isNotEmpty() }
?.let { appendToMessage(assistantId, it) }
delay(POLL_INTERVAL_MS) // 80ms
}
}

// ⑤ 阻塞式推理(当前 IO 线程会被阻塞几十秒);返回时说明推理结束
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/1000f}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() // 销毁 native 模型(会先停掉在途推理,等待 native 线程退出,最多 ~3 秒)
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) // "18:22:30", "INFO", "..."
_state.update { it.copy(logs = (it.logs + entry).takeLast(MAX_LOG_ENTRIES)) } // 只留最近 200 条
}

四级日志: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 // 0 表示"没有模型驻留"
@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) // 文件本地化(拷贝 3.5GB,首次 1~2 分钟)

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 // 6144
// 加载后再次采样 → 差值即模型驻留 NPU 的 footprint(权重 + KV cache)
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()) { "问题为空" }

// VL 模型必须有图像输入:纯文本提问时生成一张中性灰图兜底
val images = if (imagePaths.isNullOrEmpty()) {
listOf(blankImage(currentModel).absolutePath)
} else imagePaths

// ★ 阻塞调用:当前线程停在这里直到 native 返回(完成/中断/出错)
val ret = VlmNative.nativeVlmGenerate(handle, images.toTypedArray(), prompt)

onDelta(VlmNative.nativeVlmPollTokens(handle)) // 最终排水:把残余 token 收干净
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)) // 画一块 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, // "Qwen3-VL 4B"(显示名)
val dirName: String, // "Qwen3-VL-4B_512x640"(目录名)
val tag: String, // 气泡上的模型徽标
val requiredFiles: List<String>, // 6 个模型文件的清单
val familyId: Int, // 0 / 1:native 侧按这个分发到不同的推理管线
val visionWidth: Int, // 640 / 864:视觉输入分辨率(要与导出时一致)
val visionHeight: Int // 512 / 480
) {
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") // 先写 .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))
}
}

关键设计:

  1. STRATEGY_KEEP_ONLY_LATEST:相机帧来得比处理快时,丢旧帧只处理最新帧。对照后端:一个永远只保留最新消息的队列,因为”对话看的是当前画面”,旧帧没有价值。
  2. bindToLifecycle(owner, ...):把相机的生命周期绑到页面。页面退到后台时相机自动暂停,回来自动恢复,这是 CameraX 自动完成的,不用手动管理。
  3. bindGeneration 代数号:相机初始化是异步的(future.addListener 回调可能在用户已经点”关闭”之后才到)。每次 bind/unbind 都让代数号 +1,回调时核对代数号,不匹配就丢弃,防止”幽灵连接”。类似用乐观锁版本号丢弃过期回调。
  4. 帧的持有策略:分析线程把最新的 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 {
// 类加载时尝试加载 native 库;失败不崩溃,只是 available = false
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); // jstring → char*
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); // 指针 → jlong
}

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
// 两个模型家族(与 Kotlin 侧 VlmModel.familyId 对齐)
enum VlmFamily {
VLM_FAMILY_QWEN3_VL = 0,
VLM_FAMILY_QWEN35_VL = 1,
};

// 句柄 = 家族标记 + 真实状态指针
struct VlmHandle {
int family = VLM_FAMILY_QWEN3_VL;
void* state = nullptr; // 指向 Qwen3VlmState 或 Qwen35VlmState
};

inline VlmHandle* as_handle(jlong raw) { return reinterpret_cast<VlmHandle*>(raw); }

于是一套 JNI 函数服务两个模型:每个入口先取句柄,再按 family 分派:

1
2
3
4
// poll 的实现就是这样一句三元分派
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 层的两条铁律(写错了会闪退)

  1. JNIEnv 不能跨线程用。JNIEnv* 只在”函数被调用的那个线程”里有效。vendor 的回调线程不是 JVM 线程,想在回调线程里调 Kotlin 代码必须 AttachCurrentThread,但本项目干脆不做:native 只把 token 存进 C++ 队列,Kotlin 侧主动轮询取走。这就是”轮询架构”的根源。
  2. 局部引用要释放。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)

# SDK 路径(可由 Gradle 传 -DRKNN_MZ_DIR=... 覆盖)
set(QWEN3_VL "${RKNN_MZ_DIR}/examples/Qwen3_VL/cpp")
set(QWEN35_VL "${RKNN_MZ_DIR}/examples/Qwen3_5_VL/cpp")

# ★ 重名符号处理:两个示例源码都定义了同名全局函数,共编会重定义,
# 对 Qwen3.5 的编译单元做"编译期重命名"
set_source_files_properties(${QWEN35_VL}/qwen3_5_vl.cc PROPERTIES
COMPILE_DEFINITIONS "init_internal_share=qwen35_init_internal_share;...")

# 把三方库和工具库加进构建(模型 zoo 自带的)
add_subdirectory(${RKNN_MZ_DIR}/3rdparty ${CMAKE_BINARY_DIR}/3rdparty.out)
add_subdirectory(${RKNN_MZ_DIR}/utils ${CMAKE_BINARY_DIR}/utils.out)

# ★ 产出物:libhsvlm_rknn3.so(SHARED = 动态库)
add_library(hsvlm_rknn3 SHARED
vlm_jni.cpp
qwen3_vlm_bridge.cc
qwen35_vlm_bridge.cc
${QWEN3_VL}/qwen3_vl.cc # SDK 示例源码直接进来编
${QWEN3_VL}/llm/rknn_qwen3_vl_llm.cc
${QWEN3_VL}/vision/rknn_qwen3_vl_vision.cc
${QWEN35_VL}/...(同理)
)

# 头文件搜索路径(rknn3_api.h 经 ${LIBRKNNAPI_INCLUDES} 解析)
target_include_directories(hsvlm_rknn3 PRIVATE ... ${LIBRKNNAPI_INCLUDES} ...)

# 链接:RKNN3 运行时 + tokenizer 静态库 + Android 系统库
target_link_libraries(hsvlm_rknn3
imageutils fileutils timeutils
${LIBRKNNAPI} # librknn3_api.so(薄分发层,会自动打包)
${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
// SDK 的 LLM 源文件把这三个"prompt 模板"声明为 extern,期望宿主程序提供。
// 官方 main.cc 里有定义,但本工程不编译 main.cc,所以在桥里给出正式定义:
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; // ★ 待取走的 token 片段
};

生产/消费两端:

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) 置取消标记
// 2) 调 vendor 中断 API:rknn3_session_stop(state->app.llm.rknn_sess)
// 3) vendor worker 收到 RKLLM_RUN_STOP → 阻塞中的 generate 解除阻塞、返回 1
// 4) 桥接着调 rknn3_session_clear_kvcache(...) 清 KV cache → 下次干净起跑
// 注意:vision 预填充段(几百毫秒)不可中断,只有 LLM 解码循环可断
}

中断语义:返回 1 = 被中断(部分输出仍可取),返回 0 = 正常完成,负数 = 出错。三条信息全部通过 C++ 返回值 + 少量 is_xx() 查询函数回传,没有任何”反向回调”。

职责四:指标采集

1
2
3
4
5
6
7
struct Qwen3VlmMetrics {
int64_t vision_latency_us; // 视觉编码耗时
int64_t llm_latency_us; // LLM 总耗时(prefill + decode)
int64_t ttft_us; // 首 token 延迟(Time To First Token)
int32_t prefill_tokens; // prefill token 数
int32_t decode_tokens; // 解码 token 数
};

这些指标在 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 数据流的三个”不寻常”之处

  1. UI 永不主动拉数据。所有更新都是 ViewModel 推状态 → UI 被动重组。UI 代码里没有一个”刷新”方法。
  2. native 结果不推送、靠拉取。80ms 轮询是对第三方线程模型的妥协(第 8.3 节)。
  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() → 协处理器内存回收,内部目录文件保留(下次秒加载)

三个持久化策略要点:

  1. 只拷一次:ensureLocal 检查内部目录已齐全就直接用,不重复拷贝。
  2. 手动清理:模型升级时旧文件不会自动覆盖(copyChecked 对”大小一致”的文件跳过)。README 明确写了处理方式:adb shell pm clear com.horus.hsvlm 或手动 rm -rf 内部模型目录。
  3. 没有数据库。聊天记录、设置(开关状态)全在内存 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 侧没有任何机会写日志

教训与对策:

  1. 进程级崩溃的唯一证据在 logcat / tombstones(adb logcat | grep -i "signal\|abort"),App 内日志面板什么都不会有。
  2. 对这类问题,防御手段是使用前的校验(确保 6 个模型文件来自同一批次),而不是 try/catch。
  3. .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
// UI 线程点按钮 → 只是调一个方法,方法内部立刻返回(活儿在协程里)
fun send(rawPrompt: String) {
...校验...
_state.update { ... } // 主线程:改状态(微秒级)
sendJob = viewModelScope.launch(Dispatchers.IO) { ... } // 干活:切到 IO 线程
}

链式调用直到 UI 刷新怎么保证只在主线程?答案是 Compose 的 collectAsStateWithLifecycle,它自动把状态更新调度回主线程执行重组,开发者不用手动 runOnUiThread(老 Android 写法的痛点)。

规则二:阻塞中的协程不许取消,只能用”软中断”让它自己退出。

1
2
3
4
5
6
// ✗ 错误做法(会让 native 调用悬空)
sendJob?.cancel()

// ✓ 正确做法:请求 native 自己停
fun stopGenerating() { engine.cancel() } // 内部调 rknn3_session_stop
// 阻塞中的 generate 收到 stop 后正常返回,协程走完收尾逻辑

像后端的”优雅停机”(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
# 方式一:命令行(和本项目 README 完全一致)
cd MyFirstApp
.\gradlew.bat :app:assembleDebug # 构建,产物在 app\build\outputs\apk\debug\app-debug.apk

# 方式二:Android Studio 里点绿色 ▶ 按钮(推荐,自动完成构建+安装+启动)

# 设备连接(真机开 USB 调试,或启动模拟器)后:
adb devices # 确认设备在线
.\gradlew.bat :app:installDebug # 或直接由 IDE 安装
adb shell am start -n com.example.myfirstapp/.MainActivity # 命令行启动

对应本项目的构建命令(README 原文):

1
2
3
4
5
.\gradlew.bat :app:assembleDebug         # 产物 app-debug.apk (~59MB,含模型库)
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(含大量一手踩坑记录)。

面向 Java 开发的安卓实战入门指南 | Horus-VLM 安卓项目架构详解
http://www.horus-space.cloud/posts/a6bd1773.html
作者
Horus
发布于
2026年9月10日
许可协议