简介:这是一份使用TensorFlow Lite在Android手机上运行图像分类任务的入门demo,面向具备一定Android基础、但初次接触移动端机器学习的开发者,可用于课程设计、毕设或自学者快速上手。工程包含4个Java源文件、64个XML布局与配置资源、1个TFLite模型、10张PNG测试图片以及Gradle构建脚本等共110个文件,压缩包仅3.76MB,整体目录沿标准Android Studio结构组织,直接导入即可运行,部分gradle缓存文件不影响使用。已有5898人学习或下载。运行后可从真实案例中理解TFLite的推理流程:加载模型、对Bitmap图片做尺寸归一化与通道转换、组织输入张量、解析输出概率并映射为类别标签,同时在UI上展示结果。还能在现有代码基础上替换模型与标签,快速扩展为其他图像识别场景;整包体积小,学习与调试成本低,适合作为初次接触TFLite的项目模板。
1. TensorFlow Lite在Android上做图像分类demo:核心工作量其实不在模型训练
用TensorFlow Lite在Android手机上实现图像分类,最反直觉的一点是:demo里真正耗时间的不是深度学习,而是把模型从训练环境搬进App的工程链路。很多人以为难点在于模型选型或训练精度,可等你把模型转换、依赖版本、图像预处理、输出映射全部跑通一遍,会意识到端侧推理的坑全藏在细节里——一张Bitmap的缩放方式不对、标签文件编码差一个字节,识别结果就可能全是乱码。这篇笔记面向有Android基础、想用最短路径跑通图像分类demo的开发者,我会沿着模型准备、工程搭建、推理代码、性能调优、踩坑排查的顺序,把整套方案从头拆到尾,代码可以直接对照着抄。
2. 模型从哪来:拿到一个能塞进Android的tflite文件的三种途径
2.1 官方预训练模型:最快路径与文件清单
做图像分类demo,第一选择不是自己训练,而是从公开模型库直接拿现成的MobileNetV2、MobileNetV3或EfficientNet-Lite变体。这类模型已经在ImageNet这种大规模数据集上完成训练,覆盖1000个常见物体类别,拿来就能跑,识别猫、狗、杯子、手机都够用。
常见做法是用命令行直接拉取模型文件和配套的标签文件。以MobileNetV2为例,我会先在工作目录建一个models文件夹,然后下载两个关键文件:一个是.tflite格式的模型,另一个是labels.txt标签文件。下载命令大致如下:
mkdir -p android_demo/models && cd android_demo/models curl -L -o mobilenet_v2.tflite \ https://storage.googleapis.com/example-model-url/mobilenet_v2_1.0_224.tflite curl -L -o labels.txt \ https://storage.googleapis.com/example-model-url/mobilenet_v2_labels.txt下载后需要立刻核对两件事。第一,模型文件的字节数是否为0或远小于预期,网络中断经常留下一个残缺文件,后续加载必然崩溃。第二,labels.txt的行数是否与模型输出层神经元数量一致——MobileNetV2通常是1000行,一个类别占一行。
这里有个容易被忽略的选型细节:模型文件名里的“224”代表输入图像尺寸,MobileNetV2有多种规格,1.0_224表示宽度乘数为1.0、输入分辨率224x224。分辨率越高精度越好,但推理耗时同步上涨。在手机上做demo,224是安全和平衡的选择,128会更快但精度肉眼可见地下降,320更适合高配旗舰机。
拿到模型后,我习惯先用Netron这类可视化工具打开tflite文件看一眼输入输出张量的形状和数据类型。这个步骤能省掉后面很多排查时间:确认输入是float32还是uint8,输出张量是(1,1000)还是(1,1001)——MobileNetV2的原始输出有时会包含一个背景类,用可视化工具提前确认比写代码时再去猜要快得多。
2.2 把自己训练的模型转成tflite:转换脚本与三个必设参数
如果手上的模型是自己用Keras或PyTorch训练的,就需要走模型转换这一步。TensorFlow Lite的转换器是TensorFlow的配套组件,核心思路是把训练框架里的计算图固化并精简成tflite格式。
以Keras模型为例,转换脚本非常短:
import tensorflow as tf # 加载训练好的Keras模型 model = tf.keras.models.load_model("my_image_classifier.h5") # 转换为TFLite格式 converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() # 写入文件 with open("my_image_classifier.tflite", "wb") as f: f.write(tflite_model)这段代码有三处值得逐一解释。from_keras_model要求模型已经保存为.h5或SavedModel格式,如果只有权重文件而没有完整结构,需要先把结构加载回来。optimizations = [tf.lite.Optimize.DEFAULT]表示启用默认优化,它会尽可能把浮点权重转换为更紧凑的表示,但注意它不会自动做int8全整数量化,而是会退化为动态范围量化——权重用int8存储、计算时还原成浮点,模型体积能缩小到原来的四分之一左右,但推理时不一定变快,只是省内存。
第三个关键点是,如果模型输入层不是标准的[1,height,width,channels]排布,转换前需要先确保模型被正确指定了输入形状。转换完成后,需要验证tflite模型的输入张量形状,方法是用以下代码读取并打印:
import tensorflow as tf interpreter = tf.lite.Interpreter(model_path="my_image_classifier.tflite") interpreter.allocate_tensors() print(interpreter.get_input_details())这里输出的dtype、shape、quantization参数,就是后面Android端写预处理代码的依据。比如dtype是float32,Android那边就得把像素值归一化到0~1之间;如果dtype是uint8,就需要按量化参数做缩放和零点偏移。这个细节是端侧推理最常见的错位来源之一。
2.3 要不要做int8量化:模型大小与精度实测后的选择
从训练框架转出来的tflite默认是全浮点模型,体积大、推理慢。Android端通常会有两种量化选择:float16量化和int8全整数量化。
float16量化:权重从32位浮点压成16位浮点,模型体积减半,推理时在支持的设备上有更快的矩阵计算,精度损失通常小于1%。实现只需要在转换脚本里加一行:
converter.target_spec.supported_types = [tf.float16]int8全整数量化:权重和激活都用8位整数表示,模型体积缩到四分之一,推理速度明显提升,但需要提供一个代表数据集来校准量化范围。这个操作在转换脚本中的体现是:
def representative_dataset(): for i in range(100): # 从训练集取100张图,预处理成与模型输入相同的形状 yield [sample_images[i]] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.uint8 converter.inference_output_type = tf.uint8从实测效果看,在图像分类场景里,int8量化后模型精度和原始float32相比,大约会掉1到3个百分点。如果分类类别不超过几十个且类别特征差异明显,这个损失几乎感知不到;但如果涉及细粒度分类,比如区分不同品种的鸟,建议保留float16或干脆不量化。
下面这张表总结了我常用的选型思路:
| 方案 | 模型体积 | 推理延迟 | 精度损失 | 适用场景 |
|---|---|---|---|---|
| 全浮点float32 | 基准 | 基准 | 无 | 调试阶段、高端设备 |
| float16量化 | 约50% | 略有提升 | <1% | 平衡之选,demo首选 |
| int8全整数量化 | 约25% | 明显提升 | 1%~3% | 低端设备、正式上线 |
选择量化方案时还有一个隐藏前提:int8量化后的模型,在Android上默认只能跑CPU,如果后面想用GPU委托加速,反而可能会遇到算子不支持的问题。所以做demo我建议先用float16量化,性能不够再切int8。
3. Android工程搭建:依赖配置和模型落位的常见错法
3.1 两种引入方式的选型:原生Interpreter与Task API
在Android Studio里集成TensorFlow Lite,依赖引入有两种主流方式。第一种是直接用原生库org.tensorflow:tensorflow-lite,开发自由度最高,输入输出的张量排布、量化参数、线程数设置全在自己手里;第二种是使用org.tensorflow:tensorflow-lite-task-vision封装好的Task API,ImageClassifier类已经把图像预处理、推理、输出排序全包了,代码量少很多,但不适合需要精细控制预处理逻辑的场景。
我的建议是:做demo用Task API先把流程跑通,然后切成原生Interpreter来理解内部机制。因为Task API的封装太黑匣子了,一旦模型输出结果与预期不符,你很难判断问题出在预处理还是模型本身。原生Interpreter会逼着你把每一步都写清楚,排查起来反而更有方向感。
在build.gradle中,两种依赖的配置方式如下:
dependencies { // 原生TFLite解释器 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' }注意tensorflow-lite与tensorflow-lite-support的版本可能不同步,support库的版本号往往落后于主库。如果遇到NoSuchMethodError或UnsatisfiedLinkError,优先查两个库的版本是否兼容。常见做法是先在官方Release Notes里确认两个版本配套的发布时间,再写进gradle。
另一个容易翻车的点是:tensorflow-lite的Java包和旧版TF Lite的包名不同,老项目如果沿用过去的依赖名,同样会报加载错误。在新项目里只保留上面这三个依赖就行,不需要额外引入完整的TensorFlow库。
3.2 模型文件落位与ABI过滤
tflite模型文件统一放在src/main/assets目录下。这个目录是Android的打包资源目录,运行时通过AssetManager读取。注意不要放在res/raw目录——虽然也能读,但Android编译系统会对raw资源做压缩处理,某些情况下大文件读取会不正常。
assets目录下的文件组织和读取有讲究。如果你有多个模型,建议按模型名建子目录,并在代码里定义常量管理路径:
public class Constants { public static final String MODEL_PATH = "mobilenet_v2.tflite"; public static final String LABEL_PATH = "labels.txt"; public static final int INPUT_SIZE = 224; }同时,在build.gradle的defaultConfig里,需要配置ABI过滤。TensorFlow Lite的底层JNI库支持arm64-v8a、armeabi-v7a、x86_64等架构。如果不过滤,APK会打包所有架构的so文件,体积膨胀,而且个别旧设备可能加载到不匹配的ABI直接闪退。
android { defaultConfig { ndk { abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' } } }这里的核心思想是:把ABI限制在主流移动架构上。arm64-v8a是现在新款手机的标配,armeabi-v7a覆盖老设备,x86_64供模拟器使用。如果完全不做配置,模拟器会尝试加载x86的so文件,真机又可能撞上不匹配的架构,出现UnsatisfiedLinkError。对应的排查经验是,安装后立刻进行首次推理,如果立刻崩,先看崩溃日志里是不是so加载失败,再回来看ABI配置。
3.3 模型加载与解释器创建的正确姿势
创建一个Interpreter实例的标准方式,是先读模型文件为ByteBuffer,再交给Interpreter。注意不要每次推理都重新创建Interpreter——Interpreter的构建过程包含算子的预编译,耗时可能达到数百毫秒,在相机预览场景下尤其致命。一个应用生命周期内只创建一个实例,多帧图片共享它。
下面是一段标准的模型加载代码:
import org.tensorflow.lite.Interpreter; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; import java.io.FileInputStream; import java.nio.channels.FileChannel; public class Classifier { private Interpreter interpreter; public void loadModel(Context context) throws IOException { // 从assets读取模型为ByteBuffer ByteBuffer modelBuffer = loadModelFile(context, "mobilenet_v2.tflite"); // 创建解释器 interpreter = new Interpreter(modelBuffer); } private ByteBuffer loadModelFile(Context context, String modelPath) throws IOException { AssetFileDescriptor fileDescriptor = context.getAssets().openFd(modelPath); FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor()); FileChannel fileChannel = inputStream.getChannel(); long startOffset = fileDescriptor.getStartOffset(); long declaredLength = fileDescriptor.getDeclaredLength(); return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength); } }上面用FileChannel.map把文件映射到内存,而不是用readAllBytes把整个模型一次性读入byte数组。区别在于大模型用readAllBytes会产生双倍内存占用,而内存映射是懒加载,模型文件不会一次性全部载入堆内存,这对内存敏感的移动端有意义。Interpreter构造完成前不要释放FileChannel,构建结束后及时清理。
模型加载完成后的另一个动作是检查输入形状:
int[] inputShape = interpreter.getInputTensor(0).shape();打印出来的应该是[1, 224, 224, 3],如果不是,说明模型路径或选型出了问题,后面的图像预处理就要按实际shape调整。
4. 图像分类推理代码:从Bitmap到Top1结果的完整链路
4.1 图像预处理:缩放、归一化与通道顺序的一体化处理
Android端拿到一张Bitmap后,不能直接塞给模型。模型期望的输入是一个形状为[1, 224, 224, 3]的浮点张量,其中3表示RGB三个通道,像素值归一化到0到1之间。常见的错误做法是直接用Bitmap.createScaledBitmap做拉伸缩放,这会改变图像纵横比,影响分类效果。
更好做法是中心裁剪加缩放:先把Bitmap裁剪成正方形,再缩放到224x224。关键原因是ImageNet的训练数据基本都是正方形物体居中,直接用全图拉伸会让物体变形,分类精度明显下降。代码实现:
public static Bitmap centerCropAndResize(Bitmap src, int targetSize) { int width = src.getWidth(); int height = src.getHeight(); // 计算中心裁剪区域 int cropSize = Math.min(width, height); int startX = (width - cropSize) / 2; int startY = (height - cropSize) / 2; Bitmap cropped = Bitmap.createBitmap(src, startX, startY, cropSize, cropSize); Bitmap scaled = Bitmap.createScaledBitmap(cropped, targetSize, targetSize, true); if (scaled != cropped) { cropped.recycle(); // 释放中间Bitmap } return scaled; }有人会问:直接createScaledBitmap也能达到224x224,为什么非要先裁剪?原因在于如果原图是3:4竖图,直接拉伸会让整个画面在水平方向变扁,模型看到的物体比例完全变形。中心裁剪虽然丢掉了边缘信息,但保住了目标物体的大致比例,分类准确率更高。这与YOLO等物体检测框架中letterbox处理是同一个思路。
接下来把Bitmap像素值写入ByteBuffer,同时完成归一化:
public static ByteBuffer convertBitmapToBuffer(Bitmap bitmap, int inputSize) { // 使用INTERNAL_MEMORY,4个字节存一个float ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4 * inputSize * inputSize * 3); byteBuffer.order(ByteOrder.nativeOrder()); int[] pixels = new int[inputSize * inputSize]; bitmap.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize); for (int pixel : pixels) { int r = (pixel >> 16) & 0xFF; int g = (pixel >> 8) & 0xFF; int b = pixel & 0xFF; // 归一化到0~1 byteBuffer.putFloat(r / 255.0f); byteBuffer.putFloat(g / 255.0f); byteBuffer.putFloat(b / 255.0f); } return byteBuffer; }这里有两个参数需要说明。allocateDirect分配的是直接内存,不经过Java堆,这是TensorFlow Lite处理输入数据的推荐方式,因为JNI层可以直接访问直接缓冲区,避免数据拷贝。nativeOrder确保字节序与当前设备一致,如果漏掉这一行,在某些大端设备上数据会读取错乱。
通道顺序方面,getPixels返回的ARGB_8888格式中,默认排列是RGBA,上面对pixel做移位操作时,先取R、再取G、最后取B,已经在手动转换通道顺序。这一步不能省,因为模型的训练数据绝大多数是RGB排列。
4.2 执行推理并解析概率输出
执行推理的代码相对简单,但有一个细节:输入输出张量的引用不能随意创建,input数组和output数组需要预先分配好,并在每次推理时复用,避免频繁GC。核心代码:
public class Classifier { private static final int OUTPUT_CLASSES = 1000; private float[][] output; private float[][] input; public Classifier(int numThreads) { input = new float[1][224 * 224 * 3]; output = new float[1][OUTPUT_CLASSES]; // 设置线程数,2~4之间比较合理 interpreter.setNumThreads(numThreads); } public float[] classify(Bitmap bitmap) { Bitmap processed = centerCropAndResize(bitmap, 224); ByteBuffer buffer = convertBitmapToBuffer(processed, 224); buffer.rewind(); // 从ByteBuffer读入input数组 buffer.asFloatBuffer().get(input[0]); // 执行推理 interpreter.run(input, output); // 返回概率数组,长度与输出张量一致 return output[0]; } }interpreter.run(input, output)是同步调用,耗时取决于模型大小、线程数和设备性能。在低端设备上,MobileNetV2单次推理大约需要100到200毫秒;中高端设备可以压到50毫秒以内。如果连续调用多次,建议用runForMultipleInputsOutputs一次性处理多张图,这在批量测试时有明显性能优势。
关于线程数参数setNumThreads,这里有一个常见的认知误区:线程数不是越大越好。TFLite的算子内部会做并行切分,线程数超过设备物理核心数后,线程切换开销会抵消并行收益。在手机端,双核和四核设备设置2,八核设备设置4,性能和功耗最均衡。
4.3 输出映射:从概率数组到可读标签
模型输出的output[0]是一个长度为1000的浮点数组,每个位置对应一个类别编号。要把编号翻译成人能读的标签,需要按行读取labels.txt,建立一个索引到标签字符串的映射,然后把输出概率降序排列,取前几项作为TopK结果。
public static List<Recognition> getTopKResults(float[] probabilities, List<String> labels, int k) { // 创建索引数组并排序 Integer[] indices = new Integer[probabilities.length]; for (int i = 0; i < indices.length; i++) { indices[i] = i; } // 按概率降序排列索引 Arrays.sort(indices, (a, b) -> Float.compare(probabilities[b], probabilities[a])); List<Recognition> results = new ArrayList<>(); for (int i = 0; i < k; i++) { int idx = indices[i]; results.add(new Recognition( labels.get(idx), probabilities[idx], idx )); } return results; }标签文件解析也有一个隐蔽的坑:labels.txt中的行末尾可能包含\r(旧式Mac换行符或Windows行尾),如果直接用line.trim()处理,会把标签里的合法空格也给去掉。更稳妥的做法是只去掉行尾的\r和\n:
private static List<String> readLabels(InputStream inputStream) throws IOException { List<String> labels = new ArrayList<>(); BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); String line; while ((line = reader.readLine()) != null) { labels.add(line.trim()); } return labels; }这里trim()虽然是保守方案,但对纯标签文本来说足够安全,因为它不会把标签内部的空格处理掉——每个标签之间本来就可能有空格,比如“teddy bear”。这个映射关系建好后,把分类结果以覆盖层形式绘制在ImageView上,一个完整的静态图像分类demo就通了。
5. 移动端TFLite图像分类的排查手册:5个典型场景的完整复盘
5.1 模型加载即崩溃:现象多为UnsatisfiedLinkError或IllegalArgumentException
第一次运行App,点击识别按钮直接闪退,Logcat里出现UnsatisfiedLinkError或IllegalArgumentException: Native method not found。这种情况的本质是底层JNI库没有正确加载,或模型文件字节流格式不对。
排查思路有两种。第一种是确认so文件是否被打进APK:用Android Studio的APK Analyzer打开build产物,查看lib/arm64-v8a目录下是否存在libtensorflowlite_jni.so。如果不存在,回到build.gradle检查是否漏了implementation依赖。第二种是检查模型文件本身:手头如果有一个已知能跑的官方模型,先换上去再做对比测试,直接定位是模型问题还是代码问题。
我曾遇到一个耗时近两小时的翻车现场:模型文件是从微信传输助手转发到电脑的,传输过程中被自动改了文件名后缀,从mobilenet_v2.tflite变成了mobilenet_v2.tflite.bin,代码里读的还是旧路径,加载时字节流解析失败但异常被吞掉了。所以读取模型时务必加try-catch并打全日志,任何加载异常都不要静默处理。
5.2 推理返回全0或全1:输出张量形状与模型实际输出对不上
有的模型输出层不是1000个类,可能是1010或1001,因为训练时多加了背景类或做了特殊处理。当代码里写死OUTPUT_CLASSES = 1000时,要么数组越界,要么解析出的概率全部落在错误位置。
解决方法是不要硬编码输出尺寸,而是从Interpreter动态读取:
int outputSize = interpreter.getOutputTensor(0).shape()[1]; output = new float[1][outputSize];同时用前面提到的可视化工具确认模型输出层的真实类别数,再与labels.txt的行数做一致性检查。这三个数字——输出张量第二维、labels.txt行数、代码里的OUTPUT_CLASSES——必须完全相等。
5.3 结果全是乱码或无意义标签:标签排序与模型输出位置错位
模型输出的类别编号与labels.txt中的行号是严格对应的,但这个编号跟训练时的类别索引是否一致,需要先确认。从公开模型库下载的模型和标签基本是配套的,但自己训练的模型经常出现这个问题:训练时用了LabelEncoder把类别按字母序编码,而标签文件却是按原始类别顺序写的。
这个排查起来最磨人:结果看起来有模有样,但“tiger cat”对应到“tabby cat”,“british shorthair”对应到“persian cat”,所有类别整体错位。解决方案是重新拉取和模型训练日志里输出顺序完全一致的labels文件。如果训练日志不在手边,可以用一张特征极其明显的图片做校准:比如拿一张纯红色图片,看模型输出的最大概率位置是不是Red所在的行号,用这个位置反查标签文件第几行,从而判断错位幅度。
5.4 低端设备闪退或OOM:模型一次加载过大与Bitmap未及时回收
老设备上跑demo,首次推理还能通过,但连续识别十几张图后直接OOM闪退。原因往往是每帧输入都新建了ByteBuffer和Bitmap,又没有正确处理Bitmap的回收。Android的Bitmap对象不是纯Java堆内存,而是通过native分配的内存,GC的回收时机不可控。
突破口是让所有大对象在整个生命周期内只创建一次,并复用。尤其是中间裁剪用的createBitmap,每帧都新建的话内存会快速膨胀。还要主动调用recycle()释放不再使用的临时Bitmap。真机实测中,同样的代码在arm64-v8a的8GB内存新机上没有任何问题,但在3GB内存的老设备上坚持不了30次推理,可见内存习惯在移动端属于硬要求。
5.5 识别结果与预期完全相反:预处理细节与训练阶段不一致
有时候模型能跑通、标签也对,但猫图像识别出的却是“toaster”之类完全无关的类别,而且同一张图每次结果还一样。这个问题最常见的原因不是模型坏了,而是预处理与训练时代的预处理不一致。例如,训练时是用双线性插值缩放到224x224,而代码里用的是ImageProcessor默认的ResizeMethod.BILINEAR或手写的最近邻插值;再比如训练时做了标准化(pixel - mean) / std,代码里却直接除以255。
复现这个坑的时间成本最高,因为问题不在代码报错,而在结果逻辑错误。最有效的排查手段是抽出一张训练集的图片,在Python环境里用训练时的预处理流程处理一遍,用转换前的模型跑一次推理,记录Top1结果;再用Android端同样的图、同样的预处理、同样的模型跑一次,对比两个结果是否一致。不一致的位置就是预处理差异所在,通常是归一化方式或插值算法。这里几乎没有捷径,属于典型的“答案就藏在数据流里”的坑。
6. 从静态图片到实时相机分类:一个低成本的进阶改造
静态图片demo跑通之后,下一步往往是把摄像头实时画面接入分类流程。这个升级看起来只是把图片源从相册换成摄像头,实际上有几个工程陷阱需要提前规划。
第一个问题是帧率与性能的矛盾。MobileNetV2在CPU上单帧推理几十毫秒,但摄像头每秒钟产生30帧数据,如果每帧都做完整预处理加推理,必然卡顿。可行的做法是每N帧只处理一帧,跳过中间帧,同时把相机预览分辨率限制在640x480级别,降低预处理开销。N的取值需要实测,在帧率稳定和识别延迟之间折中。
第二个问题是CameraX与TFLite的集成模式。常见做法是用CameraX的ImageAnalysis分析器,在analyze()回调中拿到ImageProxy,转成Bitmap后经过裁剪、缩放、归一化进入分类器。注意analyze()默认在后台线程执行,不要在回调里执行耗时超过帧间隔的操作,否则分析器会自动降帧甚至丢弃图像。
下面是一个精简后的帧率控制思路:
class ImageAnalyzer(private val classifier: Classifier) : ImageAnalysis.Analyzer { private var frameCounter = 0 override fun analyze(image: ImageProxy) { frameCounter++ // 每3帧处理一次 if (frameCounter % 3 != 0) { image.close() return } val bitmap = imageProxyToBitmap(image) val results = classifier.classify(bitmap) // 回调到主线程刷新UI resultsCallback.onResults(results) image.close() } }image.close()必须保证在每次回调结束时调用,否则CameraX会认为缓冲区未释放,预览会卡死。这是相机集成中最高频的Bug,没有之一。
第三个问题是UI刷新的主线程限制:模型推理在后台线程完成后,结果必须通过runOnUiThread或Handler切到主线程才能更新TextView或绘制覆盖框。直接用子线程更新UI会抛异常。还有一种做法是使用LiveData更新UI,让生命周期管理更健壮。
升级到相机实时分类后,你手上的demo就已经具备真实应用的骨架了。我个人的习惯是,每次改完一个环节,都用同一张测试图跑一次静态分类,确保相机链路和静态链路在相同模型与预处理下结果一致。这种从静态到动态的对照验证,远比单纯看相机画面“好像识别对了”更可靠。端侧AI的调试很多问题都靠这种系统性对照来缩小范围,希望能帮到你。
本文还有配套的精品资源,点击获取