在手机上做 OCR,常见的做法是拍照后把图片传到服务端识别,百度、腾讯、阿里、有道都有这类接口,也不算贵。但这种做法有三个绕不开的问题:必须联网、图片要上传到别人的服务器、识别一次算一次钱。

Google 的 ML Kit 提供了一套完全在本地跑的文字识别,模型和推理库都能跟着 APK 一起打包。也就是说用户装上应用之后,没网也能识别,图片不出手机,识别次数也没有限制。

不过这套东西在国内用有一个前提:必须选对依赖。ML Kit 的文字识别有两套坐标,一套的模型跟着 APK 走,另一套要从 Google Play 服务里下载。选错的话,代码在本机跑得好好的,换一台没装 Google 服务的手机就直接报错。这篇文章把这一步和其他几个容易踩的地方一起写清楚。

注意,下面写的是 Java 调用,不是 Flutter 或 React Native。

由于我的这个 Java Android 项目大部分都是 AI 完成的,所以这篇文章也是 AI 根据我的项目和开发过程生成的,开发过程包含多次测试和调试,这篇文章算是一个记录和总结,不是随便生成的。
如果你要使用和打包 Google ML Kit,也可以简单参考这篇文章,如果你用 AI 开发,也可以把这篇文章扔给 AI 参考,应该可以少踩很多坑。

选对依赖

ML Kit 的依赖分两个 groupId,看名字很像,行为完全不同:

groupId例子模型在哪里
com.google.mlkittext-recognition-chinese跟着 APK 打包,装完就能用
com.google.android.gmsplay-services-mlkit-text-recognition-chinese首次使用时从 Google Play 服务下载

国内要用的显然是第一套。这里还有一点容易误解:com.google.mlkit 的包并不是「薄薄一层 API」,模型文件是真的在它的 AAR 里。我把两个包解开对比过:

  • play-services-mlkit-text-recognition-chinese:16.0.1 的 AAR 只有 78 KB,里面除了一个 ModuleDescriptor 和一个描述文件什么都没有,模型是靠 Google Play 服务去下的;
  • text-recognition-chinese:16.0.1 的 AAR 有 2 MB,里面带着 assets/mlkit-google-ocr-models/ 目录,中文和拉丁字母的模型都在里面。

所以看到网上说「ML Kit 需要 Google Play 服务」的说法,那说的是第二套依赖,跟我们要用的这套不是一回事。

顺带说一句,ML Kit 的文字识别在中文上只有两个选择:

  • text-recognition:只认拉丁字母,也就是英文、拼音这些;
  • text-recognition-chinese:认简体中文 和 拉丁字母。

如果你的应用要认中文,装 text-recognition-chinese 就够了,不需要装两个(它的模型本身就带拉丁字母)。另外还有 text-recognition-japanese、text-recognition-korean、text-recognition-devanagari,按需再加。

安装

在 app/build.gradle 的 dependencies 里加两行:

dependencies {
    // 简体中文 + 拉丁字母(英文)的文字识别,模型随 APK 打包
    implementation 'com.google.mlkit:text-recognition-chinese:16.0.1'
    // 提供 TextRecognizerOptions、ChineseTextRecognizerOptions 这些配置类
    implementation 'com.google.mlkit:text-recognition:16.0.1'
}

第二行是有坑的:ChineseTextRecognizerOptions 这个类不在 text-recognition-chinese 里,而在配套的 text-recognition 里。只加第一行的话,代码里写 new ChineseTextRecognizerOptions.Builder() 会直接编译不过,报「找不到符号」。我第一次就是只加了一行,在 com.google.mlkit.vision.text.chinese 这个包里翻了半天。

版本号建议两个包保持一致,都是 16.0.1。这两个包的镜像在 Google 的 Maven 仓库上,如果下载慢或失败,在项目根目录的 settings.gradle 里加一下阿里云的镜像:

dependencyResolutionManagement {
    repositories {
        maven { url 'https://maven.aliyun.com/repository/google' }
        maven { url 'https://maven.aliyun.com/repository/public' }
        google()
        mavenCentral()
    }
}

com.google.mlkit 和它依赖的 com.google.android.gms 系列包,阿里云的 google 镜像里都有。

最简单的调用

