HarmonyOS实现快递APP自动识别地址(国际版)
大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!
在之前的文章中《HarmonyOS实现快递APP自动识别地址》,已经通过HarmonyOS实现了快递APP的收货地址智能识别、自动填充功能。在此基础上,本文添加英文版,研究鸿蒙应用国际化和本地化的实现方法。
实现效果如下图所示:
中文效果 | 英文效果 |
---|---|
![]() | ![]() |
一、国际化与本地化
不同地区用户的语言、文化背景各不相同。因此,应用发布面向不同地区版本时,需要充分识别语言、地区和文化的差异。通过国际化和本地化过程,可使应用界面显示符合当地用户的使用习惯,扩大应用潜在市场。
1、国际化(I18n)
国际化(Internationalization,I18n)是系统提供的一套能力集,支持设置区域特性、时区和夏令时等,满足应用多语言多文化的设计需求。
国际化通常在应用设计开发阶段,设计和开发过程中不设定用户使用的语言,采用通用设计。
2、本地化(L10n)
本地化(Localization,L10n)在应用定制阶段,是开发者为满足不同地区用户在语言和文化方面的需求,针对具体的目标语言对应用进行翻译和定制,过程包括配置多语言等资源翻译、敏感禁忌检查和语言测试。
为保证应用界面加载显示符合所在区域使用习惯的内容,需要配置不同语言或方言的资源。资源翻译是本地化过程的一个基本步骤,资源经翻译后才能形成多语言资源,主要是UI元素翻译。翻译后,UI元素按类型(如图片、音视频)加载至相应语言的应用资源文件中。界面加载资源时,根据系统当前语言或应用偏好语言加载。
在鸿蒙原生应用开发中,HarmonyOS SDK提供了Localization Kit(本地化开发服务)来实现国际化和本地化。
二、快递APP国际化
鸿蒙应用国际化处理,与Android和iOS基本一致,都是通过JSON配置不同的语言文本内容。在UI展示时,使用JSON配置的字段key进行调用,系统选择对应语言文本内容。
实现思路:以中文和英文为例,首先分别在资源文件对应的JSON文件中配置key和value。然后再UI界面中将之前写死的文本改为读取JSON文件即可。
zh_CN为中文,en_US为英文,base-element中的string.json是默认配置,默认配置是必填。
1、创建语言资源文件夹
(1)创建英文资源
在resources目了下,创建英文资源目录及文件en_US/element/string.json,将界面上显示的文本(所有需要做本地化的文本)抽取出来,以为key和value的形式写入string.json文件中。文件内容如下:
{"string": [{"name": "module_desc","value": "module description"},{"name": "EntryAbility_desc","value": "description"},{"name": "EntryAbility_label","value": "CoreVisionKitOCR"},{"name": "page_title","value": "Picture recognition"},{"name": "page_subtitle","value": "New address"},{"name": "dialog_title","value": "Picture recognition"},{"name": "dialog_subtitle","value": "Image capture options"},{"name": "dialog_camera_btn","value": "Photograph"},{"name": "dialog_photo_btn","value": "Album"},{"name": "dialog_cancel_btn","value": "cancel"},{"name": "loading_content","value": "Recognition..."},{"name": "input_placeholder","value": "Text recognized from images will appear here, automatically identifying shipping information.Example: Lili, 139******* "},{"name": "open_dialog_btn","value": "Recognition"},{"name": "parse_btn","value": "Parse"},{"name": "empty_toast","value": "Please enter the text to be parsed"},{"name": "save_btn","value": "Saving address"},{"name": "save_success_toast","value": "Saving successfully"},{"name": "list_name_label","value": "Name"},{"name": "list_phone_label","value": "Phone"},{"name": "list_address_label","value": "Address"},{"name": "list_name_placeholder","value": "Consignee's name"},{"name": "list_phone_placeholder","value": "Consignee's phone number"},{"name": "list_address_placeholder","value": "Full address and house number"}]
}
(2)创建默认资源
以上文件创建成功后,会出现错误提示。提示在默认的本地化环境中没有以上对应的翻译项。将以上string.json的内容拷贝到rescources/base/element/string.json文件中即可。
(3)创建中文资源
接着在resources目了下,创建中文资源目录及对应文件:zh_CN/element/string.json。文件内容如下:
{"string": [{"name": "module_desc","value": "module description"},{"name": "EntryAbility_desc","value": "description"},{"name": "EntryAbility_label","value": "CoreVisionKitOCR"},{"name": "page_title","value": "图片识别"},{"name": "page_subtitle","value": "新增地址"},{"name": "dialog_title","value": "图片识别"},{"name": "dialog_subtitle","value": "选择获取图片的方式"},{"name": "dialog_camera_btn","value": "拍照"},{"name": "dialog_photo_btn","value": "相册"},{"name": "dialog_cancel_btn","value": "取消"},{"name": "loading_content","value": "图片识别中"},{"name": "input_placeholder","value": "图片识别的文本到此处,将自动识别收货信息 例:丽丽,139*******,广东省深圳市宝安区某小区"},{"name": "open_dialog_btn","value": "图片识别"},{"name": "parse_btn","value": "识别"},{"name": "empty_toast","value": "请输入要识别的文本"},{"name": "save_btn","value": "保存地址"},{"name": "save_success_toast","value": "保存成功"},{"name": "list_name_label","value": "收货人"},{"name": "list_phone_label","value": "电话"},{"name": "list_address_label","value": "地址"},{"name": "list_name_placeholder","value": "收货人姓名"},{"name": "list_phone_placeholder","value": "收货人电话"},{"name": "list_address_placeholder","value": "详细地址与门牌号"}]
}
这样就准备好了两种语言:中文和英文。
2、UI界面内容本地化
将之前在界面上写死的文本,全部换为读取以上配置的JSON文件对应的key,语法为:$r(‘app.string.XXX’),其中的XXX即为JSON文件中配置的name。
例如:
//原来写法
Text('新增收货地址')//全部替换为:
Text($r('app.string.page_title'))
由于之前界面中的收货地址是抽象到了ConsigneeInfo中,因此界面显示相关的提示也需要改为从配置文件中读取,为了操作方面,封装常量类。在ets/common目录下新建constants目录,新建CommonConstants.ets文件,内容如下:
export default class CommonConstants {static NAME_LABEL: ResourceStr = $r('app.string.list_name_label');static PHONE_LABEL: ResourceStr = $r('app.string.list_phone_label');static ADDRESS_LABEL: ResourceStr = $r('app.string.list_address_label');static NAME_PLACEHOLDER: ResourceStr = $r('app.string.list_name_placeholder');static PHONE_PLACEHOLDER: ResourceStr = $r('app.string.list_phone_placeholder');static ADDRESS_PLACEHOLDER: ResourceStr = $r('app.string.list_address_placeholder');
}
接下来修改所有界面文件如下:
Index.ets文件
import { ConsigneeInfo,Params} from '../viewmodel/DataModel';
import {ConsigneeInfoItem} from '../components/ConsigneeInfoItem'
import { PromptActionManager } from '../common/utils/PromptActionManager';
import { ComponentContent, LoadingDialog } from '@kit.ArkUI';
import { dialogBuilder } from '../components/dialogBuilder';
import { AddressParse } from '../common/utils/AddressParse';
import { i18n } from '@kit.LocalizationKit';
import CommonConstants from '../common/constants/CommonConstants';@Entry
@Component
struct Index {@State consigneeInfos: ConsigneeInfo[] = []; //收货人信息界面视图@State saveAvailable: boolean = false; //保存按钮是否可用@State ocrResult: string = '';private uiContext: UIContext = this.getUIContext();private resultController: TextAreaController = new TextAreaController();private loadingController: CustomDialogController = new CustomDialogController({builder: LoadingDialog({content: $r('app.string.loading_content')}),autoCancel: false});private contentNode: ComponentContent<Object> =new ComponentContent(this.uiContext, wrapBuilder(dialogBuilder),new Params(this.uiContext, this.resultController, this.loadingController));aboutToAppear(): void {this.consigneeInfos = [new ConsigneeInfo(CommonConstants.NAME_LABEL, CommonConstants.NAME_PLACEHOLDER, ''),new ConsigneeInfo(CommonConstants.PHONE_LABEL, CommonConstants.PHONE_PLACEHOLDER, ''),new ConsigneeInfo(CommonConstants.ADDRESS_LABEL, CommonConstants.ADDRESS_LABEL, ''),];PromptActionManager.setCtx(this.uiContext);PromptActionManager.setContentNode(this.contentNode);PromptActionManager.setOptions({alignment: DialogAlignment.Center,});}// 保存地址,清空内容clearConsigneeInfos() {for (const item of this.consigneeInfos) {item.value = '';}this.ocrResult = '';}build() {RelativeContainer() {// 界面主体内容Column() {Text($r('app.string.page_title')).id('title').width('100%').font({ size: 26, weight: 700 }).fontColor('#000000').opacity(0.9).height(64).align(Alignment.TopStart)Text($r('app.string.page_subtitle')).width('100%').padding({ left: 12, right: 12 }).font({ size: 14, weight: 400 }).fontColor('#000000').opacity(0.6).lineHeight(19).margin({ bottom: 8 })// 识别或填写区域Column() {TextArea({placeholder: $r('app.string.input_placeholder'),controller: this.resultController,text: $$this.ocrResult}).height(100).margin({ bottom: 12 }).backgroundColor('#FFFFFF')Row({ space: 12 }) {Button() {Row({ space: 8 }) {Text() {SymbolSpan($r('sys.symbol.camera')).fontSize(26).fontColor(['#0A59F2'])}Text($r('app.string.open_dialog_btn')).fontSize(16).fontColor('#0A59F2')}}.height(40).layoutWeight(1).backgroundColor('#F1F3F5').onClick(() => {PromptActionManager.openCustomDialog();})Button($r('app.string.parse_btn')).height(40).layoutWeight(1).onClick(() => {if (!this.ocrResult || !this.ocrResult.trim()) {this.uiContext.getPromptAction().showToast({ message: $r('app.string.empty_toast')});return;}this.consigneeInfos = AddressParse.extractInfo(this.ocrResult, this.consigneeInfos);})}.width('100%').padding({left: 16,right: 16,})}.backgroundColor('#FFFFFF').borderRadius(16).padding({top: 16,bottom: 16})// 收货人信息Column() {List() {//列表渲染,避免重复代码ForEach(this.consigneeInfos, (item: ConsigneeInfo) => {ListItem() {//抽取为组件ConsigneeInfoItem(item,() => {if (this.consigneeInfos[0].value && this.consigneeInfos[1].value && this.consigneeInfos[2].value) {this.saveAvailable = true;} else {this.saveAvailable = false;}})}}, (item: ConsigneeInfo, index: number) => JSON.stringify(item) + index)}.width('100%').height(LayoutPolicy.matchParent).scrollBar(BarState.Off).divider({ strokeWidth: 0.5, color: '#33000000' }).padding({left: 12,right: 12,top: 4,bottom: 4}).borderRadius(16).backgroundColor('#FFFFFF')}.borderRadius(16).margin({ top: 12 }).constraintSize({ minHeight: 150, maxHeight: '50%' }).backgroundColor('#FFFFFF')}// 保存按钮if (this.saveAvailable) {// 可用状态Button($r('app.string.save_btn'), { stateEffect: true }).width('100%').alignRules({bottom: { anchor: '__container__', align: VerticalAlign.Bottom }}).onClick(() => {if (this.consigneeInfos[0].value && this.consigneeInfos[1].value && this.consigneeInfos[2].value) {this.uiContext.getPromptAction().showToast({ message: $r('app.string.save_success_toast') });this.clearConsigneeInfos();}})} else {// 不可用状态Button($r('app.string.save_btn'), { stateEffect: false }).width('100%').alignRules({bottom: { anchor: '__container__', align: VerticalAlign.Bottom }}).opacity(0.4).backgroundColor('#317AFF')}}.height('100%').width('100%').padding({left: 16,right: 16,top: 24,bottom: 24}).backgroundColor('#F1F3F5').alignRules({left: { anchor: '__container__', align: HorizontalAlign.Start }}).expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])}
}
components/ConsigneeInfoItem.ets文件
import { ConsigneeInfo } from '../viewmodel/DataModel';
import CommonConstants from '../common/constants/CommonConstants';@Builder
export function ConsigneeInfoItem(item: ConsigneeInfo, checkAvailable?: () => void) {Row() {Text(item.label).fontSize(16).fontWeight(400).lineHeight(19).textAlign(TextAlign.Start).fontColor('#000000').opacity(0.9).layoutWeight(1)TextArea({ placeholder: item.placeholder, text: item.value }).type(item.label === CommonConstants.PHONE_LABEL ? TextAreaType.PHONE_NUMBER : TextAreaType.NORMAL).fontSize(16).fontWeight(500).lineHeight(21).padding(0).borderRadius(0).textAlign(TextAlign.End).fontColor('#000000').opacity(0.9).backgroundColor('#FFFFFF').heightAdaptivePolicy(TextHeightAdaptivePolicy.MIN_FONT_SIZE_FIRST).layoutWeight(2).onChange((value: string) => {item.value = value;//判断保存按钮是否可用(均填写即可保存)checkAvailable?.();})}.width('100%').constraintSize({ minHeight: 48 }).justifyContent(FlexAlign.SpaceBetween)}
components/dialogBuilder.ets文件
import { PromptActionManager } from '../common/utils/PromptActionManager'
import { Params } from '../viewmodel/DataModel'
import { common } from '@kit.AbilityKit'
import { OCRManager } from '../common/utils/OCRManager'@Builder
export function dialogBuilder(params: Params): void {Column() {Text($r('app.string.dialog_title')).font({ size: 20, weight: 700 }).lineHeight(27).margin({ bottom: 16 })Text($r('app.string.dialog_subtitle')).font({ size: 16, weight: 50 }).lineHeight(21).margin({ bottom: 8 })Column({ space: 8 }) {Button($r('app.string.dialog_camera_btn')).width('100%').height(40).onClick(async () => {PromptActionManager.closeCustomDialog();let text: string =await OCRManager.recognizeByCamera(params.uiContext.getHostContext() as common.UIAbilityContext,params.loadingController);params.loadingController.close();if (text) {params.textAreaController.deleteText();params.textAreaController.addText(text);}})Button($r('app.string.dialog_photo_btn')).width('100%').height(40).fontColor('#0A59F2').backgroundColor('#FFFFFF').onClick(async () => {PromptActionManager.closeCustomDialog();let text: string =await OCRManager.recognizeByAlbum(params.loadingController);params.loadingController.close();if (text) {params.textAreaController.deleteText();params.textAreaController.addText(text);}})Button($r('app.string.dialog_cancel_btn')).width('100%').height(40).fontColor('#0A59F2').backgroundColor('#FFFFFF').onClick(() => {PromptActionManager.closeCustomDialog();})}}.size({ width: 'calc(100% - 32vp)', height: 235 }).borderRadius(32).backgroundColor('#FFFFFF').padding(16)
}
3、本地化测试
在界面上添加一个按钮,测用于试本地化功能。在按钮处理事件中,手动切换语言进行测试。
修改Index.ets文件
...
@State isChinese:boolean=true; //是否是中文
...changeLange():void{if(this.isChinese){i18n.System.setAppPreferredLanguage("en");}else {i18n.System.setAppPreferredLanguage("zh");}this.isChinese=!this.isChinese}
...Row({space:20}){Text(this.isChinese===true?"语言:汉语":"Language:English")Button(this.isChinese==true?"切换":"change").onClick(()=>{this.changeLange()})}
...
点击按钮,模拟切换环境,可以看到实现了本地化功能。
ps:预览模式无法测试国际化和本地化,需要模拟器或真机。
三、其它优化
1、样式复用
想象一下,在一些节日等场景下,需要临时改变APP的整体颜色风格。如何实现更便捷?
通过分析界面得知,存在部分界面有相同的公共样式。因此,为了样式的统一性和可维护性,也将其抽取到了JSON文件中。在resources/base/element/color.json文件中,抽取界面上会用到的公用部分颜色。color.json文件内容如下:
{"color": [{"name": "start_window_background","value": "#FFFFFF"},{"name": "app_background","value": "#F1F3F5"},{"name": "save_btn_disabled","value": "#317AFF"},{"name": "btn_font_color","value": "#0A59F2"},{"name": "light_background","value": "#FFFFFF"},{"name": "item_label_color","value": "#000000"},{"name": "title_color","value": "#000000"},{"name": "divider_color","value": "#33000000"}]
}
将公用的颜色抽取出来后,界面上就可以做如下形式的修改:
//原来写法
.fontColor('#000000')//替换为
.fontColor($r('app.color.title_color'))
这样以后需要改变APP整体风格,只需要修改color.json文件中对应的value值即可。无需在复杂的界面代码中去寻找,从而增加了代码的可维护性。
最终改造后的界面相关的三个文件代码如下:
Index.ets文件
import { ConsigneeInfo } from '../viewmodel/DataModel';
import CommonConstants from '../common/constants/CommonConstants';@Builder
export function ConsigneeInfoItem(item: ConsigneeInfo, checkAvailable?: () => void) {Row() {Text(item.label).fontSize(16).fontWeight(400).lineHeight(19).textAlign(TextAlign.Start).fontColor('#000000').opacity(0.9).layoutWeight(1)TextArea({ placeholder: item.placeholder, text: item.value }).type(item.label === CommonConstants.PHONE_LABEL ? TextAreaType.PHONE_NUMBER : TextAreaType.NORMAL).fontSize(16).fontWeight(500).lineHeight(21).padding(0).borderRadius(0).textAlign(TextAlign.End).fontColor('#000000').opacity(0.9).backgroundColor('#FFFFFF').heightAdaptivePolicy(TextHeightAdaptivePolicy.MIN_FONT_SIZE_FIRST).layoutWeight(2).onChange((value: string) => {item.value = value;//判断保存按钮是否可用(均填写即可保存)checkAvailable?.();})}.width('100%').constraintSize({ minHeight: 48 }).justifyContent(FlexAlign.SpaceBetween)}
ConsigneeInfoItem.ets文件
import { ConsigneeInfo } from '../viewmodel/DataModel';
import CommonConstants from '../common/constants/CommonConstants';@Builder
export function ConsigneeInfoItem(item: ConsigneeInfo, checkAvailable?: () => void) {Row() {Text(item.label).fontSize(16).fontWeight(400).lineHeight(19).textAlign(TextAlign.Start).fontColor($r('app.color.item_label_color')).opacity(0.9).layoutWeight(1)TextArea({ placeholder: item.placeholder, text: item.value }).type(item.label === CommonConstants.PHONE_LABEL ? TextAreaType.PHONE_NUMBER : TextAreaType.NORMAL).fontSize(16).fontWeight(500).lineHeight(21).padding(0).borderRadius(0).textAlign(TextAlign.End).fontColor($r('app.color.item_label_color')).opacity(0.9).backgroundColor($r('app.color.light_background')).heightAdaptivePolicy(TextHeightAdaptivePolicy.MIN_FONT_SIZE_FIRST).layoutWeight(2).onChange((value: string) => {item.value = value;//判断保存按钮是否可用(均填写即可保存)checkAvailable?.();})}.width('100%').constraintSize({ minHeight: 48 }).justifyContent(FlexAlign.SpaceBetween)}
dialogBuilder.ets文件
import { PromptActionManager } from '../common/utils/PromptActionManager'
import { Params } from '../viewmodel/DataModel'
import { common } from '@kit.AbilityKit'
import { OCRManager } from '../common/utils/OCRManager'@Builder
export function dialogBuilder(params: Params): void {Column() {Text($r('app.string.dialog_title')).font({ size: 20, weight: 700 }).lineHeight(27).margin({ bottom: 16 })Text($r('app.string.dialog_subtitle')).font({ size: 16, weight: 50 }).lineHeight(21).margin({ bottom: 8 })Column({ space: 8 }) {Button($r('app.string.dialog_camera_btn')).width('100%').height(40).onClick(async () => {PromptActionManager.closeCustomDialog();let text: string =await OCRManager.recognizeByCamera(params.uiContext.getHostContext() as common.UIAbilityContext,params.loadingController);params.loadingController.close();if (text) {params.textAreaController.deleteText();params.textAreaController.addText(text);}})Button($r('app.string.dialog_photo_btn')).width('100%').height(40).fontColor($r('app.color.btn_font_color')).backgroundColor($r('app.color.light_background')).onClick(async () => {PromptActionManager.closeCustomDialog();let text: string =await OCRManager.recognizeByAlbum(params.loadingController);params.loadingController.close();if (text) {params.textAreaController.deleteText();params.textAreaController.addText(text);}})Button($r('app.string.dialog_cancel_btn')).width('100%').height(40).fontColor($r('app.color.btn_font_color')).backgroundColor($r('app.color.light_background')).onClick(() => {PromptActionManager.closeCustomDialog();})}}.size({ width: 'calc(100% - 32vp)', height: 235 }).borderRadius(32).backgroundColor($r('app.color.light_background')).padding(16)
}
抽取公共样式或颜色风格,需要先有整体的设计、统一的风格。
2、日志封装
在开发过程中,通过日志打印信息进行调试是一种比较常用的方法。
console对象提供的方法打印日志。如果希望每次点击切换语言环境按钮时,都打印一条日志,可以在changeLange()方法中添加以下代码实现打印。
changeLange():void{...console.log("语言环境被改变")
}
这样每次点击切换按钮,就可以看到日志输出。
虽然以上能实现日志输出,但更好的方法时使用HarmonyOS SDK提供的Performance Analysis Kit(性能分析服务),它提供的功能能更加全面的对你开发的APP进行性能分析和调优。
**Performance Analysis Kit(性能分析服务)**为开发者提供应用事件、日志、跟踪分析工具,可观测应用运行时状态,用于行为分析、故障分析、安全分析、统计分析,帮助开发者持续改进应用体验。
Performance Analysis Kit里边涉及的内容较多,本文只从日志封装角度(使用Performance Analysis Kit中的hilog),抛砖引玉。
在utils目录下新建Logger.ets文件,内容如下:
import { hilog } from '@kit.PerformanceAnalysisKit';export class Logger {private static readonly DOMAIN: number = 0xFF00;private static readonly TAG: string = 'com.express.app';private static readonly PREFIX: string = '[ocr]';public static debug(logTag: string, messageFormat: string, ...args: Object[]): void {hilog.debug(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args);}public static info(logTag: string, messageFormat: string, ...args: Object[]): void {hilog.info(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args);}public static warn(logTag: string, messageFormat: string, ...args: Object[]): void {hilog.warn(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args);}public static error(logTag: string, messageFormat: string, ...args: Object[]): void {hilog.error(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args);}public static fatal(logTag: string, messageFormat: string, ...args: Object[]): void {hilog.fatal(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args);}private constructor() {}
}
创建Logger类,封装了hilog提供的日志方法,输出更多的场景信息,方便调试。
将之前打印的日志,换为新封装的日志类来打印日志,代码如下:
import { Logger } from '../common/utils/Logger';changeLange():void{...//console.log("语言环境被改变")Logger.info('changeLange', '语言环境被改变');
}
运行后切换语言,统一可以看到日志输出,同时还能输出更多的信息。
《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!