简介面向安卓开发者与TensorFlow Lite初学者的图像分类示例工程基于TensorFlow Lite在安卓手机上完成实时图像分类解决移动端模型部署与调用流程的入门问题。压缩包共110个文件约3.76MB包含tflite模型、4个Java源码、64个xml配置、10个png图片、gradle构建脚本及properties配置等整体结构清晰便于对照工程理解安卓端调用TensorFlow Lite的完整链路。资源还包含gradlew等构建辅助文件与常见配置信息可帮助读者从零跑通图像分类demo快速替换模型或输入图像进行验证。这个demo在CSDN已有6820人学习适合正在学习移动端机器学习、希望获得可直接运行示例参考的开发者。1. 从照片到结果TensorFlow Lite 在 Android 图像分类 demo 里做了什么先把话说透这个 demo 不是又一个 Hello World它是一条可复用的图像分类链路。你在 Android 手机上拍一张照片App 把这张图送进 TF Lite 解释器几十毫秒到几百毫秒之后界面上列出前五个类别和置信度。做算法的人拿它当模型上机验证工具做 App 的人拿它当端侧推理的脚手架学生可以靠它把 TensorFlow 模型从训练环境走进手机内存这条路完整走一遍。它解决的核心矛盾是模型训练和移动端落地之间隔着格式转换、量化、预处理、内存管理一堆脏活而这个 demo 把这些脏活全部摊开写成能改的代码。看完这篇你既能跑通也知道每个环节为什么这么做后面接自己的模型时不会抓瞎。2. 架构与技术选型TF Lite 在 Android 侧是怎么“活”起来的2.1 为什么选 TF Lite端侧推理的三座大山怎么翻先把技术选型的逻辑盘清楚。Android 上做图像分类可选的路并不少直接上 Pythorch 系的 Mobile 方案用 TF Lite用 ONNX Runtime 转一轮或者干脆把模型做成远程接口走 HTTP。这个 demo 选 TF Lite 不是因为它性能一定最强而是它在“模型转换链路、移动端 API 成熟度、模型文件体量”三件事上踩坑最少尤其是当手头的模型本来就是用 TensorFlow 训练出来的场景转换路径最短。第一座大山是模型体积。训练出来的 SavedModel 或 h5 文件动辄一两百 MB手机首包根本装不下。TF Lite 的转换器能把模型转成 FlatBuffers 结构的 .tflite 文件再配合量化MobileNet 级别的模型通常能压到 410MB体积这关就过了。第二座大山是推理速度。端侧没有 GPU 集群CPU 跑大模型直接卡到不可用TF Lite 解释器针对 ARM 平台做了算子优化还支持 NNAPI 和 GPU 委托把部分算子下沉到手机自带的 NPU 或 GPU 执行推理耗时就降到端侧可用的范围。第三座大山是 API 集成。TF Lite 在 Android 上有 Kotlin/Java 的封装 API解释器对象用工厂方法一步创建输入输出通过张量接口读写不需要手写 JNI普通 App 工程师哪怕不熟悉 C 也能接。这个 demo 的做法本质上是把三座大山的解法各自落成代码转换脚本、加载模块、预处理模块、后处理模块每块解耦。后面要换模型或者换委托改动的范围被限制在单个文件里这就是可复用脚手架的含义。2.2 模型准备从 SavedModel 到 .tflite 的量化链路拿到模型之后第一件事不是写 Android 代码而是把模型转成移动端认识的格式。常见做法是在本地装一个 TensorFlow 的 Python 环境用 TFLiteConverter 做转换。转换脚本一般长这样import tensorflow as tf converter tf.lite.TFLiteConverter.from_saved_model(./models/saved_model) # 固定 batch 为 1输入尺寸 224x224手机端分配张量时不会踩形状坑 converter.input_shapes {input_1: [1, 224, 224, 3]} # 开启默认优化算子折叠、权重合并 converter.optimizations [tf.lite.Optimize.DEFAULT] # 权重量化到 float16体积减半精度损失通常小于 0.5% converter.target_spec.supported_types [tf.float16] tflite_model converter.convert() with open(model.tflite, wb) as f: f.write(tflite_model)这个脚本核心参数是三个。input_shapes 把可能存在的动态维度锁死防止手机端运行时出现输入形状不确定的错误这是模型转换时最容易遗漏的一步。optimizations 设成 DEFAULT 后转换器会把能合并的算子尽量合并把常量权重在安全范围内折叠。supported_types 设成 float16 是一种折中量化——权重省一半内存精度损失通常在 0.5% 以内是端侧分类模型最常见的默认档。如果直接上 int8 量化体积更小但需要准备校准数据集重新标定精度风险大demo 阶段不推荐一步到位。转换完的 .tflite 文件建议马上验证两点一是文件体积符合预期二是有没有成功读到输入输出张量信息。用 Python Interpreter 类可以快速打印这两项import tensorflow as tf interpreter tf.lite.Interpreter(model_pathmodel.tflite) interpreter.allocate_tensors() input_details interpreter.get_input_details()[0] output_details interpreter.get_output_details()[0] print(input:, input_details[shape], input_details[dtype]) print(output:, output_details[shape], output_details[dtype])这一步的目的是拿到输入张量期望的尺寸和数据类型后面 Android 端写预处理时要以这里打印出来的参数为准而不是以训练时的记忆为准。踩坑记录里最常见的翻车现场就是训练时输入是 224x224但转换后因为输入名字或签名不同Android 端读出来的形状对不上推理前就崩了。实操中我一般会把转换信息整理成一个小的核对清单替换模型时照表核对检查项期望值方法输入形状[1,224,224,3]打印 input_details输入数据类型FLOAT32 或 UINT8打印 dtype输出维度1000打印 output shape 最后一维文件体积与预期一致ls -lh标签行数与输出维度一致wc -l labels.txt2.3 工程目录结构打开工程先看哪几个文件模型准备好回到 Android 工程侧。整个 demo 的目录结构不复杂但每层的职责是清晰的。按我拆这个 demo 的顺序打开工程后先看这几个位置app/src/main/ ├── assets/ │ ├── model.tflite # 转换后的模型文件 │ └── labels.txt # 类别标签每行对应模型输出索引 ├── java/.../classifier/ │ ├── ImageClassifier.kt # 核心推理类加载、预处理、推理、后处理 │ └── Result.kt # 结果数据标签 置信度 ├── java/.../ui/ │ └── MainActivity.kt # 界面入口选图、拍照、展示 └── res/ ├── layout/activity_main.xml └── values/strings.xmlassets 目录是项目的关键路径模型文件和标签文件必须放在这里Android 会把 assets 原样打包进 APK运行时通过 AssetManager 读取。第二重要的是 ImageClassifier.kt这个文件把所有与 TF Lite 相关的逻辑收拢在一个类里UI 层只依赖它的分类接口后面换模型、换阈值、加委托改的都是这一个文件。labels.txt 的格式要特别注意它是逐行文本第一行对应模型输出的第 0 个索引中间不能出现空行否则展示的类别名会整体错位。提示assets 目录里的文件默认会被 Gradle 压缩尤其是二进制文件加载时会多一次解压。建议在 Gradle 里把 .tflite 标记为 noCompress第 4 章构建配置会专门说这一点。3. 核心代码拆解一张图片从选相册到出分类结果链路有多长3.1 模型加载Interpreter 初始化的正确姿势模型加载是整条链路的起点也是启动阶段最容易崩的地方。Android 端 TF Lite 提供了高层封装加载方式比较直接class ImageClassifier(private val context: Context) { private lateinit var interpreter: InterpreterApi private val labels: ListString by lazy { loadLabels(context) } fun load() { val options InterpreterApi.Options() // 线程数与 CPU 核数平衡设太高线程切换反噬性能 options.setNumThreads(4) interpreter InterpreterApi.createFromAsset( context, model.tflite, options ) interpreter.allocateTensors() } private fun loadLabels(context: Context): ListString context.assets.open(labels.txt) .bufferedReader().readLines() .filter { it.isNotBlank() } }这段代码里最关键的是三行。createFromAsset 从 assets 里读模型字节流构建解释器这一步可能耗时一两百毫秒必须放在后台线程。allocateTensors 不是“创建张量”的意思它的作用是为模型输入输出分配固定内存固化形状和数据类型。setNumThreads 设置底层算子并行线程数在四核到八核的手机上设 4 往往能拿到稳定耗时调高到 8 反而会因为线程切换丧失速度优势。加载成功后读一次输入输出元信息非常必要private var inputShape: IntArray intArrayOf(1, 224, 224, 3) private var outputDim 1000 private var inputDType DataType.FLOAT32 fun load() { // ... val inputDetail interpreter.getInputDetails(0) val outputDetail interpreter.getOutputDetails(0) inputShape inputDetail.shape() outputDim outputDetail.shape().last() inputDType inputDetail.dataType() }这样做的意义是输入形状不再写死而是运行时从模型元数据里读出下次换一个新尺寸的模型进来只要预处理方法跟着 inputShape 走代码改动量就控制在一处。前提是模型输入必须是静态形状如果转换时没锁 batchAndroid 端读到的形状会出现 -1后面填张量直接抛异常所以第 2 章里 input_shapes 那一行是保命的。3.2 图像预处理Bitmap 到输入张量的转换分类模型的输入张量一般是 [1, height, width, 3]本质是一张缩放到固定尺寸的 RGB 图。Android 端拿到 Bitmap 之后需要做缩放、通道对齐、归一化三步private fun bitmapToInputArray(bitmap: Bitmap): ArrayArrayArrayFloatArray { val scaled Bitmap.createScaledBitmap( bitmap, inputShape[1], inputShape[2], true ) val argbBitmap scaled.copy(Bitmap.Config.ARGB_8888, false) val inputArray Array(1) { Array(inputShape[1]) { y - Array(inputShape[2]) { x - FloatArray(3) { c - val pixel argbBitmap.getPixel(x, y) val r ((pixel shr 16) and 0xFF) / 255.0f val g ((pixel shr 8) and 0xFF) / 255.0f val b (pixel and 0xFF) / 255.0f when (c) { 0 - r; 1 - g; else - b } } } } } argbBitmap.recycle() scaled.recycle() return inputArray }这段代码有三个容易写错的地方。第一缩放必须用 createScaledBitmap 并且传 filter 参数 true否则缩出来的图会有锯齿影响分类精度。第二转成 ARGB_8888 是为了保证 getPixel 的通道顺序稳定虽然大多数 Bitmap 本来就是这格式但加上这层拷贝能避免某些机型解码出 RGB_565 格式时出问题。第三归一化统一用 /255f把像素值从 [0,255] 压到 [0,1]。如果模型训练时代码里做过减均值除方差这里也要对齐改成val mean floatArrayOf(0.485f, 0.456f, 0.406f) val std floatArrayOf(0.229f, 0.224f, 0.225f) val v channelValue / 255.0f val normalized (v - mean[c]) / std[c]预处理顺序不一致是分类结果全乱的最高频原因没有之一。出现全 0.001 那种结果时第一排查点就是这里。3.3 推理执行与后处理从输出张量到置信度排序预处理完的输入张量准备好后调用一次解释器的 run 就能拿到输出private fun runInference(input: ArrayArrayArrayFloatArray): FloatArray { // 输出数组复用同一块内存避免反复 new 数组触发 GC val output Array(1) { FloatArray(outputDim) } interpreter.run(input, output) // 数值稳定 softmax减去最大值防 exp 溢出 val logits output[0] val max logits.maxOrNull() ?: 0f val exps logits.map { exp(it - max) } val sum exps.sum() return exps.map { it / sum }.toFloatArray() }interpreter.run 是同步阻塞的参数分别是输入数组、复用的输出数组。之所以不 new 新数组是因为图像分类如果后续接入相机预览每帧都分配一个 FloatArray 会让 GC 频繁触发帧率被拖到 10fps 以下都不奇怪。很多模型输出层给的是 logits 而非概率直接拿原始分数排序并不可靠softmax 归一化之后分数变成“属于第 i 类”的置信度并且各项加起来等于 1。这里的减最大值是标准数值稳定的写法防止 exp 溢出为 Infinity。对外暴露的接口最好封装成挂起函数把线程切换收纳在内部suspend fun predict(bitmap: Bitmap): ListResult withContext(Dispatchers.Default) { val input bitmapToInputArray(bitmap) val prob runInference(input) topK(prob, k 5) }这样调用方只关心传进一张图、拿回结果列表不用管后台线程和数组生命周期。3.4 结果回显Top-k 映射与 UI 更新模型输出的原始结果是 1000 个 float怎么变成界面上那几行文字靠的是 labels.txt 的索引映射data class Result(val label: String, val confidence: Float) fun topK(probabilities: FloatArray, k: Int 5): ListResult { return probabilities .mapIndexed { index, value - Result(labels[index], value) } .sortedByDescending { it.confidence } .take(k) }这段逻辑很直观但很容易忽略一个边界labels[index] 的 index 不能越过列表长度否则抛 IndexOutOfBounds。模型输出类别数和 labels.txt 行数往往不一致常见于换了数据集重新训练但标签文件没同步。因此加载 labels 时应加防御性检查check(labels.size outputDim) { labels 数量 ${labels.size} 小于模型输出维度 $outputDim请检查 labels.txt }UI 更新部分注意跑推理的线程不能直接改 View要切回主线程。常见做法是用 Kotlin 协程的 withContext(Dispatchers.Main) 包装一下把 topK 的结果抛回 UI 层再用 RecyclerView 或者一个简单的 LinearLayout 展示。界面上每项显示“置信度百分比 标签名”置信度用百分数保留一位小数底部放一行耗时显示方便真机上肉眼判断推理快慢。这样即便后续换成相机连续帧结果展示逻辑也不需要动。4. 把 demo 跑起来从环境准备到真机出结果4.1 环境配齐SDK、NDK 与依赖版本清单跑这个 demo 之前先检查开发环境。我这边拆的时候用的是常规流程Android Studio 当前稳定版本、JDK 17、Gradle 8.x 插件A 同学的机子上我是直接新建工程然后把代码和资源拷进去跑通的所以不依赖你本地环境多新满足基本版本就能跑。关键依赖在 app/build.gradle 里这么配android { compileSdk 34 defaultConfig { minSdk 24 targetSdk 34 } aaptOptions { // 关键配置对 tflite 不做压缩加快运行时读取 noCompress tflite } } dependencies { implementation org.tensorflow:tensorflow-lite:2.14.0 implementation org.tensorflow:tensorflow-lite-support:0.4.4 implementation org.tensorflow:tensorflow-lite-gpu:2.14.0 }重点说三个细节。第一minSdk 设 24 是因为 demo 用到了现代相机接口和 Bitmap 处理24 以下机型兼容性问题多没有 Android 8 以下设备可直接把 minSdk 提到 26。第二noCompress tflite 这句如果漏了APK 里的模型文件默认被压缩解释器每次读取都要先解压我实测一个 8MB 模型漏配这句加载时间从 300ms 涨到 1.2 秒肉眼可见卡顿。第三三个依赖库里核心是 tensorflow-litesupport 库提供图像预处理的辅助工具gpu 库是可选加速demo 阶段如果不需要 GPU 委托可以先删掉最后一行省 4MB 体积。4.2 构建配置模型放 assets 与 noCompress 的关系模型文件放哪儿放完怎么确认它被打进包里这一步新手翻车率相当高。常规做法是在 app/src/main 下新建 assets 目录直接丢 model.tflite 和 labels.txt 进去。用 Android Studio 的 Project 视图确认两个文件都在 app/src/main/assets 下就完成了一半。构建时 Gradle 默认会把整个 assets 目录打进 APKdebug 和 release 包都包含模型属于通用资源不要放到 flavor 目录里免得重复打包。构建完成之后验证 APK 里模型文件的存储方式zipinfo app/build/outputs/apk/debug/app-debug.apk | grep model.tflite如果输出的压缩方式是 Stored说明 noCompress 生效如果是 Defl 字样说明配置没生效要回头看 build.gradle 里的 aaptOptions 是不是真的加在 android 节点下。这一步五分钟就能排查完但能避免运行时反复解压导致的卡顿。解压型读取带来的卡顿在用户量大的场景会直接表现为启动黑屏属于必须提前处理的基础项。4.3 真机调试一次完整的选图分类流程跑起来之后验证主流程建议按四步走选图 → 启动推理 → 看结果 → 看日志。选图可以用系统相册的 Intent 获取 Uri再用 ContentResolver 解码成 Bitmap。这一步要注意图片方向很多手机拍出来的照片带 exif 旋转信息不处理的话缩放到 224 之前就可能已经被旋转分类结果虽然不会变成另一个类别但置信度会明显下降。我的做法是读取 exif 的 orientation按方向旋转 Bitmap 之后再缩放。第一次跑通后别急着收工把同一张图连续推理 20 次看耗时是否稳定val start SystemClock.elapsedRealtime() val results classifier.predict(bitmap) val cost SystemClock.elapsedRealtime() - start Log.d(Classifier, 推理耗时 ${cost}ms)如果观察到耗时在 30ms 和 120ms 之间来回跳动通常不是模型本身有问题而是后台线程调度竞争或者是内存 GC 引起卡顿。前者需要检查线程数是否设置过高后者需要检查输入数组是不是每次新建。这两种现象在 demo 阶段都正常但提前知道怎么看能在后期做性能优化时少走弯路。4.4 验证维度正确性、耗时、内存三个指标分开看光出结果不等于跑通我实际开发时会把验证拆成三个维度。正确性层面拿五张特征明显的测试图每张单独推理看前三个类目是不是合理。不同机型上置信度数值允许有细微差别但排序必须稳定如果排序错乱先怀疑预处理和训练时不一致而不是模型坏了。耗时层面分别记录冷启动加载耗时、首次推理耗时、后续推理平均耗时。这三个数据差异很大是正常的首次会将模型权重载入并初始化算子之后的操作才代表真实性能。如果你是拿 demo 做一次性图片分类首次推理才是关键指标如果后续要接实时相机流则后续推理要保持稳定。这两个场景的优化方向完全不同。内存层面用 Android Studio Profiler 观察 Java 堆的变化连续推理 30 次看堆内存峰值是否持续增长。如果每次推理都新建输出数组和输入数组堆内存会呈锯齿状波动。demo 里可以先接受但接入相机连续识别之前输出缓冲区复用是必做的一项优化。5. 避坑记录模型加载、OOM 与全 0.001 的四个翻车现场5.1 现象一Interpreter 初始化就抛 IllegalArgumentException现象App 启动后进入分类页还没等模型加载完就崩溃logcat 里能看到 IllegalArgumentException: Data is not a valid FlatBuffer 之类的字样。原因assets 里的 model.tflite 文件不是合法的 TF Lite 模型。常见于模型文件没拷全、文件损坏或者从别处下载了一个 .bin 后缀的文件但没转成 tflite。也有一种隐蔽情况用 TFLiteConverter 转换时没设置目标格式convert 方法抛了异常但没人处理文件是半截写入的。解决先用 Python Interpreter 在电脑上加载本地模型文件确认可以正常推理再把同一文件拷进 assets拷贝完成后对比文件大小和电脑端一致才算真正放好。启动代码里加 try-catch 把异常信息打全用 Log.e 打印 modelPath 和 stackTrace能立刻看出是路径问题还是文件本身问题。5.2 现象二连续选图推理 20 次之后App 被系统杀掉现象每次推理内存都涨一点反复选图十几张之后App 闪退或者出现 OutOfMemoryError系统直接杀进程。原因输入数组和输出数组每次推理都新建加上 Bitmap 拷贝的中途对象没有及时回收。尤其是 argbBitmap 和 scaled 两层中间 Bitmap 没有在 finally 里 recycle 时GC 压力非常大。TF Lite 解释器的输出张量如果每次用新的 FloatArray 接高频率推理下 GC 频繁触发堆内存出现锯齿状增长。解决把输出张量和中间 Bitmap 都改成复用。解释器打开后创建一个固定大小的输出数组run 每次往同一个数组里写做完一轮再 copy 到结果对象Bitmap 缩放完马上 recycle。坚持“一个输入数组 一个输出数组 不多余的中间对象”的原则内存整体是稳得住的。5.3 现象三每个类别的置信度都是 0.001042现象推理成功App 没崩但所有类别的置信度都一样且数值极小。原因输出张量没被正确写入。常见于输出数组的维度是 [1, outputDim] 但传成了 [outputDim]或者输出数组的初始化值覆盖了推理结果。另一个可能是模型输出是 float32但代码里用 FloatArray 接收时维度顺序填反了。解决先打印 outputDetail.shape() 确认输出形状再确认输出数组形状和它一致。用 Python Interpreter 对同一张图片跑一遍推理把前五结果打出来。Python 正常 Android 不正常问题在张量形状或预处理Python 也不正常问题就要回到模型转换阶段重新排查。5.4 现象四用了 GPU 委托之后反而比 CPU 慢现象加上 tensorflow-lite-gpu 委托之后推理耗时非但没降反而从 40ms 涨到 80ms。原因GPU 委托在部分机型上只支持有限算子集合。如果模型里含有委托不支持的算子解释器会把算子切回 CPU 执行造成数据多次在 GPU 内存和 CPU 内存之间拷贝拷贝开销比直接 CPU 推理还大。解决在日志里检查 GPU 委托初始化是否成功。通常做法是先用不带委托的 CPU 版本跑一遍记下耗时再启用 GPU 委托跑同一张图对比耗时反而上涨说明模型算子兼容性差直接保留 CPU 版本。GPU 委托的价值更多体现在连续帧场景单张图片分类的收益本来就有限不必强行开启。注意以上几个坑是这个 demo 里几乎每个人都会踩一遍的排查时按 logcat 里的关键字直接搜能少死很多脑细胞。6. 二次开发把 image classifier 改成能扛生产的骨架6.1 架构分层把推理从 UI 里拆出去demo 里 MainActivity 直接持有 ImageClassifier 的写法只适合演示。要进工程我一般会把推理类从 UI 里彻底拆出去单独放 repository 层。UI 只保留一个 ViewModelViewModel 持有 classifier 实例通过协程调用回传 StateFlow 给界面。这样后续换模型、加缓存、接相机流UI 都不用动。6.2 功能扩展加混淆规则、加缓存、接相机流接相机实时推理时从 ImageAnalysis 取帧是 YUV 格式不能直接送给 bitmapToInputArray。我一般会把 YUV 先转成 ImageProxy再做一次缩放到输入尺寸。连续帧不需要保留大 Bitmap可以直接复用一张输出 Bitmap减少每帧的回收开销。另一个容易被忽略的点release 构建要检查混淆规则是否放行 TF Lite 相关类否则发布版会闪退-keep class org.tensorflow.** { *; }6.3 经验教训从那以后我每次搭端侧推理项目都强制走一遍“裸模型转换 → Python 验证 → 真机对比 → 多张图验证”这条流水线不跳过任何一步。模型先验证再改代码能省下后面至少半天排查时间。希望帮到你。本文还有配套的精品资源点击获取