先看一个最小可用的例子,把图片识别成文字:

// 创建识别器,整个应用创建一次就够了,后面复用
TextRecognizer recognizer = TextRecognition.getClient(
        new ChineseTextRecognizerOptions.Builder().build());

// 从文件路径或 content Uri 创建输入图片
InputImage image = InputImage.fromFilePath(context, imageUri);

// 提交识别任务
recognizer.process(image)
        .addOnSuccessListener(result -> {
            // result 里包含了识别出的全部文字
            Log.d("OCR", result.getText());
        })
        .addOnFailureListener(e -> {
            Log.e("OCR", "识别失败", e);
        });

result.getText() 返回的是整段文字,块与块之间用换行拼接,适合直接丢给输入框或者朗读。

InputImage.fromFilePath() 支持 file:// 和 content:// 两种 Uri,相册选出来的 content://、相机写入 FileProvider 的 content:// 都能直接用。这一点省了不少事:直接传路径就不用自己处理 Uri 转流、转 Bitmap 了。

有一个容易忽略的细节:EXIF 里的旋转信息 fromFilePath() 会自己处理。手机拍的照片常常是横着存的,靠 EXIF 标记方向,这个方法读的时候会把方向正过来。但如果你是自己把图片解码成 Bitmap 再 InputImage.fromBitmap(bitmap, 0),第二个参数得手动传旋转角度,不然识别出来的文字位置和方向都是错的。

用完记得释放:

recognizer.close();

TextRecognizer 内部持有模型和原生资源,重复创建会反复加载模型,既慢又占内存。在应用里持有一份就好(放在某个单例里),不要每次识别都 new 一个。

拿到结构化的结果:文字、行列和位置

只拿整段文字的话 getText() 就够,但 OCR 应用往往还需要位置信息,比如在图片上画出识别框、点击某一行朗读、把某一行单独丢给翻译。这就需要往下拆 Text 的结构。

ML Kit 的结果是一棵四层的树:

Text                         整张图的识别结果
 └─ TextBlock                一个文字块(一般对应一个段落或一列文字)
     └─ Line                 一行
         └─ Element         一个「词」或连续的词组
             └─ Symbol       单个字符

上面每一层都有两个共同的能力:

  • getText():这一层识别出的文字;
  • getBoundingBox():这一层在图片里的矩形范围,单位是图片的像素坐标。

Line 还多了两个属性:getConfidence() 是置信度,getAngle() 是这一行的倾斜角度。

下面遍历出每一行的文字和位置,result 就是识别成功后拿到的 Text:

List<Text.Line> lines = new ArrayList<>();

for (Text.TextBlock block : result.getTextBlocks()) {
    for (Text.Line line : block.getLines()) {
        // 跳过空行
        if (line.getText().trim().isEmpty()) continue;
        lines.add(line);
        Rect box = line.getBoundingBox();  // 可能是 null,用之前判断一下
        Log.d("OCR", line.getText() + " -> " + box);
    }
}

如果只想要「每行一项」的文字列表,把 line.getText() 收进 List 就行,和在线接口返回的那种逐行结果是同一形态。

要拆到字符级别(比如做文字选择、逐字校对)就继续往下遍历:

for (Text.TextBlock block : result.getTextBlocks()) {
    for (Text.Line line : block.getLines()) {
        for (Text.Element element : line.getElements()) {
            for (Text.Symbol symbol : element.getSymbols()) {
                // 单个字符
                Log.d("OCR", symbol.getText() + " @ " + symbol.getBoundingBox());
            }
        }
    }
}

symbol.getText() 返回的是单个字符,中文也是按字拆的。

在图片上画出识别框

拿到了每一行的 Rect,就可以在图片上画框。这里有一个坐标系的问题:Rect 的坐标是原图像素的坐标,而 ImageView 显示的图片通常被缩放过,直接画会错位。

如果 ImageView 用的是 fitCenter(默认行为),可以做这样的换算:

/**
 * 把原图坐标系的矩形换算成 View 坐标系的矩形。
 *
 * @param box       原图上的矩形
 * @param imageW    原图宽
 * @param imageH    原图高
 * @param imageView 显示图片的 ImageView
 */
