
简介这份资源是一套面向 Android 开发者与移动端机器学习入门者的 TensorFlow Lite 图像分类实战 Demo解决的是“模型在手机上如何训练推理、工程如何搭建、端侧识别如何用起来”的问题。压缩包共 110 个文件约 3.76MB包含 64 个 xml 布局与配置、4 个 Java 源码文件、3 个 gradle 构建脚本以及 1 个 tflite 模型文件另有 png 图标素材、bin 与 lock 等构建缓存和 properties 配置工程结构完整可直接导入 Android Studio 编译运行。目前已有 6821 人学习下载属于较受关注的端侧推理入门样例。读者可从中了解 TFLite 模型的加载与调用流程、相机或图片输入的预处理、分类结果的输出与展示以及 Gradle 依赖配置和资源文件组织方式适合作为课程实验、毕业设计或移动端 AI 功能原型的起步参考也便于对照排查模型路径、依赖版本等常见问题。1. 一张图进手机到出结果这个 TFLite 图像分类 Demo 到底解决了什么模型在训练机上跑出 0.92 的准确率塞进 Android 手机却吐出一堆看不懂的概率数组——这种落差我见过不止一次。这个 TensorFlow Lite 图像分类 Demo 处理的正是中间那段很少有人愿意写文档的路把训练好的模型压进 APK 的 assets 目录用 TFLite 解释器在端侧完成一次前向计算再把输出张量翻译成标签和置信度。它面向两类人一类是手里已经有模型、想搬到手机上的算法同学另一类是接需求时要在一周内做出识别功能、又不想引入云端接口的 Android 开发。工程规模不大但相机采集、图像归一化、线程调度、结果排序这几块凑齐了端侧推理的完整闭环是少数能直接照着改的骨架工程。2. 架构与选型TFLite 在端侧推理链路里站哪个位置2.1 训练侧与端侧的分工边界端侧推理的第一条纪律是手机不做训练也不做特征工程它只负责把一张图变成一维数组。TFLite 在这里承担的是解释器角色——读取.tflite文件里的算子图和权重按顺序在 CPU 或 GPU 上执行输入输出都是张量Tensor中间过程对上层完全封闭某种意义上就是个黑匣子。所以架构上真正需要设计的是这个黑匣子的两侧进它之前怎么把 Bitmap 变成它认识的形状出它之后怎么把概率数组变成人能看懂的结果。导出环节的约定必须在动手写代码之前钉死。一张 224×224 的 RGB 图进入模型前的形状是[1, 224, 224, 3]其中的 1 是 batch端侧一次只处理一张。输出形状常见两种ImageNet 1000 类加一个背景类是[1, 1001]纯自定义 N 类是[1, N]。这两个数字如果不提前确认后面所有代码都只能靠猜而猜错的代价是排查时同时怀疑模型、预处理和标签映射三件事。量化格式直接决定预处理代码长什么样这个在项目开工前就得定下来。模型格式文件体积量级输入数据类型预处理写法精度损失float32十几 MBFLOAT32像素除以 127.5 再减 1无float16数 MBFLOAT32同上极小int8 全整型几 MBUINT8或INT8直接写 0255 的原始字节视任务而定动态范围量化几 MBFLOAT32同 float32较小我一般先用 float32 跑通整条链路确认标签对得上再换 int8 量化版本对比准确率。反过来做的话一旦结果不对你会同时怀疑量化和预处理两件事排查成本翻倍。这个顺序看着笨但能省掉一整天的瞎猜。2.2 工程目录与模块职责这套 Demo 的分层很朴素没有引入 MVVM 或者依赖注入好处是任何一段都能单独拎出来看。目录大致长这样app/src/main/ ├── assets/ │ ├── mobilenet_v1_1.0_224.tflite # 模型本体 │ └── labels.txt # 每行一个标签顺序与输出索引严格对应 ├── java/com/example/classifier/ │ ├── MainActivity.java # 入口处理相册选图和预览 │ ├── CameraActivity.java # 相机预览 逐帧送入分析器 │ ├── ImageClassifier.java # 模型加载、预处理、推理、后处理 │ ├── ImageProcessor.java # Bitmap 裁剪缩放的辅助类 │ └── Recognition.java # 结果实体标签 置信度 索引 └── res/layout/activity_main.xmlImageClassifier是整份工程的重心它同时承担了四件事加载模型、把 Bitmap 塞进 ByteBuffer、调用interpreter.run()、对输出做 Top-K 排序。这种把四段逻辑塞进一个类里的写法在 Demo 阶段没问题真要上生产我建议至少把预处理拆成独立的ImageProcessor因为换模型时改的往往只有归一化那几行而推理流程是不动的。Recognition这个实体看着可有可无但它在两处帮了忙一是 Top-K 排序需要一个可比较的对象二是 UI 层只消费字符串和浮点数不需要知道模型的存在。这种隔离做得越早后面把分类换成检测、换成关键点改动面就越小。2.3 构建依赖与关键配置Gradle 这块有两处必须写对写错了就是直接崩不是性能问题。android { // 关键禁止 aapt 压缩 tflite 文件 // 压缩后 AssetManager.openFd() 会直接抛异常模型只能整包读进内存 aaptOptions { noCompress tflite } } dependencies { implementation org.tensorflow:tensorflow-lite:2.14.0 implementation org.tensorflow:tensorflow-lite-gpu:2.14.0 implementation org.tensorflow:tensorflow-lite-support:0.4.4 }noCompress tflite是这套工程里最容易漏的一行。openFd()要求 assets 里的文件能以文件描述符方式打开如果被 aapt 压缩过它会抛This file can not be opened as a file descriptor; it is probably compressed。另一种做法是用AssetManager.open()把整个模型读成字节数组再喂给 Interpreter但这样模型会在 Java 堆里多留一份副本几十 MB 的模型在低端机上很容易触发 GC 抖动。用openFd()FileChannel.map()走内存映射模型页由系统按需加载对内存更友好。GPU Delegate 那一行依赖看情况加。它在中端以上的机型上能把单帧推理耗时压下来一截但老设备或者某些驱动上会直接返回错误的结果或者首次初始化就崩。Demo 里通常留一个开关默认关闭需要时再打开对比耗时比一上来就用 GPU 稳妥得多。2.4 数据流一次识别经过的六次形态转换把整条链路摊平看一张图从相机到屏幕要经过六次形态变化每一步都有对应的出错点。阶段数据形态常见出错点相机采集ImageProxy/ YUV_420_888没做旋转图是横的转 BitmapBitmapARGB_8888内存没释放连续帧累积裁剪缩放224×224 的Bitmap拉伸变形长宽比丢了转张量直接ByteBuffer字节序写反float 读出来是乱的推理float[1][N]输出形状和标签数对不上后处理Top-K 列表标签索引差一位整体偏移这张表我建议贴在调试时对着看。大多数识别不准的问题落点都在第三行和第四行——图片被拉伸变形或者归一化方式跟训练时不一致。模型本身很少是元凶它只是忠实地把输入的错误放大出来。3. 跑通第一张图从 Gradle 配置到 assets 模型加载的完整步骤3.1 环境准备与工程导入Android Studio 用任意近两年的稳定版都行SDK 上minSdkVersion设 21 足够TFLite 的 Java API 本身要求不高如果打算开 GPU Delegate机型侧的 OpenGL ES 版本要够这个只能靠真机测。工程不需要 NDK也不需要 CMake除非你要自己编译自定义算子那种情况另说。导入之后先别急着跑把三件事确认一遍local.properties里的 SDK 路径、Gradle 版本与 AGP 版本是否匹配、aaptOptions那行有没有被模板覆盖掉。我第一次拉这类工程时就是因为 AGP 版本太新aaptOptions语法变了导致编译报错折腾了半天才反应过来是构建脚本的问题而不是代码问题。编译命令很简单# 只编译 debug 包确认构建链路没问题 ./gradlew :app:assembleDebug # 装机并直接启动 ./gradlew :app:installDebug如果assembleDebug阶段就失败问题基本在依赖解析或者构建脚本能编译但装上一启动就闪退才轮到看 Logcat。这个区分能省不少时间。3.2 模型与标签文件的放置约定assets目录下的模型文件名没有强制规范但labels.txt的顺序是硬约定第 0 行对应输出张量的索引 0第 1 行对应索引 1依此类推中间不能有空行或者注释行。这份文件错一位整个识别结果的对应关系就全乱了而且乱得很有迷惑性——置信度依然很高只是标签全错。读取标签的代码不复杂但要注意编码和空白字符private ListString loadLabels(Context context, String fileName) throws IOException { ListString labels new ArrayList(); // 用 UTF-8 显式指定编码避免部分机型默认编码不同导致中文标签乱码 try (BufferedReader reader new BufferedReader( new InputStreamReader(context.getAssets().open(fileName), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { String trimmed line.trim(); // 跳过空行但不跳过任何有效标签 if (!trimmed.isEmpty()) { labels.add(trimmed); } } } return labels; }参数上只有一个坑值得说context.getAssets()拿到的 AssetManager 是全局共享的读完之后不用手动关但InputStream一定要用 try-with-resources 关掉否则连续多次加载比如切换模型会泄漏文件句柄。标签文件一般很小几 KB但习惯要养成。模型文件建议连同一个版本号一起命名比如mobilenet_v1_224_v2.tflite。换来换去的时候没有版本号的model.tflite是灾难你会分不清手机上跑的是哪一版。3.3 首次运行的调用链验证跑通的最小闭环只有三行调用// 1. 初始化内部完成模型加载和输入形状探测 ImageClassifier classifier new ImageClassifier(context, mobilenet_v1_224_v2.tflite, labels.txt); // 2. 传一张 Bitmap 进去内部做缩放、归一化、推理、排序 ListRecognition results classifier.classify(bitmap); // 3. 取第一条展示 Recognition top1 results.isEmpty() ? null : results.get(0);classify()内部是同步的这一点很重要。Demo 里通常在相册选图之后调用一帧几百毫秒用户感觉不到卡顿但一旦接到相机预览上逐帧调用主线程立刻就被堵死预览会一顿一顿的。这个转换点是从 Demo 走向可用产品的分水岭后面第 6 章会细说。调用之前建议先打一行日志把模型的实际输入形状和数据类型打出来int[] shape interpreter.getInputTensor(0).shape(); DataType type interpreter.getInputTensor(0).dataType(); Log.d(TFLITE, input shape Arrays.toString(shape) , type type);shape一般是[1, 224, 224, 3]type是FLOAT32或者UINT8。这两个值决定了预处理代码怎么写硬编码 224 而不读它是换模型时最常见的翻车原因。3.4 结果核对怎么确认跑对了验证不能只看有输出得看输出合不合理。我的做法是拿一张内容明确的图——比如一只猫、一辆车——跑一遍然后人工检查 top-1 标签是不是对的top-3 里有没有合理的近义类别。如果 top-1 是某个毫不相干的类但置信度 0.99那基本可以断定是归一化或者标签索引出了问题。再补一步把原始的输出数组前几位打出来float[][] output new float[1][labels.size()]; interpreter.run(imgData, output); for (int i 0; i 5; i) { Log.d(TFLITE, idx i label labels.get(i) score output[0][i]); }如果这五个分数加起来离 1 很远说明模型输出没有经过 softmax或者你读错了张量下标如果分数全是 0 或全是 NaN说明输入张量根本没填进去大概率是 ByteBuffer 的 position 没复位。这些现象比结果不准具体得多定位起来快。4. 核心代码拆解ImageClassifier 里从 Bitmap 到 Top-K 的四段实现4.1 Interpreter 初始化线程数与 Delegate初始化阶段做两件事把模型映射进内存配置解释器参数。public ImageClassifier(Context context, String modelFile, String labelFile) throws IOException { this.labels loadLabels(context, labelFile); Interpreter.Options options new Interpreter.Options(); // 线程数一般取 24超过物理大核数量反而会因为调度开销变慢 options.setNumThreads(4); // XNNPACK 对常见卷积有优化浮点模型建议开 options.setUseXNNPACK(true); if (useGpuDelegate) { gpuDelegate new GpuDelegate(); options.addDelegate(gpuDelegate); } interpreter new Interpreter(loadModelFile(context, modelFile), options); // 从模型里读输入尺寸而不是硬编码 224 int[] shape interpreter.getInputTensor(0).shape(); imageSize shape[1]; }setNumThreads(4)这个值不要拍脑袋往大了写。推理的并行粒度有限线程数超过设备的大核数量之后线程切换的开销会盖过并行收益实测反而更慢。我一般在 2 和 4 之间各测一次取快的那个。模型加载走内存映射private MappedByteBuffer loadModelFile(Context context, String name) throws IOException { AssetFileDescriptor afd context.getAssets().openFd(name); try (FileInputStream fis new FileInputStream(afd.getFileDescriptor())) { FileChannel channel fis.getChannel(); // 映射区间必须用 startOffset declaredLength不能从 0 开始 return channel.map(FileChannel.MapMode.READ_ONLY, afd.getStartOffset(), afd.getDeclaredLength()); } }startOffset和declaredLength两个参数不能省。assets 里的多个文件在 APK 内部是连续存放的如果从 0 开始映射你拿到的可能是前一个文件的尾部拼接上模型的前半段Interpreter 会在解析阶段直接报ByteBuffer is not a valid flatbuffer model。这个报错看着吓人原因其实很单纯。4.2 预处理Bitmap 到 ByteBuffer 的字节序陷阱这一段是整份代码里最容易写错、也最难从报错信息里看出问题的地方。private ByteBuffer convertBitmapToByteBuffer(Bitmap bitmap) { // 复用 ByteBuffer避免每帧都分配几十 MB 触发 GC if (imgData null) { imgData ByteBuffer.allocateDirect(4 * imageSize * imageSize * 3); // 必须设成本机字节序否则 putFloat 写进去的字节顺序是反的 imgData.order(ByteOrder.nativeOrder()); } imgData.rewind(); Bitmap resized Bitmap.createScaledBitmap(bitmap, imageSize, imageSize, true); int[] pixels new int[imageSize * imageSize]; resized.getPixels(pixels, 0, imageSize, 0, 0, imageSize, imageSize); int offset 0; for (int i 0; i imageSize; i) { for (int j 0; j imageSize; j) { int val pixels[offset]; // ARGB_8888 里 R 在高 8 位G 在中间B 在低 8 位 float r (((val 16) 0xFF) - 127.5f) / 127.5f; float g (((val 8) 0xFF) - 127.5f) / 127.5f; float b ((val 0xFF) - 127.5f) / 127.5f; imgData.putFloat(r); imgData.putFloat(g); imgData.putFloat(b); } } imgData.rewind(); // 复位让 Interpreter 从头读 return imgData; }三个参数值得单独说。127.5f这个均值方差是 MobileNet 系列的标准配置换成别的模型可能要用01的简单归一化或者 ImageNet 的均值方差具体得看训练脚本不能想当然。ByteOrder.nativeOrder()不加的话float 的四个字节会以大端顺序写进去而 ARM 是小端读出来的数值完全是乱的——但程序不会崩只会输出一堆无意义的概率这才是最难受的那种 bug。最后的rewind()漏掉的话Interpreter 会从 buffer 的当前 position 开始读读到的是空的尾部表现是输出恒定值。这个坑我踩过一次对着模型怀疑了两小时最后发现是少了一行复位。4.3 推理执行与张量形状核对推理本身只有一行但周边的形状核对不能省。public ListRecognition classify(Bitmap bitmap) { ByteBuffer input convertBitmapToByteBuffer(bitmap); // 输出数组的大小必须等于标签数量否则 run() 会抛异常 float[][] output new float[1][labels.size()]; interpreter.run(input, output); return postProcess(output[0]); }run()是同步阻塞的返回时结果已经写进output。如果模型有多个输出这里要传对应的数组个数少传一个就会报Output error: number of outputs mismatch。对于分类模型只有一个输出形状是[1, N]用二维数组接住是为了保持 batch 维度。有个细节容易忽略interpreter.run()不做输入校验你给的 ByteBuffer 大小如果跟模型期望的不一致它会截断或者读取越界表现是结果乱或者直接 native crash。所以在调试阶段我习惯加一句断言把 buffer 容量和imageSize * imageSize * 3 * 4比一下早发现早省事。4.4 Top-K 后处理与标签映射输出是一维概率数组长度等于类别数后处理要做的是排序取前 K 个并且过滤掉低于阈值的。private ListRecognition postProcess(float[] scores) { PriorityQueueRecognition pq new PriorityQueue( 5, (a, b) - Float.compare(b.getConfidence(), a.getConfidence())); for (int i 0; i scores.length; i) { // 阈值默认 0.1低于它的结果对用户没有意义还会挤占榜位 if (scores[i] 0.1f) continue; // 索引越界保护标签数少于输出数时跳过避免崩溃 if (i labels.size()) break; pq.add(new Recognition(i, labels.get(i), scores[i])); } ListRecognition result new ArrayList(); while (!pq.isEmpty() result.size() 3) { result.add(pq.poll()); } return result; }这里有个真实存在的偏差要提前知道ImageNet 的浮点模型输出往往是 1001 维多出来的那一维是背景类而labels.txt通常只有 1000 行。两者差一位如果你不做保护最后几类的标签会整体错位。稳妥的做法是在导出模型时确认清楚或者在读取标签时判断数量并打一条警告日志让问题显式暴露出来而不是默默错下去。5. 排查笔记端侧推理最容易犯的五个错误5.1 输出全是同一类置信度还接近 1.0现象不管喂什么图top-1 永远是同一个标签分数稳定在 0.98 以上看起来特别自信。原因输入张量根本没被正确填充或者填充方式与模型的dataType不匹配。最常见的是模型已经是 int8 量化版本代码里还在用putFloat()往里写四字节浮点导致 Interpreter 读到的是一堆错位的字节另一种是imgData.rewind()漏了buffer 的 position 停在末尾。解决先打印interpreter.getInputTensor(0).dataType()是UINT8就改成put((byte) value)是FLOAT32才用putFloat。然后把 buffer 的capacity()和模型期望的字节数对一遍。这类问题没有玄学成分全是字节层面的对不上。5.2 一进相机就崩日志指向 openFd现象应用启动没问题点开相机或者第一次识别瞬间崩溃堆栈里是AssetManager.openFd抛出的 IOException提示文件可能被压缩。原因aaptOptions里没有排除.tflite模型被 aapt 压缩进了 APK无法以文件描述符方式打开。解决在android块里补上aaptOptions { noCompress tflite }重新 clean 构建。注意改完必须 clean增量构建有时不会重新打包 assets你会以为改了没用。5.3 换了自己的模型报形状不匹配现象Cannot copy from a TensorFlowLite tensor with shape [1,224,224,3] to a Java object with shape [1,192,192,3]或者直接 native crash。原因代码里把输入尺寸硬编码成了 224而新模型是 192或者新模型的输入是灰度单通道代码还在按三通道写。解决在构造函数里从getInputTensor(0).shape()读尺寸通道数也从 shape 的最后一维取预处理的循环条件跟着改。硬编码在 Demo 阶段方便在换模型阶段就是纯粹的负债。5.4 连续识别几十次之后内存一路涨现象单张图识别没问题接到相机预览上跑一两分钟内存曲线持续上升最后 OOM 或者被系统杀掉。原因每帧都Bitmap.createScaledBitmap()新建一张图每帧都ByteBuffer.allocateDirect()新分配一块直接内存两者都不在 GC 的快速回收路径上尤其直接内存要等 full GC 才释放。解决ByteBuffer 提到成员变量里复用尺寸变了才重新分配缩放后的 Bitmap 用完立刻recycle()或者干脆用Bitmap.createScaledBitmap之外的方案比如先画到一块复用的 Canvas 上。GPU Delegate 也要在onDestroy里显式close()它持有的 native 资源不受 Java 内存管理约束。5.5 模拟器上识别正常真机完全不准现象同一张图模拟器 top-1 正确装到手机上结果离谱但程序不崩。原因大部分情况是图片方向问题。手机拍的照片在 EXIF 里记录了旋转角度BitmapFactory.decodeFile默认不会自动旋转你拿到的是横向的图缩放成 224×224 之后内容被拉伸变形模型自然认不出。少数情况是 GPU Delegate 在特定驱动上算错了结果。解决读 EXIF 的TAG_ORIENTATION按角度做一次矩阵旋转再送进模型。排查时可以先关掉 GPU Delegate 用纯 CPU 跑一遍两边结果一致就说明是预处理问题不一致才去怀疑 Delegate。6. 二次开发换模型、接实时相机、做批量识别的三条改造路径换模型是最常见的一步难度取决于新模型和旧模型的接口差异。如果输入形状和数据类型都一致改assets里的文件名和labels.txt就行代码一行不用动。如果不一致要同步改三处imageSize已经在构造函数里从 shape 读了但归一化的均值方差是写死的得跟着训练脚本改输出维度也从labels.size()改成了动态读取如果模型带多个输出run()的参数要跟着变成数组。我习惯在换模型之后先跑一张已知答案的图把 top-3 打出来人工确认再往下做功能。接实时相机是第二个坎。相机的ImageAnalysis回调频率通常是 30fps同步推理肯定跟不上标准做法是把分析器设成STRATEGY_KEEP_ONLY_LATEST并且限制回调节奏比如每 300ms 才送一帧进模型。ImageAnalysis analysis new ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) // 绕开 YUV 手动转换 .build(); analysis.setAnalyzer(executor, image - { long now System.currentTimeMillis(); if (now - lastInferTime 300) { image.close(); return; } // 主动丢帧 lastInferTime now; Bitmap bmp Bitmap.createBitmap(image.getWidth(), image.getHeight(), Bitmap.Config.ARGB_8888); bmp.copyPixelsFromBuffer(image.getPlanes()[0].getBuffer()); // 推理放到独立线程结果通过 Handler 回主线程 image.close(); });OUTPUT_IMAGE_FORMAT_RGBA_8888这个设置能省掉一整段 YUV 转 RGB 的代码代价是内存占用略高在中端机上完全可接受。image.close()必须每帧都调漏一次就会让相机管线卡住表现是预览画面冻住不动。第三条路是做批量识别比如从相册一次选十张图。这时候可以在一个固定线程池里串行跑推理复用同一个 Interpreter 实例不要每次new因为 Interpreter 的构造本身就要解析模型图开销比一次推理还大。如果想省掉自己写前后处理也可以换成 Task Library 里的ImageClassifier它把标签加载、归一化、Top-K 都封装好了代价是灵活性下降自定义归一化方式时会比较别扭。关于验证我有个固定习惯每次改完预处理相关代码都拿同一张标准图跑一遍把 top-3 结果和置信度记在笔记里改前改后对比。这套流程听起来笨但确实救过我一次——某个版本我调了缩放算法视觉上看不出区别结果置信度从 0.91 掉到 0.62靠对比才发现是插值方式变了。从那以后我每次动 Bitmap 相关代码都强制走一遍这个对照不敢再凭看起来差不多下判断。希望帮到你。本文还有配套的精品资源点击获取