学习目标
读完本章,你应当能够:
- 说出"读懂代码"和"能独立做出来"之间的差距在哪,以及怎么弥合;
- 掌握"需求裁剪":先把需求砍到最小,打通后再逐步加回;
- 看懂
book/code/ch23/的最小工程骨架(文件路径 / 关键函数签名 / 调用顺序);- 独立把骨架从"示意"补齐成"真实可构建的 App"(对照本书各章的真实工程文件);
- 在真机上完成一次完整对话------这是本书的通关验证;
- 知道该怎么按顺序把砍掉的功能加回来(每一步对应哪一章)。
前置 :全书。特别是 ch04(应用骨架)、ch08(Flow)、ch13(JNI)、
ch15(加载)、ch16(推理循环)、ch20(构建)
对应源码 / 文档:
book/code/ch23/(本章配套最小骨架,全部文件标 📝)- 真实工程
llama.android/(补齐时的权威参考)
cpp
// 📝 mini-ai_chat.cpp ------ 最小原生侧骨架(示意,依赖 llama.cpp 头文件,需接入真实工程 CMakeLists.txt 才能构建)
// 对照真实工程:llama.android/lib/src/main/cpp/ai_chat.cpp(882 行)
// 关键真实行号:decode_tokens_in_batches 第 545--589 行(自己逐批推进 current_position)
// shift_context 第 419--452 行(第一版砍掉,见 ch18 再加回)
// chat_add_and_format 第 486--518 行(第一版砍掉,见 ch17 再加回)
// unload 第 847--868 行(free 后 g_batch = llama_batch{})
//
// 本骨架只保留主链路:init → load → generateNextToken 循环 → unload。
// 第一版不做 chat template 多轮差分、不做上下文移窗------跑通单轮后再按 ch17/ch18 加回。
#include "llama.h"
#include "common.h"
#include <jni.h>
#include <mutex>
#include <string>
static std::mutex g_mutex;
static llama_model * g_model = nullptr;
static llama_context* g_context = nullptr;
static llama_batch g_batch = llama_batch{}; // 📝 全局 batch;unload 后必须清零(BUG_AUDIT 第 7 项)
static int current_position = 0; // 下一个 token 应写入的位置(由解码函数自己推进)
// ---- 初始化 ggml backend(真实在 init(),用 g_backend_initialized 做幂等守卫,见 BUG_AUDIT 第 8 项)----
extern "C" JNIEXPORT jint JNICALL
Java_com_arm_aichat_internal_MiniInferenceEngine_init(JNIEnv *, jobject, jstring libDir) {
std::lock_guard<std::mutex> lock(g_mutex);
llama_backend_init();
return 0;
}
// ---- 加载模型(真实 load + init_context + prepare,见 ch15)----
extern "C" JNIEXPORT jint JNICALL
Java_com_arm_aichat_internal_MiniInferenceEngine_load(JNIEnv *env, jobject, jstring jpath) {
std::lock_guard<std::mutex> lock(g_mutex);
const char *path = env->GetStringUTFChars(jpath, nullptr);
g_model = llama_model_load_from_file(path, llama_model_params{});
env->ReleaseStringUTFChars(jpath, path);
if (!g_model) return 1;
llama_context_params cparams = llama_context_params{};
cparams.n_ctx = 8192; // DEFAULT_CONTEXT_SIZE(真实用常量)
cparams.n_batch = 512;
g_context = llama_new_context_with_model(g_model, cparams);
if (!g_context) return 2;
g_batch = llama_batch_init(512, 0); // 📝 分配 batch
current_position = 0;
return 0;
}
// ---- 生成下一个 token(真实 generateNextToken:prefill + llama_decode + 采样 + UTF-8 安全回传)----
// 返回 null 表示 EOG(结束)。真实工程里 position 由 decode_tokens_in_batches 自己逐批推进(第 545--589 行),
// 调用方【不要】再 current_position += ...,否则会和 shift_context 后的基准错位(ch18/BUG_AUDIT 第 2 项)。
extern "C" JNIEXPORT jstring JNICALL
Java_com_arm_aichat_internal_MiniInferenceEngine_generateNextToken(JNIEnv *env, jobject) {
std::lock_guard<std::mutex> lock(g_mutex);
if (!g_context) return nullptr;
// 📝 示意:真实应先 common_batch_add(token, position=current_position, ...) 再 llama_decode
// 再 llama_sample / llama_decode 取 logits,sampler 采样得到 next token。
// 下面用伪代码表示"推进位置"的责任归属:
// llama_pos position = current_position + j; // 用全局基准
// llama_decode(g_context, g_batch);
// current_position += cur_batch_size; // ← 自己推进(真实在 decode_tokens_in_batches 内)
// 采样到 EOG 时 return nullptr。
// 📝 占位返回:真实应返回 UTF-8 安全的 token 文本(多字节字符需缓存,见 ch16)
return env->NewStringUTF(""); // 📝 示意,真实返回非空 token 或 null(EOG)
}
// ---- 释放(真实 unload,第 847--868 行)----
extern "C" JNIEXPORT void JNICALL
Java_com_arm_aichat_internal_MiniInferenceEngine_unload(JNIEnv *, jobject) {
std::lock_guard<std::mutex> lock(g_mutex);
if (g_context) { llama_free(g_context); g_context = nullptr; }
if (g_model) { llama_model_free(g_model); g_model = nullptr; }
llama_batch_free(g_batch);
g_batch = llama_batch{}; // ⚠️ 真实修复点:清零避免二次 free(BUG_AUDIT 第 7 项)
current_position = 0;
}
kotlin
// 📝 mini-build.gradle.kts ------ app 模块最小构建脚本骨架(示意,非独立可运行)
// 对照真实工程:llama.android/app/build.gradle.kts
// 补齐方法见本目录 README.md。机密(storeFile/密码)一律来自 keystore.properties,不写在此文件。
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.llama" // 真实值见 app/build.gradle.kts:7
compileSdk = 37
defaultConfig {
applicationId = "com.example.llama.aichat" // 真实值见 :11
minSdk = 33
targetSdk = 37
versionCode = 1
versionName = "0.1-mini"
// ⚠️ 真实工程只编 arm64-v8a;ABI 过滤发生在打包合并阶段,不是 CMake 阶段(ch20 20.5)
ndk {
abiFilters.add("arm64-v8a") // 真实值见 app/build.gradle.kts:31-33
}
}
// 签名:机密从项目根 keystore.properties 读取(已 gitignore),启用 v2+v3(ch21)
signingConfigs {
create("release") {
val props = mutableMapOf<String, String>()
val f = rootProject.file("keystore.properties")
if (f.exists()) f.readLines().forEach { /* 解析 storeFile/storePassword/keyAlias/keyPassword */ }
// storeFile = rootProject.file(props.getValue("storeFile")) // 真实见 :54
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
}
buildFeatures { compose = true }
packaging {
jniLibs { useLegacyPackaging = true } // 真实见 app/build.gradle.kts:104-108
}
}
kotlin { jvmToolchain(17) } // 真实见 app/build.gradle.kts:112
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(project(":lib")) // 推理库模块,含 mini-ai_chat.cpp
implementation(libs.compose.material.icons.extended)
implementation(libs.gson)
}
kotlin
// 📝 MiniInferenceEngine.kt ------ 最小 JNI 接口骨架(示意,需放入 lib 模块才能构建)
// 对照真实工程:lib/src/main/java/com/arm/aichat/internal/InferenceEngineImpl.kt
// external fun 签名见第 85--105 行;sendUserPrompt(Flow) 见第 223 行
// 注意:JNI 函数名必须与 mini-ai_chat.cpp 的 JNIEXPORT 名字逐字对应(ch13 命名规则)。
// 包名固定 com.arm.aichat.internal(与真实 InferenceEngineImpl 同包)。
package com.arm.aichat.internal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class MiniInferenceEngine {
// 加载原生库(真实在 InferenceEngineImpl.init 块里 System.loadLibrary + init(nativeLibDir))
init {
System.loadLibrary("ai-chat") // 对应 CMakeLists.txt 的 add_library(ai-chat SHARED ...)
}
// ↓↓↓ 四个最小 external fun(签名对齐真实 InferenceEngineImpl.kt:85-105)↓↓↓
private external fun init(nativeLibDir: String): Int
private external fun load(modelPath: String): Int
private external fun generateNextToken(): String? // EOG 时返回 null
private external fun unload()
/** 初始化 backend(真实见 InferenceEngineImpl.init,g_backend_initialized 幂等守卫,BUG_AUDIT 第 8 项) */
fun initEngine(nativeLibDir: String) { init(nativeLibDir) }
/** 加载模型:suspend 包一层 native load,返回 0 成功(真实见 InferenceEngineImpl.loadModel 第 145 行) */
fun loadModel(path: String): Int = load(path)
/**
* 流式对话:Kotlin 侧自己循环调 generateNextToken,包成 Flow<String>(ch08/ch16)。
* 真实工程的 sendUserPrompt 内部也是这套循环,只是还多了 chat template / 节流 / TTS。
*/
fun chat(prompt: String): Flow<String> = flow {
// 📝 示意:真实应先 processUserPrompt(prompt, nPredict) 把 prompt 编进 KV(见 ai_chat.cpp:654)
// 这里省略该步,直接进生成循环
while (true) {
val token = generateNextToken() ?: break // null = EOG,停止
emit(token)
}
}
/** 释放(真实见 InferenceEngineImpl.destroy → unload + shutdown,BUG_AUDIT 第 7/8 项) */
fun release() { unload() }
}
kotlin
// 📝 MiniMainActivity.kt ------ 最小 UI 骨架(示意,依赖 Android/Compose,需放入真实工程才能构建)
// 对照真实工程:app/src/main/java/com/example/llama/MainActivity.kt
// + ui/MainScreenState.kt(by viewModels() 第 34 行;onAppBackgrounded/Foregrounded 第 635/644 行)
// 补齐:Manifest 标 MAIN+LAUNCHER(ch04)、res/values/strings.xml 等。
package com.example.llama
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
// 最小消息模型(真实工程见 model/Message.kt)
data class Message(val id: String, val text: String, val isUser: Boolean)
// 最小状态容器(真实工程见 ui/MainScreenState.kt,大小 1132 行;这里只留主链路)
class MiniState : ViewModel() {
val messages = mutableStateListOf<Message>() // SnapshotStateList:流式 append 自动重组(ch09)
var isModelReady by mutableStateOf(false)
var isGenerating by mutableStateOf(false)
// engine 对接 MiniInferenceEngine.kt(lib 模块)
private val engine = MiniInferenceEngine()
fun loadModel(path: String) {
// 真实:suspend 包一层 native load;返回 0 成功
isModelReady = (engine.loadModel(path) == 0)
}
fun send(input: String) {
if (!isModelReady || isGenerating) return
messages.add(Message(id = "u${messages.size}", text = input, isUser = true))
val aid = "a${messages.size}"
messages.add(Message(id = aid, text = "", isUser = false))
isGenerating = true
// 在 io 协程里跑原生生成循环(ch08 Flow + ch16 主循环)
// 真实工程用 scope.launch(Dispatchers.Default);这里用 viewModelScope 示意
androidx.lifecycle.viewmodel.compose.viewModelStoreOwner // (占位,真实用 this)
kotlinx.coroutines.GlobalScope.launch {
val buf = StringBuilder()
engine.chat(input).collect { token ->
buf.append(token)
// 节流刷新:真实工程按固定间隔(MainScreenState FLUSH_INTERVAL_NANOS)批量更新
val i = messages.indexOfFirst { it.id == aid }
if (i != -1) messages[i] = messages[i].copy(text = buf.toString())
}
isGenerating = false
}
}
}
@Composable
fun MiniChatApp(state: MiniState = viewModel()) {
val context = LocalContext.current
var input by remember { mutableStateOf("") }
// 文件选择器:选 GGUF(真实见 MainActivity.kt 的 filePickerLauncher / ModelImporter.kt)
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
// 真实:把 Uri 拷贝进内部存储再 load(见 util/ModelImporter.kt)
val path = uri.toString() // 📝 示意:真实应取可读文件路径
state.loadModel(path)
}
MaterialTheme {
androidx.compose.foundation.layout.Column {
Button(onClick = { picker.launch("application/octet-stream") }) {
Text(if (state.isModelReady) "模型已加载" else "选择模型")
}
LazyColumn(modifier = androidx.compose.ui.Modifier.weight(1f)) {
items(state.messages) { m ->
Text("${if (m.isUser) "你" else "AI"}: ${m.text}")
}
}
TextField(value = input, onValueChange = { input = it },
label = { Text("输入") })
Button(onClick = { state.send(input); input = "" },
enabled = !state.isGenerating) { Text("发送") }
}
}
}
🧭 读本章前,请先确认
- 前置 :全书 。尤其 ch04(应用骨架)、ch08(Flow)、ch13(JNI)、
ch15(加载)、ch16(推理循环)、ch20(构建)。- 需要的基础:能独立构建一次工程(ch02);会写 Kotlin 函数(ch03)。
- 本章会出现的生词 :MVP(最小可用版本,Minimum Viable Product)、需求裁剪 、
walking skeleton("能走通的骨架"------先把端到端链路打通,再往上加肉)、
capstone(毕业作品/综合实战------把前面学过的东西串起来做一个成品)、
最小架构、ANR(应用无响应,App 卡住超过约 5 秒系统会弹"无响应")。
查 附录 B.9。- 读法建议 :⭐ 本章是全书唯一一章"必须动手"的 ------
只读不做,收获约等于 0。
建议从下往上做 (先让:lib编过 → 再写 Kotlin 引擎 → 最后写界面),
而且每完成一步就构建一次(这正是 ch22 讲的"一次只改一个变量")。
本章导读你已经读完了 22 章。但有一个残酷的事实:
"读懂"和"能做出来"之间,隔着一道很宽的沟。
读代码时,一切都很合理:"哦,这里用 Flow,那里用 JNI"。但当你面对一个空目录、
要自己决定"先写哪个文件、接口怎么定"时,你会发现脑子里一片空白。
💡 类比:看懂别人盖的房子 ≠ 自己会盖
你参观了 22 栋精致的房子,知道了"承重墙要放钢筋""屋顶要做防水"。
但让你从一块空地开始盖------你不知道先打地基还是先立柱子。
本章就是让你亲手盖一间"最小的小屋" :
一个房间(一个页面)、一扇门(一个按钮)、一张床(能对话)。
不用装修、不用花园、不用车库------先能住人。
这就是**最小可用版本(MVP, Minimum Viable Product)**的思路:
完整产品(本工程) 最小版本(本章要做的) 4 个页面(模型/聊天/翻译/设置) 1 个页面(聊天) 11 个 JNI 方法 4--5 个 多会话 + 持久化 + TTS + 导出 + 采样参数 + 移窗 全砍掉 约 6000 行 Kotlin + 943 行 C++ 几百行 本章的顺序:
先讲怎么"砍"(23.2)→ 再讲最小架构长什么样(23.3--23.7)→ 怎么组装(23.8--23.9)
→ 最后讲怎么"加回来"(23.10)。
23.1 为什么做"最小复刻"
🧢 从零开场白:本章是全书的"结业项目"(capstone)
前面 22 章你学了很多东西:Kotlin 语法、Compose 界面、协程与 Flow、JNI 桥梁、
llama.cpp 的加载与推理、Gradle 构建、签名出包。
这些东西单独看都不难,但它们像一堆零件 ------发动机、轮胎、方向盘、座椅,
散落在地上你是开不走的。本章教你的,不是某个新零件,而是怎么把这些零件组装成一辆能开的车。
💡 类比:搭乐高
你已经攒了一大堆积木块(前面每一章就是一类积木)。但光有积木成不了东西。
本章就是那张拼装图纸 :先搭哪一步、后搭哪一步、每一步搭完应该长什么样、
搭错了会出现什么现象------一步步告诉你。
💡 为什么不能一上来就做一个"完美的 App"?
新手最常见的陷阱是:一上来就想做"什么功能都有"的完整产品------
模型管理、多会话、语音朗读、主题皮肤、设置页......结果哪个都没做完,半途而废。
正确做法是先做一个最小可用版本(MVP) :只保留"能跑通核心流程"的最少功能,
让它先跑起来、看到成果,再像加调料一样一个一个加功能。
记住:先有一辆能开的车,再给它加导航和真皮座椅。
23.1.1 读懂 ≠ 能做
"读懂"训练的是 :跟随别人的思路,理解既有结构。
"能做出来"需要的是 :在没有结构的地方建立结构------决定写哪些文件、接口怎么定、调用顺序如何。
这是两种不同的能力:
| 读懂 | 能做 | |
|---|---|---|
| 输入 | 已有的代码 | 一个空白目录 + 一个需求 |
| 主要动作 | 理解、trace | 决策、权衡、组织 |
| 难点 | 代码量大、概念多 | 不知道该从哪下手 |
| 训练方式 | 读 + 画图 | 动手写 |
所以本章唯一的要求是:真的动手写一遍。
23.1.2 为什么先做"最小版"
如果你一上来就复刻完整工程,会遇到:
| 困难 | 后果 |
|---|---|
| 要同时处理十几个概念 | 卡住不知道哪错了 |
| 出问题时原因可能在任何一层 | 排查无从下手 |
| 战线太长 | 中途放弃 |
先做最小版的好处:
| 好处 | 说明 |
|---|---|
| 链路短 | 出问题只可能在那几步,容易定位 |
| 反馈快 | 几百行代码,改完就能跑 |
| 有成就感 | "它能说话了!"------这是继续下去的动力 |
| 地基稳 | 后面加功能都建立在"已打通的链路"上 |
📌 这也是软件工程里一条重要原则 :
先做一个能端到端跑通的最小系统(walking skeleton),再往上加肉。
因为**"端到端跑通"能验证所有接口假设**------如果接口定错了,越晚发现成本越高。
23.2 需求裁剪:砍到最小
先把"需求裁剪"这件事说清楚,分四步看:
① 这是什么(大白话)
需求裁剪,就是把"我要做一个完美的 AI 聊天 App"这个大目标,
砍成"我要做一个能选模型、能加载、能流式对话的最小 App"。
所有"锦上添花"的功能------主题皮肤、TTS 朗读、导出分享、多会话管理------全部先砍掉,
只留下"没有它整个 App 就不成立"的核心流程。
② 为什么需要
新手最容易"贪多嚼不烂":想一次把所有功能都做完,结果每个功能都半吊子,
哪个都跑不通,最后在挫败感里放弃。裁剪需求的意义是:
先做出一个能跑的版本 (哪怕它很丑、很简陋),让你立刻看到成果、获得成就感,
然后再一步步把砍掉的功能加回来。这样每一步都有正反馈。
③ 最小示例 📝(演示一次裁剪)
原始需求清单(新手脑子里的"完美 App"):
模型管理页 / 多会话切换 / TTS 朗读 / 导出聊天记录 /
主题切换 / 设置页 / 翻译页 / 自动恢复上次模型 / ......
裁剪后的 MVP 清单(只留 4 个动作):
① 选一个模型文件
② 把它加载进来
③ 输入一句话并发送
④ AI 的回答一个字一个字显示出来
其余所有功能,全部推到 23.10 再加。
④ 工程真身 ✅(本工程实际怎么砍的)
就是下面这张表------左边是完整工程的全部功能,右边是最小版的取舍,
最后一列告诉你每一项"为什么可以砍"。对照它,你就知道哪些先不要做。
💡 类比:煮面
你不能一上来就把牛肉、鸡蛋、青菜、辣椒、醋全倒进锅里------那是大乱炖。
要先煮一碗白面条 :水烧开、面煮熟、能吃就行。
然后根据自己的口味,一样一样加调料。
需求裁剪就是"先煮白面条":能跑通核心流程就是胜利,调料后面慢慢加。
完整产品的功能清单 vs 最小版:
| 功能 | 完整工程 | 最小版 | 为什么可以砍 |
|---|---|---|---|
| 聊天页 | ✅ | ✅ 保留 | 核心功能 |
| 模型管理页 | ✅ | ❌ | 可直接硬编码一个模型路径 |
| 翻译页 | ✅ | ❌ | 与聊天同构,加了不验证新东西 |
| 设置页 | ✅ | ❌ | 参数用默认值 |
| 多会话 | ✅ | ❌ | 内存里一个 List 就够 |
| 持久化(DataStore) | ✅ | ❌ | 重启丢历史可接受 |
| TTS 朗读 | ✅ | ❌ | 独立功能,不影响主链路 |
| 导出/分享 | ✅ | ❌ | 独立功能 |
| Markdown 渲染 | ✅ | ❌ | 纯文本输出即可 |
| 采样参数贯通 | ✅ | ❌ | 用 native 默认值 |
| KV cache 移窗 | ✅ | ❌ | 短对话碰不到 |
| 自动恢复上次模型 | ✅ | ❌ | 手动选一次即可 |
| 前后台释放模型(F10) | ✅ | ❌ | 长驻内存,简单 |
| 取消生成 | ✅ | ❌ | 让它生成完就行 |
保留的只有三件事:
① 选模型(从文件管理器选一个 .gguf)
② 加载模型
③ 流式对话(发一句 → 一个字一个字收到回复)
⚠️ 注意"砍掉"不等于"不重要" 。
恰恰相反,砍掉的那些正是工程复杂度的大头 (ch09 的 1132 行状态容器、ch10 的资源释放、
ch18 的位置管理...)。
本章目的是"打通主链路",不是"做一个能用的产品"。
23.2.1 砍掉后的"最小数据模型"
完整工程有 Message / ChatSession / ModelInfo 三个数据类。
最小版只需要一个:
kotlin
// 📝 示例
data class MiniMessage(val isUser: Boolean, val content: String)
连 id 都不需要------因为不做持久化、不做列表 key 的复杂管理。
💡 这就是"需求裁剪"的威力 :砍掉多会话,
id就没用了;砍掉持久化,
data class也不用考虑 Gson 序列化。
23.3 最小架构:四块拼图
最小版只有四块(对照完整工程的五层架构,ch01):
┌──────────────────────────────────────────────┐
│ ① MiniMainActivity.kt │
│ 一个 Activity + Compose 界面 │
│ 显示消息列表 + 输入框 + "发送" │
├──────────────────────────────────────────────┤
│ ② MiniInferenceEngine.kt │
│ 状态(消息列表 + 引擎状态) │
│ + 调 native 的 external fun 声明 │
├──────────────────────────────────────────────┤
│ ③ mini-ai_chat.cpp │
│ JNI 实现(load / generate / unload) │
├──────────────────────────────────────────────┤
│ ④ CMakeLists.txt │
│ 把 ③ 和 llama.cpp 一起编成 .so │
└──────────────────────────────────────────────┘
加上构建脚本 (build.gradle.kts),一共 5 个文件。
📌 对比一下完整工程:
- ① 对应
app模块(但完整工程有 25 个 Kotlin 文件);- ② 对应
MainScreenState+InferenceEngineImpl(两件事合成了一个);- ③ 对应
ai_chat.cpp(882 行 → 本章约 100 行);- ④ 对应
lib/.../CMakeLists.txt(60 行 → 本章约 15 行)。结构是一样的,只是"厚度"不同。
这正是"最小复刻"的意义:把结构的骨架抽出来,先跑通它。
23.4 最小 JNI 接口设计
"最小 JNI"也分四步看:
① 这是什么(大白话)
JNI 是 Kotlin 和 C++ 之间的"电话线"。最小 JNI 的意思是:
先只拉通最核心的两条线 ------"加载模型"和"生成回答"两个 JNI 函数就够了,
完整工程那 11 个 JNI 方法,现在不用一次全写。
② 为什么需要
JNI 是跨语言调用:参数类型、函数命名、线程切换都很容易写错(ch13 踩过的坑)。
一次写 11 个函数,出错时你根本不知道是哪一个出了问题。
先只写几个最核心的函数,把"Kotlin 能调到 C++"这件事跑通,
确认电话能拨通,后面再一条一条加线。
③ 最小示例 📝(最短的 JNI 长什么样)
Kotlin 侧只要声明两个 external fun(external 的意思是"这个函数实现在 C++ 里,Kotlin 只负责声明",详见 ch13):
kotlin
// 📝 示例(需自行验证)
external fun loadModel(path: String): Boolean
external fun generate(prompt: String): String
C++ 侧实现这两个函数:loadModel 里调 llama_model_load_from_file 把模型读进来,
generate 里把整条回答一次性算完返回。
------注意,"一次性返回整条回答"只是这里为了讲清楚而用的最简写法;
真要做到"一个字一个字蹦出来"的流式效果,才需要 23.4.2 的 generateNextToken。
④ 工程真身 ✅(本工程的最小 JNI 到底是哪几个)
见 23.4.1(完整的 11 个)和 23.4.2(最小版只留 4 个)。
其中最核心、缺了整个 App 就不成立的就是一对:
loadModel(加载)和 generateNextToken(生成);
init / unload 是"开机"和"关机",配套必须。
💡 类比:两国之间先建一条电话线
两个国家要做生意,不用一上来就建光缆、卫星、海底电缆。
先拉一条电话线,能通话、能确认线路是通的,就够用了。
后面业务多了,再一条一条加线。JNI 函数就是这些"线"------先通两条,别贪多。
23.4.1 完整工程有 11 个方法(ch13 讲过)
kotlin
private external fun init(nativeLibDir: String)
private external fun load(modelPath: String): Int
private external fun prepare(): Int
private external fun setSamplingParamsNative(temperature: Float, topP: Float, topK: Int)
private external fun systemInfo(): String
private external fun benchModel(pp: Int, tg: Int, pl: Int, nr: Int): String
private external fun processSystemPrompt(systemPrompt: String): Int
private external fun processUserPrompt(userPrompt: String, predictLength: Int): Int
private external fun generateNextToken(): String?
private external fun unload()
private external fun shutdown()
23.4.2 最小版只需要 4 个
| 最小版方法 | 作用 | 对应完整工程的哪个 |
|---|---|---|
init(libDir: String): Int |
初始化 backend | init |
loadModel(path: String): Int |
公开包装,返回 0=成功 ;内部合并了 prepare,实际调 external load |
load + prepare 合并 |
generateNextToken(): String? |
生成一个 token(null=结束,空串=继续) | processUserPrompt + generateNextToken 合并 |
unload() |
释放全部资源 | unload + shutdown 合并 |
注意三个"合并":
| 合并 | 为什么可以合 |
|---|---|
load + prepare |
最小版不需要"分两步报错",合起来更简单 |
processUserPrompt + generateNextToken |
最小版不做多轮历史,每次直接喂完整 prompt |
unload + shutdown |
最小版不需要区分"模型层"和"backend 层" |
⚠️ 但要注意一个后果 :
generateNextToken合并了两件事,意味着它第一次调用时要先处理 prompt(prefill) 。所以它的语义是"第一次调用做 prefill,之后每次生成一个 token "------
这需要一个内部状态标志(见 23.7)。
💡 接口设计的自由度就在这里体现 :完整工程的拆分是为了支持更多功能 (多轮历史、移窗、错误分级);
最小版的合并是为了让链路更短 。
两者都对------取决于你要什么。
23.5 最小 CMakeLists
✅ 对照本工程真实 CMakeLists(60 行),最小版只需要核心几行:
cmake
# 📝 示例(需自行验证)
cmake_minimum_required(VERSION 3.31.6)
project("ai-chat" VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED true)
# 把 llama.cpp 挂进来一起构建
set(LLAMA_SRC ${CMAKE_CURRENT_LIST_DIR}/../../../../../../llama.cpp)
add_subdirectory(${LLAMA_SRC} build-llama)
# 我们的 JNI 桥
add_library(${CMAKE_PROJECT_NAME} SHARED
mini-ai_chat.cpp)
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
${LLAMA_SRC}
${LLAMA_SRC}/common
${LLAMA_SRC}/include
${LLAMA_SRC}/ggml/include
${LLAMA_SRC}/ggml/src)
target_link_libraries(${CMAKE_PROJECT_NAME}
llama
llama-common
android
log)
对比完整工程 ,最小版少了什么:
| 少了 | 为什么可以少 |
|---|---|
if(DEFINED ANDROID_ABI) 的 ABI 条件配置 |
最小版只跑自己的真机,不用管多 ABI(ch20) |
GGML_CPU_KLEIDIAI / GGML_OPENMP 等宏 |
不给 ai_chat.cpp 传这些宏也能跑(只是慢一点) |
target_compile_definitions |
同上 |
⚠️ 注意
LLAMA_SRC的路径层数 :完整工程是
../../../../../llama.cpp(上溯 5 级),因为真实路径是
lib/src/main/cpp/。最小版的目录结构不同,层数也不同 ------照抄别人的路径是这类错误的高发区 。
一定要按自己的目录结构数一遍。
23.6 最小 Kotlin 侧
23.6.1 引擎:把"状态"和"引擎"合成一个类
✅ 对照本工程 (MainScreenState + InferenceEngineImpl 两件事),最小版合成一个:
kotlin
// 📝 骨架(需自行验证)
class MiniInferenceEngine {
val messages = mutableStateListOf<MiniMessage>()
var isLoaded by mutableStateOf(false)
var isGenerating by mutableStateOf(false)
// ↓↓↓ 四个最小 external fun:名字必须与 mini-ai_chat.cpp 的 JNIEXPORT 逐字对应(ch13)↓↓↓
private external fun init(libDir: String): Int
private external fun load(modelPath: String): Int // JNI: Java_..._MiniInferenceEngine_load
private external fun generateNextToken(): String? // EOG 返回 null,空串表示"继续"
private external fun unload()
fun initialize(context: Context) {
System.loadLibrary("ai-chat") // ← 与 CMake 的 project(ai-chat) 对应
init(context.applicationInfo.nativeLibraryDir) // ← backend 动态加载需要它(ch20)
}
// 公开包装:内部调 external load,返回 0=成功(与骨架 MiniMainActivity 的 engine.loadModel(path) 一致)
fun loadModel(path: String) { isLoaded = load(path) == 0 }
fun send(input: String) {
messages.add(MiniMessage(true, input))
// 生成要在后台线程做(ch08)
// ...(见 23.6.2)
}
}
三处关键对应:
| 最小版 | 完整工程 | 章节 |
|---|---|---|
System.loadLibrary("ai-chat") |
System.loadLibrary("ai-chat") |
ch13(最小版与完整工程库名一致,都对应 CMake project(ai-chat)) |
传 nativeLibraryDir |
传 nativeLibraryDir |
ch20(GGML_BACKEND_DL 需要) |
用 mutableStateListOf |
用 SnapshotStateList |
ch09 |
23.6.2 生成循环:最短的版本
✅ 对照本工程 MainScreenState.sendMessage(113 行),最小版约 20 行:
kotlin
// 📝 示例
fun send(input: String, scope: CoroutineScope) {
messages.add(MiniMessage(true, input))
val reply = MiniMessage(false, "")
messages.add(reply)
isGenerating = true
scope.launch(Dispatchers.Default) {
try {
var idx = 0
while (true) {
val piece = generateNextToken() ?: break // ← null = 结束(EOG)
if (piece.isNotEmpty()) {
idx = messages.indexOf(reply)
messages[idx] = reply.copy(content = reply.content + piece) // 触发重组
}
}
} finally {
isGenerating = false
}
}
}
对比完整工程,这里省掉了:
- 节流刷新(ch08 的 100ms 节流)------最小版每 token 刷新,简单但会稍卡;
ThinkStripper(ch16/ch17 的思考块剥离);- TTS 流式朗读(ch12);
- 取消(ch08);
- 节流落盘(ch09);
- 错误处理(只用了
try/finally)。
💡 注意
messages[idx] = reply.copy(...):这是 ch03 讲的不可变更新 ------必须产生新对象,Compose 才会重组。
如果写成
reply.content += piece(改字段),界面不会刷新。这是最小版最容易踩的坑之一。
23.6.3 界面:最短的版本
"最小 UI"同样分四步看:
① 这是什么(大白话)
最小 UI 就是:界面上只放四样东西------
一个"选模型"按钮、一个输入框、一个"发送"按钮、一块显示回答的文本区。
不要 Scaffold(Compose 里那种带顶栏底栏的整体页面架子)、不要底部导航、
不要主题配色、不要页面跳转动画------那些都是"装修",先不做。
② 为什么需要
新手做 App 最容易在界面上花掉 80% 的时间:调颜色、加圆角、做转场动画......
结果把真正难的"模型推理流程"晾在一边。最小 UI 的目的就是逼你把注意力放在主链路上 :
界面只要能"点按钮、打个字、看到回答"就行,好不好看根本不重要。
③ 最小示例 📝(不到 20 行)
kotlin
// 📝 示例(需自行验证)
Column {
Button(onClick = onPickModel) { Text("选模型") } // 选模型
TextField(value = input, onValueChange = { input = it }) // 输入框
Button(onClick = { engine.send(input) }) { Text("发送") } // 发送
Text(answer) // 显示回答
}
一个 Column(竖排布局)从上到下堆四样东西,就是最小 UI 的全部。
④ 工程真身 ✅(本工程的最小 UI)
就是下面这段 MiniChatScreen------你会发现它和上面的最小示例几乎一一对应:
Button(选模型) → LazyColumn 消息列表 → TextField + Button(发送)。
这四样是 MVP 必需的 ;完整工程里那些抽屉导航、主题切换、Markdown 渲染、
状态栏适配,都是后来加的"装修"(见 23.10 的加回顺序)。
💡 类比:毛坯房
毛坯房是什么样?有门、有窗、有水电、能住人,但墙没刷、地没铺、家具没有。
最小 UI 就是 App 的"毛坯房"------先保证"能住人"(能操作、能看到结果),
后面再慢慢装修(加圆角、加动画、加主题)。
kotlin
// 📝 示例
@Composable
fun MiniChatScreen(engine: MiniInferenceEngine, onPickModel: () -> Unit) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Button(onClick = onPickModel) { Text(if (engine.isLoaded) "已加载" else "选模型") }
LazyColumn(Modifier.weight(1f)) {
items(engine.messages) { msg -> // ⚠️ 最小版没给 key(能跑,但见下)
Text(if (msg.isUser) "我: ${msg.content}" else "AI: ${msg.content}")
}
}
var input by remember { mutableStateOf("") }
Row {
TextField(value = input, onValueChange = { input = it }, modifier = Modifier.weight(1f))
Button(
enabled = engine.isLoaded && !engine.isGenerating,
onClick = { engine.send(input); input = "" }
) { Text("发送") }
}
}
}
与完整工程的对应(ch06/ch07):
LazyColumn← ch07 的MessageList(但没给key------见下方警告);remember { mutableStateOf("") }← ch06 的remember+mutableStateOf;enabled = engine.isLoaded && !engine.isGenerating← ch07 的状态提升(isSendButtonEnabled)。
⚠️ 最小版故意"不给 key"是有意的 :
因为只有一个固定列表、不增删重排 ,功能上不会出错。
但如果你要加"多会话切换",就必须给 key (ch07 7.2.3 讲过不给 key 的后果)。
这是"最小版"和"可用产品"的边界。
23.7 最小 C++ 侧
✅ 对照本工程 ai_chat.cpp(882 行),最小版约 100 行:
cpp
// 📝 骨架(需自行验证)
#include <jni.h>
#include <string>
#include <mutex>
#include "llama.h"
#include "common.h"
#include "chat.h"
static std::mutex g_mutex;
static llama_model * g_model = nullptr;
static llama_context * g_context = nullptr;
static llama_batch g_batch;
static common_sampler * g_sampler = nullptr;
static common_chat_templates_ptr g_templates;
// 最小版的"会话状态":prefill 是否已完成
static bool g_prompt_processed = false;
static llama_pos g_pos = 0;
extern "C" JNIEXPORT jint JNICALL
Java_<包>_MiniInferenceEngine_init(JNIEnv *env, jobject, jstring libDir) {
std::lock_guard<std::mutex> lock(g_mutex);
const char *dir = env->GetStringUTFChars(libDir, 0);
ggml_backend_load_all_from_path(dir);
env->ReleaseStringUTFChars(libDir, dir);
llama_backend_init();
return 0;
}
extern "C" JNIEXPORT jint JNICALL
Java_<包>_MiniInferenceEngine_load(JNIEnv *env, jobject, jstring path) {
std::lock_guard<std::mutex> lock(g_mutex);
const char *p = env->GetStringUTFChars(path, 0);
g_model = llama_model_load_from_file(p, llama_model_default_params());
env->ReleaseStringUTFChars(path, p);
if (!g_model) return 1;
llama_context_params cp = llama_context_default_params();
cp.n_ctx = 4096; // 最小版用小一点的窗口
cp.n_batch = 512;
g_context = llama_init_from_model(g_model, cp);
if (!g_context) return 2;
g_batch = llama_batch_init(512, 0, 1);
g_templates = common_chat_templates_init(g_model, "");
g_sampler = common_sampler_init(g_model, {});
g_prompt_processed = false;
g_pos = 0;
return 0;
}
extern "C" JNIEXPORT jstring JNICALL
Java_<包>_MiniInferenceEngine_generateNextToken(JNIEnv *env, jobject) {
std::lock_guard<std::mutex> lock(g_mutex);
// 第一次调用:处理 prompt(prefill)
if (!g_prompt_processed) {
// 用模型自带模板渲染(ch17)
// tokenize → 分批 decode(ch16)
// g_prompt_processed = true
}
// 每次调用:采样一个 token → decode → 转文本返回(ch16)
// 结束(EOG / 上限)返回 nullptr
}
23.7.1 最小版省掉了什么
| 省掉 | 章节 | 后果 |
|---|---|---|
g_kv_text 记账 |
ch17 | 不支持多轮对话(每次重喂 prompt) |
移窗 shift_context |
ch18 | 上下文顶满就报错/崩 |
UTF-8 缓存 + is_valid_utf8 |
ch16 | 中文可能出乱码方块 |
| 位置三处同步 | ch18 | ---(因为没移窗) |
| 失败路径清理 | ch15 | 加载失败会残留 |
| 采样参数注入 | ch19 | 用默认值 |
| 释放后置空 | ch15 | 反复 load/unload 可能 double free |
⚠️ 注意"省掉 UTF-8 缓存"这一条 :
如果你的模型输出的 token 恰好把汉字切断,屏幕上会出现乱码方块 。
如果遇到这个现象,说明你需要 ch16.5 的 UTF-8 缓存 ------
这就是"加回来"的第一个候选(见 23.10)。
23.7.2 一个必须注意的细节:Java_<包>_... 要替换
JNI 函数名必须和你的包名、类名完全对应 (ch13 讲过)。
本章骨架里写的是 Java_<包>_MiniInferenceEngine_init------你要把 <包> 换成真实的(包名逐层转下划线)。
例如包名 com.arm.aichat.internal、类名 MiniInferenceEngine:
Java_com_arm_aichat_internal_MiniInferenceEngine_init
↑ JNI 名字用类名 `MiniInferenceEngine`(与骨架 MiniInferenceEngine.kt 一致)
📌
class MiniInferenceEngine的方法,JNI 名字里用类名MiniInferenceEngine------
external fun按类名 + 方法名 拼(包名逐层转下划线前缀)。具体拼法可以在
InferenceEngineImpl里对照(规则一致)。
23.8 组装步骤:从骨架到能构建
✅ 对照 book/code/ch23/README.md 的步骤,完整流程如下。
步骤 ①:建工程骨架
mini-llama/
├── settings.gradle.kts ← include(":app", ":lib")
├── build.gradle.kts
├── gradle.properties ← JDK 路径 + 4G 堆(ch02)
├── local.properties ← SDK 路径(ch02)
├── app/ ← 界面
│ ├── build.gradle.kts
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/.../MiniMainActivity.kt
│ └── res/values/strings.xml
└── lib/ ← 引擎 + C++
├── build.gradle.kts
└── src/main/
├── java/.../MiniInferenceEngine.kt
└── cpp/
├── CMakeLists.txt
└── mini-ai_chat.cpp
📌 建议直接以本工程为模板裁剪 :
建一个空 Android 工程(Android Studio 或命令行),
然后按上面的结构把文件一个个写出来 。
这样能保证"构建体系的部分"是对的(ch20 的坑都避开了)。
步骤 ②:配构建脚本
关键项(对照 ch02/ch20):
| 文件 | 要配什么 |
|---|---|
gradle.properties |
org.gradle.java.home(JDK 17)、org.gradle.jvmargs=-Xmx4096m |
local.properties |
sdk.dir |
app/build.gradle.kts |
namespace、applicationId、compileSdk 37、minSdk 33、abiFilters.add("arm64-v8a") |
lib/build.gradle.kts |
ndkVersion、externalNativeBuild { cmake { path(...); version = "3.31.6" } }、CMake arguments |
⚠️ 别忘了
abiFilters------ch20 讲过"半残切片"的坑。最小版只留 arm64,避免踩坑。
步骤 ③:写 CMakeLists
按 23.5 的模板,注意 LLAMA_SRC 的相对层数(按你自己的目录结构数)。
步骤 ④:写 C++
按 23.7 的骨架补全(关键是替换 Java_<包>)。
步骤 ⑤:写 Kotlin 引擎
按 23.6.1/23.6.2 补全。注意 System.loadLibrary 的库名要和 CMake 的 project() 对应。
步骤 ⑥:写界面
按 23.6.3 补全。
步骤 ⑦:构建
bash
gradlew :app:assembleRelease
第一次构建会很慢(要编 llama.cpp,ch02 讲过 2 分钟以上)。
📌 如果报错,按"从下往上"排查(先 C++ 再 Kotlin):
- CMake 配置错误?→ 看
LLAMA_SRC路径(最常见);- C++ 编译错误?→ 用
clang++ -fsyntax-only快速验证(ch13);- JNI 找不到方法?→ 核对
Java_<包>_类_方法拼写(ch13);- Kotlin 编译错误?→ 常规 Kotlin 问题。
步骤 ⑧:装到真机
bash
gradlew :app:assembleRelease
adb install -r app/build/outputs/apk/release/离线AI-release.apk
(首次安装可能需要 adb install 而不是双击------debug 签名在某些 ROM 上会被拦,ch02/ch21 讲过。)
23.9 打通验证:完成一次对话
这是本书的通关验证。
在按清单验证之前,先理解"打通链路"这件事,分四步看:
① 这是什么(大白话)
"打通链路"就是把四个动作串成一条线 :
用户点"选模型" → 拿到文件路径 → 调用加载 → 用户打字点"发送" → 调出生成 →
回答一个字一个字出现在屏幕上。
每一步单独做都不难(选文件 ch12、加载 ch15、生成 ch16、刷新界面 ch09),
但串起来才是一个完整 App。
② 为什么需要
单独做某一步,你只能证明"这一步我写对了";
但一个 App 能不能用,取决于"从用户点按钮到屏幕出字"这条线是否从头到尾都通。
串起来最容易出的问题是:线程不对(界面卡死)、回调没接上(发了消息没反应)、
状态没更新(回答出来了屏幕却不动)。
链路一旦打通,MVP 就成了------这是整个 capstone 最关键的一步。
③ 最小示例 📝(每步加一行日志确认)
打通时别指望一把跑通,要在每一步加日志,逐个确认:
// 📝 示例(伪代码,需自行验证)
① 用户点"选模型" -> Log.d("chain", "picked: $path") // 确认能拿到文件路径
② loadModel(path) -> Log.d("chain", "load result: $ok") // 确认 C++ 真的加载了模型
③ 点"发送" -> Log.d("chain", "send: $input") // 确认点击传到了引擎
④ generate 回字 -> Log.d("chain", "piece: $piece") // 确认 JNI 在往外吐字
⑤ UI 刷新 -> 确认屏幕上真的多了一行字
哪一步日志没出来,问题就在那一步------不要猜,用日志定位。
④ 工程真身 ✅(本工程的完整链路)
就是下面 23.9.1 的验证清单:五个步骤,从打开 App 到"一个字一个字往外蹦"。
它不是凭空设计的,而是上面这条链路在真机上的逐步落地。
💡 类比:串珠子
你有很多颗珠子(选文件、加载、JNI、生成、刷新界面------每一步都是一颗珠子),
散在桌上什么也不是。要用一根线(数据流)把它们按顺序穿起来,
才能变成一条项链(一个能用的 App)。这根线穿到一半断了,项链就做不成------
所以每穿一颗都要确认线还在(加日志)。
23.9.1 验证清单
| # | 操作 | 期望 |
|---|---|---|
| 1 | 打开 App | 看到聊天界面 + "选模型"按钮 |
| 2 | 点"选模型" | 拉起系统文件选择器(ch12 的 ActivityResultContracts) |
| 3 | 选一个 .gguf |
按钮变成"已加载"(可能要等几秒,加载是异步的) |
| 4 | 输入一句话,点"发送" | 你的消息立刻出现 (ch09 的 messages.add 触发重组) |
| 5 | 观察回复 | 一个字一个字往外蹦 (ch16 的 generateNextToken + ch08 的 Flow/emit) |
第 5 步是全书最激动人心的时刻 ------
因为它意味着:JNI 通了、模型加载了、推理循环转起来了、状态驱动 UI 也通了。
23.9.2 如果第 5 步没成功
| 现象 | 最可能的原因 | 对应章节 |
|---|---|---|
| 点了发送,界面卡住 | 生成跑在主线程 | ch08(要用 Dispatchers.Default) |
| 一个字都不出来 | generateNextToken 一直返回 null(JNI 名字错/未加载) |
ch13 |
| 出来一个乱码方块 | UTF-8 被切断 | ch16.5 |
| 出几个字后崩 | 位置越界 / batch 没清 | ch16/ch18 |
| 回复内容对但界面不刷新 | 用了 copy 之外的改法(原地改字段) |
ch03/ch09 |
| 装不上 | debug 签名被 ROM 拦 / 签名冲突 | ch02/ch21 |
UnsatisfiedLinkError: ai-chat |
库名不对 / ABI 不对 | ch13/ch20 |
💡 注意最后一栏"对应章节" :
每一类错误,本书前面都有专门章节讲过。
这就是"先做最小版"的另一个好处------
出问题时你能快速定位到"是哪一章的知识"。
23.10 逐步加回功能
打通之后,按下面的顺序加回(每步都能独立验证):
加回顺序表
| 顺序 | 加什么 | 对应章节 | 为什么这个顺序 |
|---|---|---|---|
| 1 | UTF-8 缓存 | ch16.5 | 中文乱码是最高频的体验问题 |
| 2 | 多轮对话历史 (g_kv_text + 前缀比较) |
ch17 | 聊一轮就没法继续,体验很差 |
| 3 | 上下文移窗 | ch18 | 聊久了会崩 |
| 4 | 取消生成 | ch08 | 用户会想"停下来" |
| 5 | 采样参数贯通 | ch19 | 想调"创造力" |
| 6 | 持久化(DataStore) | ch11 | 重启不丢历史 |
| 7 | 多会话 | ch09/ch11 | 能管理多段对话 |
| 8 | TTS 朗读 | ch12 | 听而不是看 |
| 9 | Markdown 渲染 | ch07 | 代码块/列表更可读 |
| 10 | 导出/分享 | ch11/ch12 | 把对话带出去 |
| 11 | 前后台释放模型(F10) | ch04/ch09 | 省内存 |
| 12 | R8 / 体积优化 | ch20/ch21 | 发布前再考虑 |
📌 这个顺序不是随便定的,它遵循两个原则:
- 先修最影响体验的(乱码 → 不能多轮 → 会崩);
- 先加"与主链路耦合紧的" (UTF-8/多轮/移窗都在同一条数据流上),
后加"独立的"(TTS/导出是旁支,随时能加)。
🎯 每加一个功能,都要有明确的"验收标准"不能"加完代码就觉得完成了"------你要知道做到什么程度算真的完成了 。
每个功能加回来后,都按下面四个问题自查:
# 自查问题 这是在问什么 ① 做完后,用户能做什么? 从用户视角说清这个功能带来的新能力 ② 怎么验证? 具体操作步骤(点哪里、输入什么) ③ 预期结果是什么? 应该看到/听到什么(具体到现象) ④ 没看到预期结果,可能卡在哪? 最常见的失败原因,对照前面哪一章 💡 类比:考试题目
你学完一个知识点(加完一个功能),不能"我觉得我会了"就算数,
要像考试一样做几道题(按上面四条测一遍),确认自己真的会了、功能真的工作了。
"我觉得加好了"和"测试通过了"之间,差着无数个 bug。
下面这张表,就是上面加回顺序里每一步的验收标准(把"用户能做什么 / 怎么验证 / 预期结果 / 常见失败"四问压成四列):
| 顺序 | 加回后用户能... | 怎么验证 | 预期结果 / 没看到=常见失败 |
|---|---|---|---|
| 1 UTF-8 缓存 | 用中文聊天不再出方块 | 让模型生成一段较长的中文回复 | 全程无乱码方块;仍有方块→缓存没接上,或 Kotlin 没判断空串(ch16.5) |
| 2 多轮对话历史 | 能连续聊第 2、3 轮 | 先问"A 是什么",再问"它呢?" | 第 2 轮能记住上文;第二轮就胡说→没维护历史 / 前缀比较错(ch17) |
| 3 上下文移窗 | 长对话不崩 | 连续聊 20 轮以上 | 顶到窗口上限自动移窗不崩;越界崩→g_pos 没跟着走(ch18) |
| 4 取消生成 | 能中途打断 AI | 发送后立刻点"停止" | 回复立刻停止、发送按钮恢复;点了还在蹦字→循环里没查取消标志(ch08) |
| 5 采样参数 | 能调"创造力" | 把 temperature 调到 0 再问事实题 | 回答明显更确定;调了没变化→参数没传到 native(ch19) |
| 6 持久化 | 杀进程重开历史还在 | 聊几句后把 App 划掉再进 | 历史消息还在;历史空→DataStore 没写或读取时机错(ch11) |
| 7 多会话 | 能开新会话、互不串台 | 开 A、B 两个会话来回切 | A 的内容不出现在 B;串台→列表没给 key(ch07) |
| 8 TTS 朗读 | 能听 AI 回答 | 回复出来后点朗读 | 开始播放声音;不响→录音/音频权限或播放状态错(ch12) |
| 9 Markdown 渲染 | 代码块/列表更可读 | 让模型"写一段 Python" | 有格式高亮、换行正常;满屏星号→渲染器没接(ch07) |
| 10 导出/分享 | 能把对话带出去 | 点"导出" | 弹出系统分享面板、文本完整;导出空文件→读的不是当前消息列表(ch11/ch12) |
| 11 前后台释放 | 切后台省内存 | 切后台 30 秒再切回前台 | 内存明显下降;切回要重选模型→释放了但没重建(ch04/ch09) |
| 12 R8/体积优化 | 出的包更小 | 打 Release 看 APK 体积 | 体积下降且 App 能正常跑;编过却闪退→混淆把 JNI 函数名删了(ch20/ch21) |
⚠️ 特别提醒第 12 行 :R8 混淆会"优化"掉它认为没人调用的函数------
JNI 函数虽然 Kotlin 侧声明了,但真正的调用方在 C++ 侧,混淆器看不见。
所以加 R8 时必须在混淆规则里 keep 住 JNI 类和它的 external fun ,
否则装上就
UnsatisfiedLinkError(ch21)。这正是"验收标准第 ④ 条"的典型应用。
每加一个功能的"标准动作"
① 想清楚它属于哪一层(ch01 的五层架构)
② 找对应的章节复习一遍
③ 在最小版上实现
④ 用该章的"动手验证"方法确认
⑤ 确认没弄坏已有功能(回归,ch22)
💡 注意第 ⑤ 步 :每加一个功能都要回归。
"加一个坏一个"是很多项目的死因 ------
一次加十个功能再一起测,出问题时根本不知道是哪个引起的(ch22 练习 4 讲过)。
23.11 常见错误与排查
| 现象 / 报错 | 原因 | 对应章节 |
|---|---|---|
CMake Error: add_subdirectory given source ... not found |
LLAMA_SRC 相对层数不对 |
ch20(23.5) |
UnsatisfiedLinkError: No implementation found for ... |
JNI 函数名拼错 / 漏 extern "C" |
ch13 |
UnsatisfiedLinkError: library "ai-chat" not found |
库名和 project() 不一致 / ABI 不对 |
ch13/ch20 |
SDK location not found |
local.properties 没写或反斜杠没转义 |
ch02 |
| Gradle 起不来 / 版本报错 | JDK 路径没写 / 版本没对齐 | ch02 |
| 加载模型后闪退 | prepare 失败(context/batch/sampler) |
ch15 |
| 界面卡死(ANR) | 生成跑在主线程 | ch08 |
| 回复不刷新界面 | 没产生新对象(原地改字段/列表) | ch03/ch09 |
| 中文乱码方块 | 没做 UTF-8 缓存 | ch16.5 |
| 生成几个字后崩 | 位置越界 / batch 未清 | ch16/ch18 |
| 第二轮开始胡说 | 没维护对话历史 | ch17 |
| 聊久了胡说 | 没做移窗 | ch18 |
中间构建失败:journal-1.lock |
加了 --no-daemon |
ch02 |
native-code 有两个 ABI |
用了注入参数(半残切片) | ch20 |
📌 注意这张表的右列 :
几乎每一个问题都能对应到一章 。
这说明:最小复刻的难度不在"新知识",而在"把已知的东西正确组装"。
23.12 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| 读懂 ≠ 能做 | 前者是理解,后者是决策与组织 | 本章要求真的动手 |
| 最小可用版本 | 先打通主链路,再往上加 | 选模型 → 加载 → 流式对话 |
| 需求裁剪 | 砍掉一切不验证新问题的东西 | 4 页 → 1 页;11 个 JNI → 4 个 |
| 最小架构 | 四块拼图(Activity / 引擎 / JNI / CMake) | 23.3 |
| 接口可合并 | 拆分是为了更多功能,合并是为了链路更短 | load+prepare 合并 |
| 结构相同、厚度不同 | 最小版和完整工程同构 | 23.3 的对比 |
| 组装八步 | 骨架→脚本→CMake→C++→Kotlin→界面→构建→装机 | 23.8 |
| 通关验证 | 真机上完成一次对话 | 23.9 |
| 加回顺序 | 先修体验、先加耦合紧的 | 23.10 |
| 每步都回归 | 一次加一个,加完就测 | ch22 |
23.13 本章你学会了什么
逐条自测。任何一条打不了勾,回到对应小节再看一遍。
- 我能说清"读懂"和"能做"的区别。
- 我理解为什么要先做最小版(walking skeleton)。
- 我能列出最小版砍掉了哪些功能,以及各自对应的章节。
- 我能画出最小版的四块结构。
- 我能说出最小版为什么可以把
load+prepare合并。 - 我能写出最小版的 CMakeLists,并知道
LLAMA_SRC层数要按自己目录数。 - 我知道
System.loadLibrary的库名要和project()对应。 - 我能写出最小版的生成循环(含
copy不可变更新)。 - 我知道最小版为什么可以不给列表
key,以及什么时候必须给。 - 我能说出最小版 C++ 省掉了哪些东西、各自后果是什么。
- 我知道
Java_<包>_类_方法要按自己的包名替换。 - 我能独立完成八步组装并在真机上完成一次对话。
- 我知道加回功能该按什么顺序,以及为什么。
- 我理解"每加一个功能都要回归"的重要性。
23.14 练习
练习 1:动手做一遍(本章唯一的必修练习)
按 23.8 的八步,真的把最小版做出来。
解题思路提示
- 不要从零建工程------以本工程为模板裁剪(省掉 ch02/ch20 的所有坑);
- 先让
:lib编过(C++ 是最难的部分,先搞定它); - 再加 Kotlin 引擎,最后写界面;
- 每完成一步就构建一次,不要攒着。
参考答案要点
推荐的实施顺序(从下往上):
① 建工程骨架(以本工程为模板)
② 写 CMakeLists + 一个"空的" mini-ai_chat.cpp(只 include 头文件)
→ gradlew :lib:externalNativeBuildRelease ← 先确认能编过
③ 实现 init + load(JNI 名字要对,见骨架)
→ 构建 + 装到真机,在 logcat 里看到加载成功的日志
④ 实现 generate(先只生成一个 token 试试)
→ 确认能拿到一个 token
⑤ 补完整生成循环(prefill + 逐 token)
⑥ 写 Kotlin 引擎 + 界面
⑦ 真机验证
为什么这个顺序?
- 从下往上:底层不通,上层白写;
- 每步都构建:早发现早修(ch22 讲的"一次只改一个变量");
- ② 先用空实现:先验证"构建链路通",再写逻辑。
验收标准(23.9.1 的五条):
- 打开看到界面;
- 点"选模型"能拉起选择器;
- 选完能加载(按钮变化);
- 发送后自己的消息立刻出现;
- 回复一个字一个字蹦出来。
遇到问题:查 23.9.2 / 23.11 的表格,按"对应章节"去复习。
练习 2:加回 UTF-8 缓存
如果你在练习 1 中遇到了"中文乱码方块",请把 ch16.5 的 UTF-8 缓存加回来。
解题思路提示
需要三样东西:
① 一个"待发缓存"(cached_token_chars);
② is_valid_utf8 函数;
③ 合法才返回、不合法返回空串。
参考答案要点
改动点(对照 ch16.5 的真实实现):
① 加一个静态缓存 (mini-ai_chat.cpp):
cpp
static std::string g_cache; // 可能是半个字符的字节
② 加 is_valid_utf8(照抄 ch16.5 的 33 行实现)。
③ 改 generate 的返回部分:
cpp
g_cache += token_text;
if (is_valid_utf8(g_cache.c_str())) {
jstring s = env->NewStringUTF(g_cache.c_str());
g_cache.clear();
return s;
} else {
return env->NewStringUTF(""); // 还没凑齐,先不发
}
④ Kotlin 侧要配合(ch16.7 的约定):
kotlin
val piece = generateNextToken() ?: break
if (piece.isNotEmpty()) { /* 才追加 */ } // ← 空串不发
验证:
- 用中文提问,观察是否还有乱码方块;
- 在
is_valid_utf8的两个分支加日志,观察是否出现"PENDING"(说明确实拦截过半个字符); - 如果从来没见过 PENDING,说明你的模型 token 恰好对齐字符边界------那也不影响。
这个练习的价值:
它是第一个"加回功能" ,让你体验"最小版 → 完整版"的过程。
而且它很小(几十行),风险低------适合作为第一次加回尝试。
练习 3:加回多轮对话
这是最有挑战性的一个(ch17 的核心)。请思考需要改哪些地方。
解题思路提示
回想 ch17:
① 需要保存"消息历史"(chat_msgs);
② 需要"渲染整段历史"(chat_render);
③ 需要求"增量"(g_kv_text 前缀比较)。
最小版现在的做法是"每次都重喂完整 prompt"------那其实已经能多轮了?
参考答案要点
这里有个容易忽略的点 :最小版"每次都重喂完整 prompt"其实已经支持多轮 ------
只要你把历史消息拼进 prompt 里。
但问题在于:
| 做法 | 每轮成本 | 支持多轮 |
|---|---|---|
| 每次重喂完整历史(最小版现状) | O(历史长度) --- 每轮都重新 prefill | ✅ |
| 增量拼接(完整工程) | O(新增长度) --- 只喂增量 | ✅ |
所以练习 3 的真正目标是"从重喂改成增量",为了性能。
需要的改动(对照 ch17):
① 维护历史(C++ 侧):
cpp
static std::vector<common_chat_msg> g_msgs;
② 用模板渲染整段 (chat_render,ch17.2)。
③ 求增量(关键,ch17.4):
cpp
static std::string g_kv_text; // KV 里已存在的文本
// 增量 = target 去掉 g_kv_text 前缀
if (target.size() >= g_kv_text.size() &&
target.compare(0, g_kv_text.size(), g_kv_text) == 0) {
increment = target.substr(g_kv_text.size());
} else {
// 对不上 → 清 KV 重放
reset_kv();
increment = target;
}
g_kv_text = target;
④ 位置要跟着走 (ch16/ch18):
每轮解码增量后,g_pos += 增量 token 数。
⑤ 千万不要用"按长度切片"(ch17 的 24 字节事故的根源)。
验证:
- 第 2 轮问题能不能正常回答(这是关键,ch17 的 bug 就在这里暴露);
- 看日志:第 2 轮的增量应该只包含新消息(而不是整段历史);
- 对比性能:第 3 轮、第 4 轮的首字延迟应该不随历史增长而暴涨。
这个练习的价值:
它让你亲手经历 ch17 那个事故的上下文 ------
你会理解为什么"求增量"这件事这么容易错,
以及为什么本工程要用"前缀比较"而不是"长度切片"。
练习 4:给最小版画一张架构图
请凭记忆(不看 23.3)画出你实现的最小版架构,
标注数据流向(用户点击 → ... → 屏幕更新)。
解题思路提示
参考 ch01 的"一次提问在五层里怎么走"。
最小版只有四块,但数据流是一样的。
参考答案要点
用户点"发送"
│
▼
① MiniMainActivity(按钮 onClick)
│ engine.send(input)
▼
② MiniInferenceEngine.send
│ ├─ messages.add(用户消息) → Compose 观察到 → 界面更新(用户看到自己的消息)
│ └─ scope.launch(Dispatchers.Default) {
│ while(true) {
│ piece = generateNextToken() ← ③ JNI
│ if (piece == null) break
│ messages[i] = reply.copy(...) → 触发重组 → 界面逐字更新
│ }
│ }
▼
③ JNI 边界(mini-ai_chat.cpp)
│ ├─ 第一次:prefill(tokenize + 分批 llama_decode)
│ └─ 之后每次:采样 → decode → 转文本
▼
④ llama.cpp / ggml
(真正的矩阵运算,跑在 CPU 上)
关键标注 (这些是最小版和完整版共有的结构):
- 状态在 ② 里 (对应完整工程的
MainScreenState); - UI 只渲染(①);
- 跨语言边界在 ③(对应 ch13);
- 每一层的概念都和完整工程一致。
差异:
- 完整工程把 ② 拆成了
MainScreenState(状态)+InferenceEngineImpl(引擎); - 完整工程在 ③ 后面还有 chat template / KV 管理 / 移窗 / 采样参数等;
- 完整工程有多个页面,最小版只有一个。
这张图的意义:
它证明"最小版和完整工程是同构的"。
你做完最小版,就理解了完整工程的骨架 ;
剩下的只是"在每个位置上加更多东西"。
这就是 capstone 的目的:用最小的代价,获得对整个系统的理解。
练习 5:设计你的"加回路线图"
假设你要把最小版做到接近完整工程的水平,请排出你的加回顺序,并说明理由。
解题思路提示
参考 23.10 的顺序,但按你自己的判断 调整。
想清楚:什么是"最影响体验的"?什么是"耦合最紧的"?
参考答案要点
参考路线图(可以和 23.10 不同,关键是理由):
| 阶段 | 加什么 | 理由 |
|---|---|---|
| 一、修体验 | UTF-8 缓存 → 多轮历史 → 移窗 | 这三者都在同一条数据流上,且直接决定"能不能正常聊天" |
| 二、加控制 | 取消生成 → 采样参数 | 用户会想"停下来"和"调参数" |
| 三、加持久化 | DataStore → 多会话 | 从"一次性对话"变成"能管理历史" |
| 四、加旁支 | TTS → Markdown → 导出/分享 | 独立功能,互不影响 |
| 五、做工程化 | F10 释放 → 测试 → R8/体积 | 发布前的事 |
为什么这样排?
① 先修体验:
- UTF-8 乱码、不能多轮、聊久了崩------这三个是"能不能用"的问题;
- 它们都在"生成 → 显示"这条主链路上,改动会互相影响,所以一起考虑。
② 再加控制:
- 取消和参数是"用户主动性",比持久化更"刚需"(不然只能干等);
- 而且取消会影响主链路(ch08 讲要加检查点),早加早理顺。
③ 再持久化:
- 持久化是"数据层",相对独立;
- 但多会话会影响 UI 结构(要加抽屉,ch07),所以放在 UI 稳定之后。
④ 旁支最后:
- TTS/Markdown/导出都不影响主链路,随时能加;
- 放最后是为了不干扰主链路的调试。
⑤ 工程化收尾:
- F10 释放、R8 这些是"发布前"的事,早期做了反而干扰调试。
通用原则(从 ch22 学来的):
先加"耦合紧的",后加"独立的";先加"不改接口的",后加"要改接口的"。
因为"改接口"会波及面很广------放在后期,前面已经稳定了,改动影响可控。
反例:如果先加"多会话"(要改 UI 结构 + 数据模型),
后面每加一个功能都要考虑"多会话下怎么处理"------复杂度指数上升。
这个练习的价值:
它训练你的工程规划能力 :
面对一堆功能,怎么排序,才能让每一步都建立在前一步的稳定基础上。
这已经不是"写代码"的能力,而是"做工程 "的能力------
也是本书想给你的最终能力。
23.15 自测题(附答案)
这是全书最后的自测 。前 4 题"判断"、3 题"选择"考的是认知 ,
后 3 题"简答"考的是动手计划------后者更重要。
一、判断对错
- 做最小版应该先把所有功能实现完,再一起测。
- 最小版可以直接"从完整工程删代码"得到,不用重写。
- 最小版和完整工程的结构完全不同。
- 加回功能应该先加"与主链路耦合最紧的"。
二、选择
- 最小版保留的三件事是?
A. 选模型 / 加载 / 流式对话
B. 多会话 / 持久化 / TTS C. 翻译 / 设置 / 导出 D. 全部 - 最小版需要几个 JNI 方法?
A. 1 B. 4 C. 11 D. 20 - 组装(动手实现)应该从哪开始 ?
A. 界面(最直观) B. 底层(CMake + C++) C. Kotlin 引擎 D. 随便
三、简答
- 为什么"读懂 "和"能做出来"之间隔着一道沟?
- 最小版为什么可以不给 列表
key?什么时候必须给? - "加回功能的顺序"遵循哪两个原则?
答案与解析
一、判断
- ❌ 错 。一次加十个功能再一起测,出问题时根本不知道是哪个引起的 。
正确做法:每加一个功能就回归一次(23.10、ch22 讲的"一次只改一个变量")。 - ❌ 错 (而且是个好问题)。"删代码"看似省事,实则更难 :
完整工程的代码互相纠缠(删 TTS 会牵连状态层、UI、生命周期......),
删到最后你不确定剩下的是不是自洽 。
从空目录按骨架重写反而更清晰------因为每一行都是你自己决定要不要的(23.1.2)。 - ❌ 错 。是同构的 !最小版的四块(Activity / 引擎 / JNI / CMake)
对应完整工程的五层架构,数据流一模一样 (23.3)。
差别只在"厚度"------每个位置的东西更少。 - ✅ 对 。先修最影响体验的 (乱码 → 不能多轮 → 会崩),
再加独立的旁支(TTS / 导出随时能加)(23.10)。
二、选择
- A 。只保留主链路:选模型 → 加载 → 流式对话(23.2)。
- B(4 个) :
init/load(合并了prepare)/generateNextToken
(合并了processUserPrompt+generateNextToken)/unload(合并了shutdown)(23.4.2)。 - B(底层) 。因为底层不通,上层白写 (23 练习 1):
先让:lib编过(CMake 路径最容易错)→ 再写 C++ → 再 Kotlin → 最后界面。
三、简答(要点)
- 两种能力的输入和动作都不同 (23.1.1):
- 读懂 :输入是"已有代码",动作是理解、trace;
- 能做出来 :输入是"空白目录 + 一个需求 ",动作是决策、权衡、组织 ------
要自己决定"写哪些文件、接口怎么定、调用顺序如何"。
所以读懂的人面对空白目录会脑子一片空白 。
弥合办法就是"最小复刻":把结构骨架抽出来,亲手搭一遍。
- 因为最小版只有一个固定列表、不增删重排 ------
不给 key 功能上不会出错。
但一旦要做"多会话切换"或"消息插入/删除" ,就必须给 key,
否则会状态串位(23.6.3、ch07.2.3)。
这正是"最小版"和"可用产品"的边界。 - 两个原则(23.10):
① 先修最影响体验的 (乱码 → 不能多轮 → 会崩------这些是"能不能用"的问题);
② 先加"与主链路耦合紧的",后加"独立的" ------
因为独立功能随时能加,而耦合紧的放后期会让每一步都和它打架。
反例:先加"多会话"(要改 UI 结构 + 数据模型),
后面每加一个功能都要考虑"多会话下怎么处理"------复杂度指数上升。
评分建议 :如果你能答好第 8 题、并真的做完练习 1(把最小版跑起来),
这本书的 capstone 你就完成了。
结语:回到最开始
你已经走完了全程
回想 ch01 那个问题:"这个 App 是怎么做出来的?"
现在你能回答了。而且不只"能说"------
| 你学会的 | 出处 |
|---|---|
| 看懂一个真实的 Android + NDK 工程 | ch01--ch05 |
| 用 Compose 写界面,用 ViewModel 管状态 | ch06--ch09 |
| 用协程/Flow 处理流式数据,正确取消与释放 | ch08、ch10 |
| 用 DataStore 持久化,接入系统能力 | ch11、ch12 |
| 看懂 JNI,能自己加 native 方法 | ch13 |
| 读懂 GGUF,会流式解析 | ch14 |
| 理解模型加载、KV cache、推理主循环 | ch15、ch16 |
| 理解 chat template 与上下文移窗 | ch17、ch18 |
| 会调采样参数,并知道怎么贯通到 native | ch19 |
| 能配置构建、出包、校验、发版 | ch20、ch21 |
| 有一套排查问题的方法,而不是靠猜 | ch22 |
| 能从零做一个最小可用版本 | ch23 |
三个最重要的"元能力"
比知识点更重要的,是这三样:
① 分层思维
遇到任何复杂系统,先问"它分几层"。本工程的五层(应用 / 框架 / JNI / 引擎 / 工具链)
让你在 6000 行 Kotlin + 79 万行 C++ 里不迷路。
② 可验证的习惯
- 发版后比
.so的 MD5(ch21)而不是"我觉得进了"; - 修了 bug 做回归验证(ch22)而不是"应该好了";
- 写代码时想着**"这个结论我怎么证明"**。
③ 拆解与裁剪的能力
- 看不懂就裁剪(ch23 的最小版);
- 找不到问题就二分(ch22 的隔离变量);
- 学不动就只学用到的(ch03 的 Kotlin 子集)。
最后一句
这本书讲的是一个具体的 App。但里面那些方法 ------
分层、可验证、裁剪、诊断 ------适用于你做任何一个软件项目。
技术会过时,方法不会。
祝你在下一个项目里,依然能说出那句:
"我知道它为什么这样,也知道它错了该怎么查。"
全书完。
如果这本书帮到了你,欢迎回头看看
book/code/里的配套代码,也欢迎在真实工程里动手改一改------最好的学习永远发生在你亲手改坏又修好的时候。