private static RectF mapBox(Rect box, int imageW, int imageH, ImageView imageView) {
    float viewW = imageView.getWidth();
    float viewH = imageView.getHeight();
    // fitCenter:取较小的缩放比,保证整张图都能放下
    float scale = Math.min(viewW / imageW, viewH / imageH);
    float drawW = imageW * scale;
    float drawH = imageH * scale;
    // 居中后图片左上角在 View 里的偏移
    float offsetX = (viewW - drawW) / 2f;
    float offsetY = (viewH - drawH) / 2f;

    return new RectF(
            offsetX + box.left * scale,
            offsetY + box.top * scale,
            offsetX + box.right * scale,
            offsetY + box.bottom * scale);
}

然后在一个叠在 ImageView 上面的自定义 View 里把矩形画出来:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    for (Text.Line line : lines) {
        Rect box = line.getBoundingBox();
        if (box == null) continue;
        RectF mapped = mapBox(box, imageWidth, imageHeight, imageView);
        canvas.drawRect(mapped, boxPaint);           // 边框
        canvas.drawText(line.getText(), mapped.left, mapped.top - 6, textPaint);  // 文字
    }
}

文字建议画在框的上方或下方,直接盖在框里会压住原图上的字。框的画笔用 Paint.Style.STROKE,线宽别超过 3dp,不然小图上会糊成一片。

完整示例

把上面这些拼起来,做成一个「选张图片 → 后台识别 → 显示文字和位置」的完整例子。识别本身是异步任务,为了代码好读,我把它包成一个同步方法,放在子线程里调用,这样调用方不用处理回调嵌套。

package com.example.ocr;

import android.content.Context;
import android.graphics.Rect;
import android.net.Uri;

import com.google.android.gms.tasks.Tasks;
import com.google.mlkit.vision.common.InputImage;
import com.google.mlkit.vision.text.Text;
import com.google.mlkit.vision.text.TextRecognition;
import com.google.mlkit.vision.text.TextRecognizer;
import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * ML Kit 离线文字识别的封装。
 *
 * 识别器(TextRecognizer)内部持有模型和原生资源,创建一次就一直复用,
 * 所以这里做成单例;识别放在自己的工作线程里执行,结果通过回调返回。
 */
public class MlKitOcr {

    /** 识别结果里的一行:文字 + 在图片里的位置。 */
    public static class Line {
        public final String text;
        public final Rect box;

        Line(String text, Rect box) {
            this.text = text;
            this.box = box;
        }
    }

    /** 识别完成的回调,运行在调用线程(这里是工作线程)。 */
    public interface Callback {
        void onSuccess(List<Line> lines);
        void onError(Exception e);
    }

    private static final long TIMEOUT_SECONDS = 60;

    private static volatile TextRecognizer recognizer;
    private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();

    /** 单例识别器,第一次调用时创建(会加载模型,稍慢)。 */
    private static TextRecognizer recognizer() {
        TextRecognizer value = recognizer;
        if (value == null) {
            synchronized (MlKitOcr.class) {
                value = recognizer;
                if (value == null) {
                    value = TextRecognition.getClient(
                            new ChineseTextRecognizerOptions.Builder().build());
                    recognizer = value;
                }
            }
        }
        return value;
    }

    /**
     * 识别图片里的文字。
     *
     * @param context  用来解析 Uri
     * @param imageUri 图片地址,file:// 或 content:// 都可以
     * @param callback 识别结果回调
     */
    public static void recognize(Context context, Uri imageUri, Callback callback) {
        final Context appContext = context.getApplicationContext();
        EXECUTOR.execute(() -> {
            try {
                // 图片较大时可以先压一下再识别,见下面「可能遇到的问题」
                InputImage image = InputImage.fromFilePath(appContext, imageUri);
                // 把异步任务转成同步等待,超时时间防止极端情况下一直挂着
                Text result = Tasks.await(recognizer().process(image),
                        TIMEOUT_SECONDS, TimeUnit.SECONDS);
                callback.onSuccess(toLines(result));
            } catch (ExecutionException e) {
                // 真正的失败原因(比如缺运行环境)在 cause 里
                Throwable cause = e.getCause() != null ? e.getCause() : e;
                callback.onError(new Exception(cause.getMessage(), cause));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                callback.onError(e);
            } catch (Exception e) {
                // 图片读不出来、原生库加载失败等
                callback.onError(e);
            }
        });
    }

