大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我怎么用 @kit.CoreVisionKit 的通用文字识别能力,把旅行票据图片自动识别成可编辑的行程信息。
《时光旅记》的旅行模块里有一个"旅行票夹"。用户可以把火车票、飞机票、酒店确认单、门票等凭证集中收纳到某个旅行计划下面。这里真正麻烦的地方不是"保存一条票据记录",而是用户手动填写成本太高:车次、航班号、出发到达、日期、座位、酒店地址,字段很多,而且大多数信息已经在截图或照片里。
所以我在票据编辑面板里加了一个入口:选择图片并智能识别。用户从相册选择一张票据图片后,APP 在端侧把图片解码成 PixelMap,调用 Core Vision Kit 的 textRecognition.recognizeText(),拿到整段 OCR 文本,再结合《时光旅记》的票据模型做字段解析和回填。
官方文档入口是:textRecognition(文字识别)。
我在《时光旅记》里的使用场景
旅行详情页里有一个"旅行票夹"卡片。没有票据时,我会提示"添加票据图片后,可用端侧 OCR 自动识别关键信息"。用户点加号进入票据编辑面板,面板顶部有"选择图片并智能识别"按钮。


整个业务流是这样:
旅行详情页
旅行票夹
添加或编辑票据
选择图片并智能识别
PhotoViewPicker 选择相册图片
CoreFileKit 打开 URI
ImageKit 解码 PixelMap
CoreVisionKit textRecognition.recognizeText
获得 OCR 原始文本
按业务规则解析票据字段
回填票据草稿
用户检查并保存
TravelTicketItem 持久化
我这里没有把 OCR 结果直接保存。原因很简单:OCR 可能把 G101 识别成 G1O1,也可能因为图片角度或压缩质量漏掉座位号。我的设计是"先识别,后检查,再保存"。识别结果会进入草稿字段,用户确认后才写入 TravelTicketItem。
Core Vision Kit OCR 的最小链路
通用文字识别的链路很清楚。
先导入模块:
arkts
import { textRecognition } from '@kit.CoreVisionKit';
页面出现时初始化 OCR 服务,页面消失时释放:
arkts
await textRecognition.init();
await textRecognition.release();
真正识别时,需要把图片变成 PixelMap。我的票据图片来自相册选择器,所以流程是:
票据解析逻辑 textRecognition ImageKit CoreFileKit PhotoViewPicker TravelPlanDetailPage 用户 票据解析逻辑 textRecognition ImageKit CoreFileKit PhotoViewPicker TravelPlanDetailPage 用户 点击选择图片并智能识别 select({ IMAGE_TYPE, maxSelectNumber: 1 }) 返回 photoUris[0] open(uri, READ_ONLY) 返回 fd createImageSource(fd) ImageSource createPixelMap() PixelMap recognizeText({ pixelMap }, config) TextRecognitionResult.value applyTicketOcrText(rawText) 回填草稿字段 release PixelMap close file
TextRecognitionConfiguration 里我打开了朝向检测:
arkts
let configuration: textRecognition.TextRecognitionConfiguration = {
isDirectionDetectionSupported: true
};
票据照片经常不是规整扫描图,可能是手机拍屏、聊天截图、横竖方向不一致的订单图。打开朝向检测会更稳。如果你的业务能保证图片一定是正向的,可以设成 false 换一点性能。
字段解析要贴着业务写
OCR 只能告诉我"图里有哪些文字",它并不知道《时光旅记》里的票据模型。项目里的 TravelTicketItem 包含这些字段:类型、标题、承运方或酒店、票号或订单号、乘客、出发到达、开始结束时间、座位、地址、备注和 ocrText。
所以我把识别分成两层:
第一层是通用 OCR,只负责把图片变成文本。
第二层是旅行票据解析,只负责把文本变成业务草稿。比如看到"乘车日期""检票口""车厢座位"时偏向火车票;看到航班号、登机口、座位号时偏向飞机票;看到"酒店""入住""地址"时偏向酒店确认单。
这里不要追求一次解析百分百正确。票据 OCR 的产品体验重点是减少输入量,不是替用户绕过确认。我的处理规则是:只在草稿字段为空时自动填充,用户已经手填的内容不会被 OCR 覆盖。
完整代码
下面这份代码把《时光旅记》里的 OCR 主链路整理成一个可复用版本。如果要在多个页面复用,可以按这个结构抽成工具类。
JackTravelTicketOcr.ets
arkts
import { Context } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { textRecognition } from '@kit.CoreVisionKit';
export class TravelTicketDraft {
type: string = '火车票';
title: string = '';
provider: string = '';
identifier: string = '';
passengerName: string = '';
departure: string = '';
arrival: string = '';
startTime: string = '';
endTime: string = '';
seatInfo: string = '';
address: string = '';
note: string = '';
ocrText: string = '';
}
export class JackTravelTicketOcr {
async init(): Promise<void> {
try {
await textRecognition.init();
} catch (error) {
console.error(`Failed to init travel ticket OCR: ${JSON.stringify(error)}`);
}
}
async release(): Promise<void> {
try {
await textRecognition.release();
} catch (error) {
console.error(`Failed to release travel ticket OCR: ${JSON.stringify(error)}`);
}
}
async selectImageAndRecognize(_context: Context): Promise<string> {
let picker: photoAccessHelper.PhotoViewPicker = new photoAccessHelper.PhotoViewPicker();
let result: photoAccessHelper.PhotoSelectResult = await picker.select({
MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 1
});
if (result.photoUris.length === 0) {
return '';
}
return await this.recognizeTextFromUri(result.photoUris[0]);
}
async recognizeTextFromUri(uri: string): Promise<string> {
let file = await fs.open(uri, fs.OpenMode.READ_ONLY);
let imageSource: image.ImageSource | undefined = undefined;
let pixelMap: image.PixelMap | undefined = undefined;
try {
imageSource = image.createImageSource(file.fd);
pixelMap = await imageSource.createPixelMap();
let visionInfo: textRecognition.VisionInfo = { pixelMap: pixelMap };
let configuration: textRecognition.TextRecognitionConfiguration = {
isDirectionDetectionSupported: true
};
let result: textRecognition.TextRecognitionResult =
await textRecognition.recognizeText(visionInfo, configuration);
return result.value;
} finally {
if (pixelMap !== undefined) {
pixelMap.release();
}
if (imageSource !== undefined) {
imageSource.release();
}
await fs.close(file);
}
}
applyTextToDraft(rawText: string, draft: TravelTicketDraft): TravelTicketDraft {
let text: string = rawText.trim();
draft.ocrText = text;
if (text.length === 0) {
return draft;
}
let lines: Array<string> = this.normalizeLines(text);
let compactText: string = text.replace(/\s+/g, ' ');
draft.type = this.inferTicketTypeFromText(text);
let trainNo: string = this.extractTrainNumber(compactText, lines);
let flightNo: string = draft.type === '飞机票' ? this.extractFlightNumber(compactText, lines) : '';
let identifier: string = this.matchFirstText(/订单号[::]?\s*([A-Z0-9]{6,24})/, compactText);
if (identifier.length === 0 && draft.type === '火车票') {
identifier = trainNo;
} else if (identifier.length === 0 && draft.type === '飞机票') {
identifier = flightNo;
} else if (identifier.length === 0) {
identifier = this.matchFirstText(/([A-Z]{1,3}\d{2,12})/, compactText);
}
this.fillIfEmpty(draft, 'identifier', identifier);
let dateText: string = this.matchFirstText(
/(\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}[日]?\s*\d{1,2}:\d{2})/,
compactText
);
if (dateText.length === 0 && draft.type === '火车票') {
dateText = this.combineDateAndTime(
this.matchFirstText(/乘车日期[::]?\s*(\d{4}年\d{1,2}月\d{1,2}日)/, compactText),
this.extractTrainDepartureTimeFromTimeBlocks(lines)
);
} else if (dateText.length === 0 && draft.type === '飞机票') {
dateText = this.combineDateAndTime(
this.extractFlightDateText(compactText, lines),
this.extractFlightBoardingTime(lines)
);
}
this.fillIfEmpty(draft, 'startTime', dateText);
let provider: string = this.extractTicketProvider(draft.type, compactText, lines);
if (provider.length === 0 && draft.type === '飞机票') {
provider = this.getAirlineProviderFromFlightNumber(flightNo);
}
this.fillIfEmpty(draft, 'provider', provider);
if (draft.type === '飞机票') {
this.fillIfEmpty(draft, 'passengerName', this.extractFlightPassengerName(lines, compactText));
}
let route: Array<string> = draft.type === '飞机票'
? [this.extractFlightDeparture(lines), this.extractFlightArrival(compactText, lines)]
: this.extractTicketRoute(compactText, lines);
if (route[0].length === 0 && draft.type === '火车票') {
route = this.extractTrainRouteFromTimeBlocks(lines);
}
this.fillIfEmpty(draft, 'departure', route[0]);
this.fillIfEmpty(draft, 'arrival', route[1]);
let seatInfo: string = draft.type === '飞机票'
? this.buildFlightSeatSummary(this.extractFlightGateInfo(lines), this.extractFlightSeatNumber(lines, compactText))
: this.extractTicketSeatInfo(compactText);
this.fillIfEmpty(draft, 'seatInfo', seatInfo);
this.fillIfEmpty(draft, 'address', this.findLine(lines, ['地址', '集合地点']));
if (draft.title.trim().length === 0) {
if (draft.type === '飞机票' && flightNo.length > 0 && draft.departure.length > 0 && draft.arrival.length > 0) {
draft.title = `${flightNo} ${draft.departure}-${draft.arrival}`;
} else if (trainNo.length > 0 && draft.departure.length > 0 && draft.arrival.length > 0) {
draft.title = `${trainNo} ${draft.departure}-${draft.arrival}`;
} else {
draft.title = this.buildTicketTitleFromDraft(draft);
}
}
if (draft.note.trim().length === 0) {
draft.note = text.length > 220 ? text.substring(0, 220) : text;
}
return draft;
}
private fillIfEmpty(draft: TravelTicketDraft, key: string, value: string): void {
let normalizedValue: string = value.trim();
if (normalizedValue.length === 0) {
return;
}
if (key === 'identifier' && draft.identifier.trim().length === 0) {
draft.identifier = normalizedValue;
} else if (key === 'startTime' && draft.startTime.trim().length === 0) {
draft.startTime = normalizedValue;
} else if (key === 'provider' && draft.provider.trim().length === 0) {
draft.provider = normalizedValue;
} else if (key === 'passengerName' && draft.passengerName.trim().length === 0) {
draft.passengerName = normalizedValue;
} else if (key === 'departure' && draft.departure.trim().length === 0) {
draft.departure = normalizedValue;
} else if (key === 'arrival' && draft.arrival.trim().length === 0) {
draft.arrival = normalizedValue;
} else if (key === 'seatInfo' && draft.seatInfo.trim().length === 0) {
draft.seatInfo = normalizedValue;
} else if (key === 'address' && draft.address.trim().length === 0) {
draft.address = normalizedValue;
}
}
private inferTicketTypeFromText(text: string): string {
let compactText: string = text.replace(/\s+/g, ' ');
if (/登机口|航班|BOARDING|GATE|Flight|航站楼|机票/.test(compactText)) {
return '飞机票';
}
if (/车次|检票口|候车|乘车日期|火车票|高铁|动车/.test(compactText)) {
return '火车票';
}
if (/酒店|入住|离店|房型|住宿|前台/.test(compactText)) {
return '酒店';
}
if (/门票|景区|入园|游客|预约/.test(compactText)) {
return '门票';
}
return '其他';
}
private normalizeLines(text: string): Array<string> {
let sourceLines: Array<string> = text.split(/\r?\n/);
let lines: Array<string> = [];
for (let i: number = 0; i < sourceLines.length; i++) {
let line: string = sourceLines[i].replace(/\s+/g, ' ').trim();
if (line.length > 0) {
lines.push(line);
}
}
return lines;
}
private matchFirstText(pattern: RegExp, text: string): string {
let match: RegExpMatchArray | null = text.match(pattern);
if (match === null || match.length < 2) {
return '';
}
return match[1].trim();
}
private extractTrainNumber(text: string, lines: Array<string>): string {
let trainNo: string = this.matchFirstText(/\b([GCDZTKSY]\d{1,5})\b/, text);
if (trainNo.length > 0) {
return trainNo;
}
for (let i: number = 0; i < lines.length; i++) {
trainNo = this.matchFirstText(/车次[::]?\s*([A-Z]\d{1,5})/, lines[i]);
if (trainNo.length > 0) {
return trainNo;
}
}
return '';
}
private extractFlightNumber(text: string, lines: Array<string>): string {
let flightNo: string = this.matchFirstText(/\b([A-Z]{2}\d{3,4})\b/, text);
if (flightNo.length > 0) {
return flightNo;
}
for (let i: number = 0; i < lines.length; i++) {
flightNo = this.matchFirstText(/航班号?[::]?\s*([A-Z0-9]{2,8})/, lines[i]);
if (flightNo.length > 0) {
return flightNo;
}
}
return '';
}
private extractTicketProvider(type: string, text: string, lines: Array<string>): string {
if (type === '火车票') {
if (/中国铁路|铁路12306|12306/.test(text)) {
return '中国铁路';
}
return '';
}
if (type === '飞机票') {
let provider: string = this.findLine(lines, ['航空', 'AIRLINES']);
return provider.length > 30 ? '' : provider;
}
if (type === '酒店') {
let hotel: string = this.findLine(lines, ['酒店', '宾馆', '民宿']);
return hotel.length > 40 ? '' : hotel;
}
return '';
}
private getAirlineProviderFromFlightNumber(flightNo: string): string {
if (flightNo.length < 2) {
return '';
}
let prefix: string = flightNo.substring(0, 2);
if (prefix === 'CZ') {
return '南方航空';
}
if (prefix === 'MU') {
return '东方航空';
}
if (prefix === 'CA') {
return '中国国航';
}
if (prefix === 'HU') {
return '海南航空';
}
if (prefix === 'MF') {
return '厦门航空';
}
return '';
}
private extractTicketRoute(text: string, lines: Array<string>): Array<string> {
let routeText: string = this.matchFirstText(/([\u4e00-\u9fa5A-Za-z]{2,20})\s*[----→至到]\s*([\u4e00-\u9fa5A-Za-z]{2,20})/, text);
if (routeText.length > 0) {
let routeMatch: RegExpMatchArray | null =
text.match(/([\u4e00-\u9fa5A-Za-z]{2,20})\s*[----→至到]\s*([\u4e00-\u9fa5A-Za-z]{2,20})/);
if (routeMatch !== null && routeMatch.length >= 3) {
return [routeMatch[1].trim(), routeMatch[2].trim()];
}
}
let from: string = this.findLine(lines, ['出发', '始发']);
let to: string = this.findLine(lines, ['到达', '终到']);
return [this.removeLabel(from), this.removeLabel(to)];
}
private extractTrainRouteFromTimeBlocks(lines: Array<string>): Array<string> {
let stationLines: Array<string> = [];
for (let i: number = 0; i < lines.length; i++) {
if (this.isLikelyTrainStationLine(lines[i])) {
stationLines.push(lines[i]);
}
}
if (stationLines.length >= 2) {
return [stationLines[0], stationLines[1]];
}
return ['', ''];
}
private isLikelyTrainStationLine(line: string): boolean {
return /^[\u4e00-\u9fa5]{2,12}(站|东|西|南|北)?$/.test(line) && !/乘车|检票|候车|座位/.test(line);
}
private extractTrainDepartureTimeFromTimeBlocks(lines: Array<string>): string {
for (let i: number = 0; i < lines.length - 1; i++) {
if (!/^\d{1,2}:\d{2}$/.test(lines[i])) {
continue;
}
for (let j: number = i + 1; j < lines.length && j <= i + 3; j++) {
if (this.isLikelyTrainStationLine(lines[j])) {
return lines[i];
}
}
}
return '';
}
private combineDateAndTime(dateText: string, timeText: string): string {
if (dateText.length === 0 || timeText.length === 0) {
return dateText.length > 0 ? dateText : timeText;
}
return `${dateText} ${timeText}`;
}
private extractTicketSeatInfo(text: string): string {
let seat: string = this.matchFirstText(/([0-9A-Z]{1,4}车\s*[0-9A-Z]{1,6}号?)/, text);
if (seat.length > 0) {
return seat;
}
seat = this.matchFirstText(/(座位[::]?\s*[0-9A-Z\-]+号?)/, text);
if (seat.length > 0) {
return seat;
}
return this.matchFirstText(/(登机口[::]?\s*[0-9A-Z\-]+)/, text);
}
private extractFlightDateText(text: string, _lines: Array<string>): string {
let dateText: string = this.matchFirstText(/日期[::]?\s*(\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}[日]?)/, text);
if (dateText.length > 0) {
return dateText;
}
return this.matchFirstText(/(\d{1,2}月\d{1,2}日)/, text);
}
private extractFlightBoardingTime(lines: Array<string>): string {
for (let i: number = 0; i < lines.length; i++) {
let time: string = this.matchFirstText(/(?:登机|起飞|时间)[::]?\s*(\d{1,2}:\d{2})/, lines[i]);
if (time.length > 0) {
return time;
}
}
return '';
}
private extractFlightDeparture(lines: Array<string>): string {
let value: string = this.findLine(lines, ['出发', 'FROM']);
return this.removeLabel(value);
}
private extractFlightArrival(text: string, lines: Array<string>): string {
let value: string = this.findLine(lines, ['到达', 'TO']);
if (value.length > 0) {
return this.removeLabel(value);
}
return this.matchFirstText(/到达[::]?\s*([\u4e00-\u9fa5A-Za-z]{2,20})/, text);
}
private extractFlightPassengerName(lines: Array<string>, text: string): string {
let name: string = this.matchFirstText(/(?:姓名|旅客|乘客|PASSENGER)[::]?\s*([\u4e00-\u9fa5A-Za-z ]{2,30})/, text);
if (name.length > 0) {
return name;
}
for (let i: number = 0; i < lines.length; i++) {
if (/旅客|乘客|PASSENGER/.test(lines[i]) && i + 1 < lines.length) {
return lines[i + 1].trim();
}
}
return '';
}
private extractFlightGateInfo(lines: Array<string>): string {
for (let i: number = 0; i < lines.length; i++) {
let gate: string = this.matchFirstText(/(?:登机口|GATE)[::]?\s*([A-Z0-9\-]+)/, lines[i]);
if (gate.length > 0) {
return `登机口 ${gate}`;
}
}
return '';
}
private extractFlightSeatNumber(lines: Array<string>, text: string): string {
let seat: string = this.matchFirstText(/(?:座位|SEAT)[::]?\s*([0-9A-Z\-]+)/, text);
if (seat.length > 0) {
return `座位 ${seat}`;
}
for (let i: number = 0; i < lines.length; i++) {
seat = this.matchFirstText(/([0-9]{1,2}[A-F])/, lines[i]);
if (seat.length > 0) {
return `座位 ${seat}`;
}
}
return '';
}
private buildFlightSeatSummary(gate: string, seat: string): string {
if (gate.length > 0 && seat.length > 0) {
return `${gate} ${seat}`;
}
return gate.length > 0 ? gate : seat;
}
private findLine(lines: Array<string>, keywords: Array<string>): string {
for (let i: number = 0; i < lines.length; i++) {
for (let j: number = 0; j < keywords.length; j++) {
if (lines[i].indexOf(keywords[j]) >= 0) {
return lines[i];
}
}
}
return '';
}
private removeLabel(value: string): string {
return value
.replace(/^(出发|始发|到达|终到|FROM|TO|地址|集合地点)[::]?\s*/i, '')
.trim();
}
private buildTicketTitleFromDraft(draft: TravelTicketDraft): string {
if (draft.departure.trim().length > 0 && draft.arrival.trim().length > 0) {
return `${draft.departure.trim()} - ${draft.arrival.trim()}`;
}
if (draft.provider.trim().length > 0) {
return `${draft.provider.trim()} ${draft.type}`;
}
return draft.type;
}
}
页面接入代码
页面里只需要维护一个 OCR 实例、一个忙碌状态和一份票据草稿。下面是和《时光旅记》当前页面一致的接入方式。
arkts
import { Context } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { promptAction } from '@kit.ArkUI';
import { JackTravelTicketOcr, TravelTicketDraft } from './JackTravelTicketOcr';
@Component
export struct TravelTicketEditorDemo {
@State private isTicketOcrBusy: boolean = false;
@State private ticketDraft: TravelTicketDraft = new TravelTicketDraft();
private readonly ticketOcr: JackTravelTicketOcr = new JackTravelTicketOcr();
aboutToAppear(): void {
void this.ticketOcr.init();
}
aboutToDisappear(): void {
void this.ticketOcr.release();
}
build(): void {
Column({ space: 16 }) {
Button() {
Row({ space: 10 }) {
if (this.isTicketOcrBusy) {
LoadingProgress()
.width(20)
.height(20)
} else {
SymbolGlyph($r('sys.symbol.circle_viewfinder'))
.fontSize(20)
}
Text('选择图片并智能识别')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
.height(52)
.padding({ left: 16, right: 16 })
.borderRadius(18)
.enabled(!this.isTicketOcrBusy)
.onClick(() => {
void this.selectTicketImageAndRecognize();
})
TextInput({ text: this.ticketDraft.title, placeholder: '票据标题' })
.onChange((value: string) => {
this.ticketDraft.title = value;
})
TextInput({ text: this.ticketDraft.identifier, placeholder: '车次、航班号或订单号' })
.onChange((value: string) => {
this.ticketDraft.identifier = value;
})
TextInput({ text: this.ticketDraft.departure, placeholder: '出发/入住' })
.onChange((value: string) => {
this.ticketDraft.departure = value;
})
TextInput({ text: this.ticketDraft.arrival, placeholder: '到达/离店' })
.onChange((value: string) => {
this.ticketDraft.arrival = value;
})
TextArea({ text: this.ticketDraft.ocrText, placeholder: 'OCR 原文' })
.height(120)
.onChange((value: string) => {
this.ticketDraft.ocrText = value;
})
}
.width('100%')
.padding(20)
}
private async selectTicketImageAndRecognize(): Promise<void> {
if (this.isTicketOcrBusy) {
return;
}
let hostContext: Context | undefined = this.getUIContext().getHostContext() as Context | undefined;
if (hostContext === undefined) {
this.showToast('无法访问当前页面上下文');
return;
}
this.isTicketOcrBusy = true;
try {
let rawText: string = await this.ticketOcr.selectImageAndRecognize(hostContext);
this.ticketDraft = this.ticketOcr.applyTextToDraft(rawText, this.ticketDraft);
this.showToast(rawText.trim().length > 0 ? '已识别票据信息,请检查后保存' : '未识别到文字,可手动填写');
} catch (error) {
let businessError: BusinessError = error as BusinessError;
this.showToast(
businessError.message && businessError.message.length > 0 ? businessError.message : '票据识别失败'
);
} finally {
this.isTicketOcrBusy = false;
}
}
private showToast(message: string): void {
promptAction.showToast({
message: message,
duration: 1600
});
}
}
保存到旅行票夹
识别只是填草稿,保存时再把草稿转成业务模型。项目里的 TravelTicketItem 有一个 ocrText 字段,我会把 OCR 原文一起存下来,方便用户后面回看,也方便后续优化解析规则。
arkts
private saveTicketDraft(): void {
let nextItem: TravelTicketItem = new TravelTicketItem();
nextItem.type = this.ticketDraft.type;
nextItem.title = this.ticketDraft.title.trim().length > 0 ? this.ticketDraft.title.trim() : this.ticketDraft.type;
nextItem.provider = this.ticketDraft.provider.trim();
nextItem.identifier = this.ticketDraft.identifier.trim();
nextItem.passengerName = this.ticketDraft.passengerName.trim();
nextItem.departure = this.ticketDraft.departure.trim();
nextItem.arrival = this.ticketDraft.arrival.trim();
nextItem.startTime = this.ticketDraft.startTime.trim();
nextItem.endTime = this.ticketDraft.endTime.trim();
nextItem.seatInfo = this.ticketDraft.seatInfo.trim();
nextItem.address = this.ticketDraft.address.trim();
nextItem.note = this.ticketDraft.note.trim();
nextItem.ocrText = this.ticketDraft.ocrText.trim();
nextItem.updatedAt = new Date().toISOString();
// 这里接你的持久化逻辑。我的项目里会写入 travel_ticket_items 表,
// 并同步更新当前 TravelPlan 的 ticketItems。
}
接入时要注意的细节
PhotoViewPicker 返回的是 URI,不是图片字节。要先用 CoreFileKit 打开,再交给 ImageKit 创建 ImageSource 和 PixelMap。
VisionInfo 当前传的是 PixelMap。OCR 结束后要释放 PixelMap,文件句柄也要关闭。这个逻辑建议放在 finally,不要只写在成功分支。
textRecognition.init() 和 release() 不要每识别一张图就反复调用。我的处理是进入旅行详情页时初始化,离开页面时释放。票据识别按钮只负责选择图片和识别。
OCR 原文建议保存。解析规则后续一定会迭代,有原文就能重新分析为什么某张票没填出字段。
小结
这套能力落到旅行票夹里,核心价值不是"识别出一段文字",而是把用户要填的字段提前补上。Core Vision Kit 负责端侧 OCR,业务层负责票据语义解析,最后让用户检查再保存。这样既能减少输入,又不会把 OCR 的不确定性直接写进用户数据。