ch23 综合复刻:从零做一个最小可用版本(capstone)

学习目标

读完本章,你应当能够:

  1. 说出"读懂代码"和"能独立做出来"之间的差距在哪,以及怎么弥合;
  2. 掌握"需求裁剪":先把需求砍到最小,打通后再逐步加回;
  3. 看懂 book/code/ch23/ 的最小工程骨架(文件路径 / 关键函数签名 / 调用顺序);
  4. 独立把骨架从"示意"补齐成"真实可构建的 App"(对照本书各章的真实工程文件);
  5. 在真机上完成一次完整对话------这是本书的通关验证;
  6. 知道该怎么按顺序把砍掉的功能加回来(每一步对应哪一章)。

前置 :全书。特别是 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 namespaceapplicationIdcompileSdk 37minSdk 33abiFilters.add("arm64-v8a")
lib/build.gradle.kts ndkVersionexternalNativeBuild { 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):

  1. CMake 配置错误?→ 看 LLAMA_SRC 路径(最常见);
  2. C++ 编译错误?→ 用 clang++ -fsyntax-only 快速验证(ch13);
  3. JNI 找不到方法?→ 核对 Java_<包>_类_方法 拼写(ch13);
  4. 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 发布前再考虑

📌 这个顺序不是随便定的,它遵循两个原则:

  1. 先修最影响体验的(乱码 → 不能多轮 → 会崩);
  2. 先加"与主链路耦合紧的" (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 的八步,真的把最小版做出来。
解题思路提示

  1. 不要从零建工程------以本工程为模板裁剪(省掉 ch02/ch20 的所有坑);
  2. 先让 :lib 编过(C++ 是最难的部分,先搞定它);
  3. 再加 Kotlin 引擎,最后写界面;
  4. 每完成一步就构建一次,不要攒着。

参考答案要点

推荐的实施顺序(从下往上)

复制代码
① 建工程骨架(以本工程为模板)
② 写 CMakeLists + 一个"空的" mini-ai_chat.cpp(只 include 头文件)
    → gradlew :lib:externalNativeBuildRelease   ← 先确认能编过
③ 实现 init + load(JNI 名字要对,见骨架)
    → 构建 + 装到真机,在 logcat 里看到加载成功的日志
④ 实现 generate(先只生成一个 token 试试)
    → 确认能拿到一个 token
⑤ 补完整生成循环(prefill + 逐 token)
⑥ 写 Kotlin 引擎 + 界面
⑦ 真机验证

为什么这个顺序?

  • 从下往上:底层不通,上层白写;
  • 每步都构建:早发现早修(ch22 讲的"一次只改一个变量");
  • ② 先用空实现:先验证"构建链路通",再写逻辑。

验收标准(23.9.1 的五条):

  1. 打开看到界面;
  2. 点"选模型"能拉起选择器;
  3. 选完能加载(按钮变化);
  4. 发送后自己的消息立刻出现;
  5. 回复一个字一个字蹦出来

遇到问题:查 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 上)

关键标注 (这些是最小版和完整版共有的结构):

  1. 状态在 ② 里 (对应完整工程的 MainScreenState);
  2. UI 只渲染(①);
  3. 跨语言边界在 ③(对应 ch13);
  4. 每一层的概念都和完整工程一致

差异

  • 完整工程把 ② 拆成了 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 题"简答"考的是动手计划------后者更重要。

一、判断对错

  1. 做最小版应该先把所有功能实现完,再一起测
  2. 最小版可以直接"从完整工程删代码"得到,不用重写。
  3. 最小版和完整工程的结构完全不同
  4. 加回功能应该先加"与主链路耦合最紧的"

二、选择

  1. 最小版保留的三件事是?
    A. 选模型 / 加载 / 流式对话
    B. 多会话 / 持久化 / TTS C. 翻译 / 设置 / 导出 D. 全部
  2. 最小版需要几个 JNI 方法?
    A. 1 B. 4 C. 11 D. 20
  3. 组装(动手实现)应该从哪开始
    A. 界面(最直观) B. 底层(CMake + C++) C. Kotlin 引擎 D. 随便

三、简答

  1. 为什么"读懂 "和"能做出来"之间隔着一道沟?
  2. 最小版为什么可以不给 列表 key?什么时候必须给
  3. "加回功能的顺序"遵循哪两个原则

答案与解析

一、判断

  1. 。一次加十个功能再一起测,出问题时根本不知道是哪个引起的
    正确做法:每加一个功能就回归一次(23.10、ch22 讲的"一次只改一个变量")。
  2. (而且是个好问题)。"删代码"看似省事,实则更难
    完整工程的代码互相纠缠(删 TTS 会牵连状态层、UI、生命周期......),
    删到最后你不确定剩下的是不是自洽
    从空目录按骨架重写反而更清晰------因为每一行都是你自己决定要不要的(23.1.2)。
  3. 是同构的 !最小版的四块(Activity / 引擎 / JNI / CMake)
    对应完整工程的五层架构,数据流一模一样 (23.3)。
    差别只在"厚度"------每个位置的东西更少。
  4. 先修最影响体验的 (乱码 → 不能多轮 → 会崩),
    再加独立的旁支(TTS / 导出随时能加)(23.10)。

二、选择

  1. A 。只保留主链路:选模型 → 加载 → 流式对话(23.2)。
  2. B(4 个)init / load(合并了 prepare)/ generateNextToken
    (合并了 processUserPrompt + generateNextToken)/ unload(合并了 shutdown)(23.4.2)。
  3. B(底层) 。因为底层不通,上层白写 (23 练习 1):
    先让 :lib 编过(CMake 路径最容易错)→ 再写 C++ → 再 Kotlin → 最后界面。

三、简答(要点)

  1. 两种能力的输入和动作都不同 (23.1.1):
    • 读懂 :输入是"已有代码",动作是理解、trace
    • 能做出来 :输入是"空白目录 + 一个需求 ",动作是决策、权衡、组织 ------
      要自己决定"写哪些文件、接口怎么定、调用顺序如何"。
      所以读懂的人面对空白目录会脑子一片空白
      弥合办法就是"最小复刻":把结构骨架抽出来,亲手搭一遍。
  2. 因为最小版只有一个固定列表、不增删重排 ------
    不给 key 功能上不会出错。
    一旦要做"多会话切换"或"消息插入/删除" ,就必须给 key,
    否则会状态串位(23.6.3、ch07.2.3)。
    这正是"最小版"和"可用产品"的边界。
  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/ 里的配套代码,

也欢迎在真实工程里动手改一改------最好的学习永远发生在你亲手改坏又修好的时候。

相关推荐
ai2work1 天前
ch21 签名、校验与发版
kotlin
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
Android打工仔1 天前
Kotlin 协程源码解析:协程是如何切换线程的?
android·kotlin
ai2work1 天前
附录 D 速查索引与阅读指南
kotlin
ai2work1 天前
ch22 诊断方法论:三个真实事故的根因分析
kotlin
Kapaseker1 天前
你有搞明白 Volatile 什么意思吗?
android·kotlin
alexhilton2 天前
藏在设备上的秘密,终究藏不住
android·kotlin·android jetpack
ai2work2 天前
ch15 加载模型与初始化上下文
kotlin
hai_android2 天前
Kotlin / Android 常用函数使用示例手册
android·java·kotlin