    /** 把识别结果摊平成「每行一项」。 */
    private static List<Line> toLines(Text text) {
        List<Line> lines = new ArrayList<>();
        for (Text.TextBlock block : text.getTextBlocks()) {
            for (Text.Line line : block.getLines()) {
                if (line.getText().trim().isEmpty()) continue;
                lines.add(new Line(line.getText(), line.getBoundingBox()));
            }
        }
        return lines;
    }

    /** 释放识别器,进程退出或需要重新加载模型时调用。 */
    public static void release() {
        synchronized (MlKitOcr.class) {
            if (recognizer != null) {
                recognizer.close();
                recognizer = null;
            }
        }
    }
}

界面侧的用法(从一个按钮或相册回调里调用):

MlKitOcr.recognize(getContext(), imageUri, new MlKitOcr.Callback() {
    @Override
    public void onSuccess(List<MlKitOcr.Line> lines) {
        // 结果回到主线程再刷新界面
        runOnUiThread(() -> {
            StringBuilder text = new StringBuilder();
            for (MlKitOcr.Line line : lines) {
                text.append(line.text).append('\n');
            }
            textView.setText(text.toString().trim());
            // lines 里的 box 就是画框要用的坐标
            overlayView.setLines(lines);
        });
    }

    @Override
    public void onError(Exception e) {
        runOnUiThread(() -> Toast.makeText(getContext(),
                "识别失败:" + e.getMessage(), Toast.LENGTH_LONG).show());
    }
});

如果项目里已经有 RxJava,用 Observable.create 包一层会更顺手;用 Kotlin 的话 kotlinx-coroutines-play-services 提供的 await() 也能把 Task 直接变成挂起函数,不用自己写线程池。上面的写法不依赖任何第三方库,纯 Java 就能跑。

打包:把模型打进 APK

用 com.google.mlkit 这套依赖的话,模型和原生库在构建时就合并进 APK 了,打包这一步不需要额外配置,也没有「首次运行时下载模型」的等待。构建完之后可以解压 APK 确认一下:

unzip -l app-debug.apk | grep -i mlkit

会看到两类东西:

assets/mlkit-google-ocr-models/...                              模型文件(30 个左右)
lib/arm64-v8a/libmlkit_google_ocr_pipeline.so                   推理用的原生库

代价是体积。按我自己的实测(只开了 arm64-v8a):

内容大小
assets/mlkit-google-ocr-models/约 2.4 MB
lib/arm64-v8a/libmlkit_google_ocr_pipeline.so约 10.5 MB
APK 总大小变化约 7 MB → 约 21 MB

原生库占了大部分,而且它是按 ABI 分开的,armeabi-v7a、x86、x86_64 各有一份,都在 6~11 MB 之间。如果不做分架构打包,APK 会把四份全塞进去,体积直接翻倍。国内上架的应用大多只留 arm64-v8a(Android 7 之后的 64 位机器都是这个),在 build.gradle 里加:

android {
    splits {
        abi {
            enable true
            reset()
            include 'arm64-v8a'   // 只出 64 位 ARM
            universalApk false    // 不再生成包含全部架构的通用包
        }
    }
}

开了这个之后产物名会变成 app-arm64-v8a-debug.apk。如果想同时保留一个「什么机器都能装」的包,把 universalApk 改成 true,它会另外生成一个通用包,但那个包会把所有 ABI 的原生库都打进去,体积就是四份之和。

另外可以用 R8 去掉用不到的代码,但对模型和原生库的体积没有影响——那是资源,不是代码。

可能遇到的问题

报错「MlKitException」,错误码看不懂

ML Kit 的失败会抛 MlKitException(com.google.mlkit.common.MlKitException),里面带一个 errorCode。排查的时候把这个错误码打出来,能省很多时间:

} catch (ExecutionException e) {
    Throwable cause = e.getCause();
    if (cause instanceof MlKitException) {
        MlKitException exception = (MlKitException) cause;
        // 错误码对应官方文档里的原因,比如模型加载失败、运行环境缺失
        Log.e("OCR", "错误码 " + exception.getErrorCode(), exception);
    }
}

Tasks.await() 会把真正的异常包在 ExecutionException 里,直接看 e.getMessage() 往往只能看到一层壳,一定要拆到 getCause()。用 addOnFailureListener 的回调形式时就不用拆了,回调收到的已经是原始异常。

应用能编译,装上后识别失败

先确认用的是 com.google.mlkit 而不是 com.google.android.gms 那套。如果确认没错,再看设备上有没有 Google 的运行环境。

这一点需要说清楚,避免走弯路:模型确实在 APK 里、识别过程也确实不联网、不需要 Google Play 商店;但 ML Kit 的客户端代码仍然依赖 play-services-base、play-services-basement 这些运行时库(它们会被 text-recognition 自动带进来)。在一台完全没有 Google 组件的国内定制 ROM 上,识别会失败并抛出带错误码的 MlKitException(把错误码打出来的方法见上一节)。

如果你的目标机器完全没有 Google 组件,ML Kit 这条路走不通,得换纯本地的方案,比如 PaddleOCR 的 ncnn 版本、Tesseract(中文需要额外装语言包,效果一般)。这一点建议先在真机上验证再决定技术选型,别等做完了才发现跑不起来。

识别速度慢

几个可以试的方向,按收益排序:

  1. 复用识别器。每次识别都 getClient() 会反复加载模型,第一次可能要一两秒。把它放在单例里,第一次识别之后就是复用的。
  2. 识别前先压图。InputImage.fromFilePath() 会读原图,相机拍的照片动辄 4000×3000,解码和推理都白花时间。可以先按最长边压到 2000 像素左右再交给 ML Kit:
// 先把图片按最长边压到 2000px,再交给识别器
Bitmap bitmap = decodeScaled(context, imageUri, 2000);
InputImage image = InputImage.fromBitmap(bitmap, 0);
// 识别结束后记得回收
bitmap.recycle();

用 InputImage.fromBitmap() 时要自己处理 EXIF 方向(见前面),fromFilePath() 才帮你转。

  1. 别在主线程调用。识别是 CPU 密集的,放在主线程会卡界面,严重时还会被系统判定为 ANR。放到工作线程里(上面的例子用的是单线程池),结果再回主线程刷界面。

文字位置画出来是偏的

八成是坐标系没换算(原图像素 vs View 尺寸),或者图片被 fitCenter / centerCrop 缩放了。上面「在图片上画出识别框」那一段是按 fitCenter 写的:缩放比取 Math.min,图片居中,四周留白。如果 ImageView 用的是 centerCrop,改成缩放比取 Math.max、多出来的部分被裁掉——这时原图的左上角通常落在 View 外面,两个方向的偏移里至少有一个是负数(比如图比 View 宽就是要往左移),公式不用改,把 scale 换成 Math.max 就行,负号是算出来的,不用手写。

另外 getBoundingBox() 返回的矩形在个别行上可能是 null(比如识别到但定位失败),画之前判断一下。

中文识别不出来,或者英文夹杂着乱码

检查是不是装了 text-recognition 而不是 text-recognition-chinese。text-recognition 只有拉丁字母的模型,遇到中文会当成噪声,返回的文字要么是空的,要么是零星的字母。中文用 ChineseTextRecognizerOptions,它同时认中文和英文,不需要为英文单独加一个。

中文竖排或倾斜的文字识别不准

ML Kit 的文字识别是按行处理的,对竖排文字的切分不理想,倾斜太多也会影响。可以先用图像处理把图片转正(Line.getAngle() 能拿到倾斜角度),再重新识别一次。如果业务上确实有大量竖排中文的场景,ML Kit 不是一个好选择。

和在线接口怎么选

最后说一下我自己的取舍,供参考:

场景建议
有网、对识别精度要求高、不在乎把图片传出去用在线接口,中文场景下大厂的识别率还是更好
没网、网络不稳定、图片涉隐私用 ML Kit 离线
识别量很大,不想按次付费用 ML Kit 离线
机器完全没有 Google 组件都别用 ML Kit,换 PaddleOCR 之类的纯本地方案

比较实用的做法是两套都留着:默认用离线的,省流量也保护隐私;识别结果明显不对时再让用户切到在线接口。两套的结果格式都统一成「每行一项」,界面层不需要区分是哪套识别的。