• [技术干货] 开发者技术支持-App最小化功能工具类技术方案总结
    1、关键技术难点总结1.1 问题说明​ 在HarmonyOS应用开发中,App最小化功能对于提升用户体验具有重要作用,特别是在媒体播放、即时通讯、后台任务处理等使用场景中。然而,原生窗口管理API的复杂性以及多样化的异常处理需求给开发者带来了显著的技术挑战,具体体现在以下方面:(一)API接口调用的复杂度与可靠性问题使用HarmonyOS窗口管理API(如window.getLastWindow()、WindowClass.minimize())需要开发者深入理解复杂的调用流程和异常处理机制。在直接调用原生API时,开发者面临以下挑战:需要准确获取窗口实例,并妥善处理窗口不存在、权限缺失等异常状况;必须正确理解异步回调机制,完整处理minimize()方法的成功与失败状态;需针对不同设备类型和系统版本进行兼容性适配,防止API调用失败。(二)错误处理逻辑的分散与标准化不足需要针对各类错误代码(权限异常、窗口状态异常、系统资源不足等)设计专门的应对方案;需要建立重试机制以应对临时性故障,但重试策略缺乏统一规范,实现方式各不相同;需要妥善处理异步操作的超时和取消场景,防止出现内存泄漏和状态不一致问题。(三)开发效率与代码维护的挑战每次实现最小化功能都需要重复编写窗口获取和错误处理代码,开发效率低下;项目不同模块的最小化实现存在差异,导致功能行为不一致和维护困难;缺乏标准化的配置和回调机制,难以实现统一的用户反馈和业务逻辑集成。1.2 原因分析(一)原生API的基础架构定位HarmonyOS窗口管理API的设计目标是为开发者提供完整的窗口控制能力,其设计理念更注重"功能完整性"而非"使用便捷性"。导致API具有以下特点:接口粒度较细,需要开发者组合多个API调用才能完成完整的最小化流程;错误信息详细但处理复杂,要求开发者具备深入的系统知识才能正确应对;缺乏高级封装,无法直接满足"一键最小化"的简化需求。(二)应用场景的多元化特征媒体类应用需要在最小化后维持播放状态,要求最小化操作快速稳定;通讯类应用需要在最小化时保持连接状态,要求错误处理机制完善;工具类应用可能需要定时自动最小化,要求支持灵活的触发机制。(三)开发团队的技术能力差异经验丰富的开发者可能实现相对完善的错误处理,但代码复杂度较高;经验较少的开发者可能忽略边界情况,导致应用在特定场景下出现异常行为;缺乏统一的最佳实践指导,团队内部实现方式不一致。2、解决思路(一)工具化封装:构建统一的最小化工具基于"简化使用、统一标准"的设计理念,封装MinimizeAppUtil工具类,整合窗口获取、最小化执行、错误处理等核心功能:封装复杂的API调用序列,提供"一行代码实现最小化"的简洁接口;内置完善的错误处理和重试机制,自动应对常见的失败场景;支持灵活的配置选项,适配不同应用场景的个性化需求。(二)稳定性提升:优化错误恢复与状态管理实现智能重试策略,根据错误类型自动调整重试间隔和次数;提供完整的回调机制,支持成功、失败、重试等状态的业务逻辑集成;采用单例模式确保全局状态一致,避免多实例导致的资源竞争问题。3、解决方案(一)App最小化工具类(MinimizeAppUtil)通过封装窗口管理API调用逻辑,结合状态管理与重试机制实现稳定的最小化功能,支持多场景适配:import { BusinessError } from "@kit.BasicServicesKit"; import { window } from "@kit.ArkUI"; export interface MinimizeConfig { maxRetries?: number; // 最大重试次数,默认3次 retryDelay?: number; // 重试间隔,默认1000ms enableLogging?: boolean; // 是否启用日志,默认true onSuccess?: () => void; // 成功回调 onError?: (error: Error) => void; // 错误回调 } export class MinimizeAppUtil { // 单例实例(确保全局唯一,避免状态冲突) private static instance: MinimizeAppUtil | null = null; private context: Context; private config: MinimizeConfig; private constructor(context: Context, config?: MinimizeConfig) { this.context = context; if(config){ this.config = config }else { this.config = { maxRetries:3, retryDelay:1000, enableLogging:true } } } // 单例模式:确保全局实例唯一,避免资源竞争 static getInstance(context: Context, config?: MinimizeConfig): MinimizeAppUtil { if (!MinimizeAppUtil.instance) { MinimizeAppUtil.instance = new MinimizeAppUtil(context, config); } return MinimizeAppUtil.instance; } /** * 主要的最小化方法:带重试机制的智能最小化 */ async minimize(): Promise<boolean> { for (let attempt = 1; attempt <= this.config.maxRetries!; attempt++) { try { const success = await this.performMinimize(); if (success) { this.log(`最小化成功 (第${attempt}次尝试)`); this.config.onSuccess?.(); return true; } } catch (error) { this.log(`最小化失败 (第${attempt}次尝试): ${error}`); if (attempt === this.config.maxRetries) { const finalError = new Error(`最小化失败,已重试${this.config.maxRetries}次`); this.config.onError?.(finalError); return false; } // 等待后重试 await this.delay(this.config.retryDelay!); } } return false; } /** * 执行实际的最小化操作 */ private async performMinimize(): Promise<boolean> { return new Promise(async (resolve, reject) => { try { const windowClass: window.Window = await window.getLastWindow(this.context); windowClass.minimize((err: BusinessError) => { if (err.code) { reject(new Error(`窗口最小化失败: ${err.message} (错误码: ${err.code})`)); } else { resolve(true); } }); } catch (error) { reject(new Error(`获取窗口实例失败: ${error}`)); } }); } // 延时工具方法 private delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } // 日志输出方法 private log(message: string): void { if (this.config.enableLogging) { console.log(`[MinimizeAppUtil] ${message}`); } } } (二)核心使用场景示例工具类支持"一键调用"与"配置化调用"两种模式,直接调用对应方法即可实现App最小化,示例代码如下:基础场景调用// 一行代码实现最小化 MinimizeAppUtil.getInstance(getContext()).minimize(); 自定义配置调用// 自定义配置 const config: MinimizeConfig = { maxRetries: 5, retryDelay: 2000, enableLogging: true, onSuccess: () => { console.log('应用已成功最小化'); // 执行成功后的业务逻辑 }, onError: (error) => { console.error('最小化失败:', error.message); // 执行错误处理逻辑 } }; // 使用自定义配置 MinimizeAppUtil.getInstance(getContext(), config).minimize(); (三)关键交互流程:用户触发最小化操作(如点击最小化按钮、手势操作、系统事件);工具类自动获取当前窗口实例,执行窗口最小化API调用;若调用成功,则执行成功回调并返回true;若调用失败,则根据配置进行重试;重试过程中自动处理各种错误情况(如权限不足、窗口状态异常等);达到最大重试次数后仍失败,则执行错误回调并返回false,完成单次最小化操作闭环。4、方案成果总结(一)功能层面:通过工具类统一实现App最小化能力,封装复杂的窗口管理API调用,内置智能重试机制应对各种异常情况,有效保障应用稳定运行。(二)开发层面:工具类提供”一行代码”的简洁调用方式,开发者无需掌握复杂的窗口管理API细节;同时支持灵活的配置选项,适配不同应用场景需求,减少代码重复与维护成本。(三)用户层面:通过稳定可靠的最小化功能,提升用户在多任务切换场景下的操作体验;结合完善的错误处理机制,避免最小化失败导致的应用异常,优化整体使用体验。
  • [技术干货] 开发者技术支持-全局加载动画技术方案总结
    1、关键技术难点总结1.1 问题说明(一)不同页面加载动画样式不统一:在鸿蒙应用开发中,存在不同页面加载动画样式不统一的问题。当用户在不同页面触发加载操作时,会看到风格不同的加载动画,影响用户体验的一致性。有些页面使用简单的旋转图标,有些页面使用进度条,还有些页面甚至没有加载提示,这种不一致性源于缺乏统一的加载动画规范和组件复用机制。(二)重复编写加载动画逻辑代码:在鸿蒙应用开发中,存在重复编写加载动画逻辑代码的问题。每个需要加载动画的页面都需要单独实现显示/隐藏逻辑、状态管理、动画效果等,导致大量重复代码。当需要修改加载动画样式或行为时,需要在多个地方进行修改,维护成本高,这种重复性工作源于缺乏可复用的加载动画组件和统一的调用接口。(三)动画状态管理复杂,容易出现内存泄漏:在鸿蒙应用开发中,存在动画状态管理复杂且容易出现内存泄漏的问题。当页面频繁显示/隐藏加载动画或在页面生命周期变化时,容易出现动画未正确停止、监听器未及时移除等情况,导致内存泄漏和性能问题。特别是在复杂页面中,多个组件可能同时使用加载动画,状态同步变得困难,这种复杂性源于缺乏统一的状态管理机制和生命周期处理规范。(四)全局动画调用不够便捷:在鸿蒙应用开发中,存在全局动画调用不够便捷的问题。当前实现中,要在页面中使用加载动画,需要在每个页面手动导入组件、初始化状态、编写显示/隐藏逻辑,操作繁琐且容易出错。开发者希望能通过简单的一行代码调用加载动画,而不需要关心底层实现细节,这种不便源于缺乏封装良好的工具类和全局状态管理机制。1.2 原因分析(一)缺乏统一的加载动画组件规范:在鸿蒙应用开发中,由于缺乏统一的加载动画组件规范,导致不同开发者实现的加载动画样式各异。没有制定统一的设计规范和组件接口标准,使得加载动画在不同页面呈现出不同的视觉效果和交互方式,这种规范缺失源于团队缺乏组件化设计的统一标准和设计指南。(二)动画与业务逻辑耦合度高:在鸿蒙应用开发中,由于动画与业务逻辑耦合度高,导致代码复用性差。加载动画的显示/隐藏逻辑往往与具体的业务逻辑混杂在一起,使得动画组件无法独立复用。当业务逻辑发生变化时,可能需要同时修改动画相关的代码,这种高耦合性源于没有将动画组件与业务逻辑进行有效解耦。(三)没有建立全局状态管理机制:在鸿蒙应用开发中,由于没有建立全局状态管理机制,导致动画状态在不同页面间无法共享。每个页面都需要独立管理加载动画的状态,无法实现全局统一控制。当需要在不同页面间协调加载状态时,缺乏有效的通信机制,这种机制缺失源于没有设计统一的状态管理模式和跨组件通信方案。(四)缺少标准的加载动画调用接口:在鸿蒙应用开发中,由于缺少标准的加载动画调用接口,导致开发者使用不便。没有提供简单易用的API来控制加载动画的显示/隐藏,使得开发者需要深入了解动画组件的内部实现才能正确使用,这种接口缺失源于没有从开发者使用角度设计封装良好的工具类和调用方法。2、解决思路通过自定义组件+全局状态管理的方式,设计一个统一的加载动画解决方案:创建可复用的自定义加载组件使用全局状态管理动画显示/隐藏封装统一的调用接口支持自定义动画样式和参数3、解决方案步骤1:创建全局状态管理// GlobalLoadingManager.ets export class GlobalLoadingManager { private static instance: GlobalLoadingManager = new GlobalLoadingManager(); private loadingState: boolean = false; private loadingText: string = '加载中...'; private listeners: Array<(show: boolean, text: string) => void> = []; public static getInstance(): GlobalLoadingManager { return GlobalLoadingManager.instance; } // 显示加载动画 showLoading(text: string = '加载中...'): void { this.loadingState = true; this.loadingText = text; this.notifyListeners(); } // 隐藏加载动画 hideLoading(): void { this.loadingState = false; this.notifyListeners(); } // 注册状态监听 addListener(listener: (show: boolean, text: string) => void): void { this.listeners.push(listener); } // 移除监听 removeListener(listener: (show: boolean, text: string) => void): void { const index = this.listeners.indexOf(listener); if (index > -1) { this.listeners.splice(index, 1); } } private notifyListeners(): void { this.listeners.forEach(listener => { listener(this.loadingState, this.loadingText); }); } } 步骤2:创建自定义加载动画组件// CustomLoading.ets import { GlobalLoadingManager } from "./GlobalLoadingManager"; @Component export struct CustomLoading { @State private isShowing: boolean = false; @State private loadingText: string = '加载中...'; @State private rotateAngle: number = 0; aboutToAppear(): void { GlobalLoadingManager.getInstance().addListener(this.onLoadingStateChange.bind(this)); } aboutToDisappear(): void { GlobalLoadingManager.getInstance().removeListener(this.onLoadingStateChange.bind(this)); } private onLoadingStateChange(show: boolean, text: string): void { this.isShowing = show; this.loadingText = text; if (show) { this.startRotationAnimation(); } } private startRotationAnimation(): void { this.rotateAngle = 0; const animation = animateTo({ duration: 1000, tempo: 1, curve: Curve.Linear, iterations: -1, // 无限循环 onFinish: () => { console.info('Animation finished'); } }, () => { this.rotateAngle = 360; }); } build() { if (this.isShowing) { Stack({ alignContent: Alignment.Center }) { // 半透明背景 Column() { // 空列用于背景 } .width('100%') .height('100%') .backgroundColor('#000000') .opacity(0.3) // 加载动画内容 Column() { // 旋转图标 Row() { Image($r('app.media.ic_loading')) .width(40) .height(40) .rotate({ angle: this.rotateAngle }) .margin({left: 20}) } .width(80) .height(80) .backgroundColor('#FFFFFF') .borderRadius(10) .shadow({ radius: 10, color: '#33000000', offsetX: 0, offsetY: 2 }) .alignItems(VerticalAlign.Center) // 加载文字 Text(this.loadingText) .fontSize(14) .fontColor('#FFFFFF') .margin({ top: 10 }) } .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') .position({ x: 0, y: 0 }) } } } 步骤3:创建工具类封装调用方法// LoadingUtils.ets import { GlobalLoadingManager } from "./GlobalLoadingManager"; export class LoadingUtils { // 显示加载动画 public static showLoading(text?: string): void { GlobalLoadingManager.getInstance().showLoading(text); } // 隐藏加载动画 public static hideLoading(): void { GlobalLoadingManager.getInstance().hideLoading(); } // 带自动隐藏的加载 public static async showLoadingWithAutoHide(duration: number = 3000, text?: string): Promise<void> { LoadingUtils.showLoading(text); setTimeout(() => { LoadingUtils.hideLoading(); }, duration); } // 网络请求包装器 public static async withLoading<T>(promise: Promise<T>, text?: string): Promise<T | null> { LoadingUtils.showLoading(text); try { const result = await promise; LoadingUtils.hideLoading(); return result; } catch (error) { LoadingUtils.hideLoading(); console.error('error: ' + error) return null } } } 步骤4:在主页面使用全局加载组件// Index.ets import { CustomLoading } from './CustomLoading'; import { LoadingUtils } from './LoadingUtils'; @Entry @Component struct Index { build() { Stack() { // 主页面内容 Column() { Text('全局加载动画演示') .fontSize(20) .fontWeight(FontWeight.Bold) Button('显示加载动画') .width('60%') .margin({ top: 20 }) .onClick(() => { LoadingUtils.showLoading( '数据加载中...'); // 3秒后自动隐藏 setTimeout(() => { LoadingUtils.hideLoading(); }, 3000); }) Button('模拟网络请求') .width('60%') .margin({ top: 20 }) .onClick(() => { this.mockNetworkRequest(); }) Button('自定义文字') .width('60%') .margin({ top: 20 }) .onClick(() => { LoadingUtils.showLoading('正在处理,请稍候...'); setTimeout(() => { LoadingUtils.hideLoading(); }, 2000); }) // 全局加载组件 CustomLoading() } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } // 模拟网络请求 private async mockNetworkRequest(): Promise<void> { await LoadingUtils.withLoading( new Promise<void>((resolve) => { setTimeout(() => { console.info('网络请求完成'); resolve(); }, 2500); }), '请求数据中...' ); } } 4、方案成果总结(一)统一性:整个应用使用统一的加载动画样式。通过创建标准化的组件和全局状态管理机制,确保了所有页面使用一致的加载动画样式,提升了用户体验的一致性。无论用户在哪个页面触发加载操作,都能看到相同风格的加载动画,这种统一性源于组件化设计和全局状态管理的结合。(二)便捷性:一行代码即可调用加载动画。通过封装工具类,开发者只需调用LoadingUtils.showLoading()即可显示加载动画,调用LoadingUtils.hideLoading()即可隐藏动画,极大地简化了使用流程。这种便捷性使得开发者能够专注于业务逻辑实现,而无需关心加载动画的底层实现细节。(三)灵活性:支持自定义文字、持续时间。方案支持通过参数传递自定义加载文字,未来可扩展支持不同类型的动画效果和持续时间设置,满足不同场景的需求。开发者可以根据具体业务场景灵活调整加载提示文字,这种灵活性源于参数化设计和可扩展的架构。(四)性能优化:避免重复创建动画对象,减少内存占用。通过全局单例模式管理加载状态和监听器,避免了在每个页面重复创建动画对象和监听器,有效减少了内存占用。同时,通过合理的生命周期管理,确保动画在不需要时能够正确停止和清理,这种优化源于对内存管理和生命周期的深入理解。
  • [开发技术领域专区] 开发者技术支持-防重复点击适配案例技术总结
    1.1问题说明在鸿蒙应用中,按钮、列表项、表单提交等控件被用户快速连续点击时,易触发多次目标函数执行。这会导致接口重复请求、页面重复跳转、状态修改错乱等问题,进而引发数据不一致、界面卡顿、资源浪费等异常,影响应用稳定性和用户体验。1.2原因分析(一)用户操作习惯:部分用户存在快速连续点击控件的行为,尤其是在网络响应较慢或界面反馈不及时的场景下。(二)缺乏统一控制:防重复点击逻辑分散在各业务模块,未形成全局统一的解决方案,维护成本高且易遗漏。(三)系统事件机制:鸿蒙系统事件分发响应效率高,未做限制时,连续点击会快速触发绑定的函数。1.3解决思路(一)封装统一工具类:将节流、防抖核心逻辑集中封装,提供标准化调用接口,降低业务代码耦合度。(二)双模式节流适配:支持 “立即执行” 和 “延迟执行” 两种节流模式,满足不同业务场景(如立即反馈操作结果、等待接口响应后执行)。(三)场景化防抖设计:通过唯一 ID 区分不同防抖场景,避免多控件、多业务的防抖逻辑相互干扰。(四)单例模式保障:采用单例模式确保工具类全局唯一,避免多实例导致的状态冲突和控制失效。(五)轻量无依赖实现:内部自主管理定时器和状态,不依赖外部缓存工具,降低鸿蒙应用适配成本。1.4解决方案基于鸿蒙应用运行特性,设计单例模式工具类,集中实现节流、防抖逻辑,适配多场景防重复需求:单例模式构建:通过静态方法 getInstance () 创建全局唯一实例,避免多实例引发的状态冲突,保障防重复控制的一致性。节流功能实现:支持两种执行模式:立即执行(先执行目标函数,再锁定 wait 时长)和延迟执行(锁定 wait 时长后,执行目标函数)。内部维护 throttleFlag 状态标识和 throttleTimer 定时器,通过锁定期控制函数触发频率,默认间隔 1000ms。防抖功能实现:采用 Map 结构(debounceTimerMap)管理不同场景的定时器,通过 clickId 作为唯一标识区分场景。重复触发时清除当前场景的旧定时器,重置延迟时长,确保只有最后一次触发在 wait 时长后执行,默认间隔 1000ms。轻量无依赖设计:不依赖外部缓存组件,通过内部变量和集合管理状态与定时器,降低鸿蒙应用适配复杂度。代码示例:export class ThrottleDebounceUtil { // 节流状态标识(静态属性需保证唯一性) private throttleFlag = false; // 节流定时器(统一管理,避免分散) private throttleTimer: number | null = null; // 防抖:使用 Map 管理不同 clickId 的定时器(替代 CacheUtil,避免依赖外部缓存) private debounceTimerMap = new Map<string, number>(); // 防抖默认 ID(保持原有逻辑兼容) private defaultDebounceId = new Date().toDateString(); // 单例模式:确保全局实例唯一,避免状态冲突 private static instance: ThrottleDebounceUtil | null = null; static getInstance() { if (ThrottleDebounceUtil.instance) { return ThrottleDebounceUtil.instance; } ThrottleDebounceUtil.instance = new ThrottleDebounceUtil(); return ThrottleDebounceUtil.instance; } /** * 节流函数:控制函数执行频率 * @param func 要执行的函数 * @param wait 等待时长(毫秒),默认 1000ms * @param immediate 是否立即执行(true: 先执行再等待;false: 等待结束后执行) */ throttle( func: () => void, wait: number = 1000, immediate: boolean = true ): void { // 立即执行模式:需保证当前无执行锁 if (immediate) { if (!this.throttleFlag) { this.throttleFlag = true; func(); // 立即执行目标函数 // 等待时长后释放锁 this.throttleTimer = setTimeout(() => { this.throttleFlag = false; this.throttleTimer && clearTimeout(this.throttleTimer); }, wait); } } // 延迟执行模式:需保证当前无定时器 else { if (!this.throttleTimer) { this.throttleTimer = setTimeout(() => { func(); // 等待结束后执行 this.throttleTimer && clearTimeout(this.throttleTimer); this.throttleTimer = null; }, wait); } } } /** * 防抖函数:延迟执行函数,重复触发则重置延迟 * @param func 要执行的函数 * @param wait 等待时长(毫秒),默认 1000ms * @param clickId 用于区分不同防抖场景的 ID,默认使用日期字符串 */ debounce( func: () => void, wait: number = 1000, clickId: string = this.defaultDebounceId ): void { // 清除之前的定时器 const existingTimer = this.debounceTimerMap.get(clickId); existingTimer && clearTimeout(existingTimer); // 新建定时器 const newTimer = setTimeout(() => { func(); // 延迟执行目标函数 this.debounceTimerMap.delete(clickId); // 执行后清除记录 }, wait); // 缓存新定时器 this.debounceTimerMap.set(clickId, newTimer); } } 1.5方案成果总结(一)解决核心问题:有效拦截高频重复点击,彻底避免接口重复请求、状态错乱等异常,保障应用功能稳定性。(二)提升应用性能:减少不必要的函数执行和资源占用,降低界面卡顿概率,优化鸿蒙应用运行流畅度。(三)适配多业务场景:节流双模式、防抖场景化设计,覆盖按钮点击、表单提交、列表滑动等各类高频操作场景。(四)降低开发成本:统一工具类替代分散的业务内控制逻辑,减少重复编码,提升开发效率,便于后续维护迭代。(五)鸿蒙适配友好:单例模式适配鸿蒙系统运行机制,轻量无依赖设计降低集成门槛,可快速接入各类鸿蒙应用。
  • [技术干货] 开发者技术支持-文本转语音组件技术方案总结
    1、关键技术难点总结1.1 问题说明在HarmonyOS平台上实现文本转语音(textToSpeech)功能面临以下几个关键技术痛点:textToSpeech的创建需要传入多个参数,包括语言、发音人、在线/离线模式等,参数配置不当容易导致引擎创建失败。textToSpeech播报涉及多个异步操作,包括引擎初始化、文本播报、状态监听等,需要合理管理Promise和回调函数。textToSpeech引擎占用系统资源,在不使用时需要正确关闭和释放,否则可能导致内存泄漏或资源浪费。在UI界面上需要准确反映textToSpeech引擎的工作状态(空闲/播报中),如完成播报回调onComplete有两次回调过程1.2 原因分析API复杂性:HarmonyOS的textToSpeech API较为底层,直接使用需要处理大量细节,开发者学习成本高。异步编程模型:textToSpeech操作本质上是异步的,涉及到回调函数和Promise的嵌套使用,容易出现状态管理混乱。播放回调存在多次:完成播报回调onComplete有两次回调过程,一次语言合成回调,一次语言播报完成回调,在UI界面上的状态需要多情况处理2、解决思路封装核心API:将鸿蒙原生textToSpeech API进行封装,提供简洁易用的接口,隐藏底层实现细节。统一异步处理:采用Promise方式统一封装异步操作,提供async/await风格的调用接口,简化使用流程。完善状态管理:通过状态标志位和事件回调机制,实时跟踪和同步textToSpeech引擎的工作状态。建立错误处理体系:针对不同类型的错误建立分类处理机制,提供详细的错误信息反馈。优化资源管理:提供标准的资源初始化和释放接口,确保系统资源得到合理利用。3、解决方案3.1 核心组件设计设计[TtsComponent]组件,包含以下核心功能:参数接口定义:TtsOptions接口定义了文本、速度、音量、语言等配置参数TtsCallbacks接口定义了开始、完成、停止、错误等回调函数/** * 文本转语音组件参数接口 */ export interface TtsOptions { /** * 要播报的文本内容 */ text: string; /** * 播放速度,范围通常为0.5-2.0,默认为1.0 */ speed?: number; /** * 播放音量,范围通常为0-100,默认为50 */ volume?: number; /** * 语种,如'zh-CN'(中文)、'en-US'(英文)等,默认为'zh-CN' */ language?: string; /** * 音调,默认为1.0 */ pitch?: number; /** * 发音人,默认为0 */ person?: number; /** * 是否在线合成,默认为true */ online?: boolean; } /** * 文本转语音组件事件回调接口 */ export interface TtsCallbacks { /** * 开始播报回调 */ onStart?: (requestId: string, response?: textToSpeech.CompleteResponse) => void; /** * 播报完成回调 */ onComplete?: (requestId: string, response?: textToSpeech.CompleteResponse) => void; /** * 播报停止回调 */ onStop?: (requestId: string, response?: textToSpeech.CompleteResponse) => void; /** * 播报出错回调 */ onError?: (requestId: string, errorCode: number, errorMessage: string) => void; } 核心方法实现:init()方法负责初始化TTS引擎speak()方法执行文本播报stop()方法停止播报shutdown()方法关闭引擎isBusy()方法检查引擎状态/** * 初始化TTS引擎 * @param options 初始化参数 * @returns Promise<void> */ public async init(options?: TtsOptions): Promise<void> { return new Promise((resolve, reject) => { if (this.engineCreated) { resolve(); return; } const lang = options?.language || 'zh-CN'; const person = options?.person !== undefined ? options.person : 0; const online = options?.online !== undefined ? (options.online ? 1 : 0) : 1; // 设置创建引擎参数 let extraParam: Record<string, Object> = { "style": 'interaction-broadcast', "locate": lang.split('-')[1] || 'CN', "name": 'TtsEngine' }; let initParamsInfo: textToSpeech.CreateEngineParams = { language: lang, person: person, online: online, extraParams: extraParam }; try { // 调用createEngine方法 textToSpeech.createEngine(initParamsInfo, (err: BusinessError, textToSpeechEngine: textToSpeech.TextToSpeechEngine) => { if (!err) { console.log('TTS引擎创建成功'); // 接收创建引擎的实例 this.ttsEngine = textToSpeechEngine; this.engineCreated = true; resolve(); } else { console.error(`TTS引擎创建失败, 错误码: ${err.code}, 错误信息: ${err.message}`); reject(new Error(`TTS引擎创建失败: ${err.message}`)); } }); } catch (error) { const businessError = error as BusinessError; const message = businessError.message; const code = businessError.code; console.error(`TTS引擎创建异常, 错误码: ${code}, 错误信息: ${message}`); reject(new Error(`TTS引擎创建异常: ${message}`)); } }); } /** * 播报文本 * @param options 播报参数 * @param callbacks 事件回调 * @returns Promise<void> */ public async speak(options: TtsOptions, callbacks?: TtsCallbacks): Promise<void> { if (!this.engineCreated || !this.ttsEngine) { await this.init(options) } return new Promise((resolve, reject) => { // 设置播报相关参数 const speed = options.speed !== undefined ? options.speed : 1.0; const volume = options.volume !== undefined ? options.volume : 50; const language = options.language || 'zh-CN'; const pitch = options.pitch !== undefined ? options.pitch : 1.0; const person = options.person !== undefined ? options.person : 0; const online = options.online !== undefined ? (options.online ? 1 : 0) : 1; let extraParam: Record<string, Object> = { "queueMode": 0, "speed": speed, "volume": volume / 50, // 调整音量参数范围 "pitch": pitch, "languageContext": language, "audioType": "pcm", "soundChannel": 3, "playType": 1 }; // 生成唯一的请求ID const requestId = 'tts_' + Date.now(); this.currentRequestId = requestId; let speakParams: textToSpeech.SpeakParams = { requestId: requestId, extraParams: extraParam }; // 创建回调对象 let speakListener: textToSpeech.SpeakListener = { // 开始播报回调 onStart: (reqId: string, response: textToSpeech.StartResponse) => { console.info(`TTS开始播报, 请求ID: ${reqId}`); if (callbacks?.onStart) { callbacks.onStart(reqId); } }, // 完成播报回调 onComplete: (reqId: string, response: textToSpeech.CompleteResponse) => { console.info(`TTS播报完成, 请求ID: ${reqId}`); if (callbacks?.onComplete) { callbacks.onComplete(reqId, response); } resolve(); }, // 停止播报完成回调 onStop: (reqId: string, response: textToSpeech.StopResponse) => { console.info(`TTS播报停止, 请求ID: ${reqId}`); if (callbacks?.onStop) { callbacks.onStop(reqId); } resolve(); }, // 返回音频流(如果需要处理音频数据) onData: (reqId: string, audioData: ArrayBuffer, response: textToSpeech.SynthesisResponse) => { console.info(`TTS音频数据, 请求ID: ${reqId}, 序列号: ${response.sequence}`); }, // 错误回调 onError: (reqId: string, errorCode: number, errorMessage: string) => { console.error(`TTS播报出错, 请求ID: ${reqId}, 错误码: ${errorCode}, 错误信息: ${errorMessage}`); if (errorCode === 1002300007) { this.engineCreated = false; } if (callbacks?.onError) { callbacks.onError(reqId, errorCode, errorMessage); } reject(new Error(`TTS播报出错: ${errorMessage}`)); } }; // 设置回调 this.ttsEngine?.setListener(speakListener); try { // 调用speak播报方法 this.ttsEngine?.speak(options.text, speakParams); } catch (error) { const businessError = error as BusinessError; const message = businessError.message; const code = businessError.code; console.error(`TTS播报异常, 错误码: ${code}, 错误信息: ${message}`); reject(new Error(`TTS播报异常: ${message}`)); } }); } /** * 停止播报 */ public stop(): void { if (this.ttsEngine && this.engineCreated) { try { const isBusy: boolean = this.ttsEngine.isBusy(); if (isBusy) { this.ttsEngine.stop(); } } catch (err) { console.error('停止播报失败:', err); } } } /** * 关闭TTS引擎 */ public shutdown(): void { if (this.ttsEngine && this.engineCreated) { try { this.ttsEngine.shutdown(); this.engineCreated = false; this.ttsEngine = null; console.log('TTS引擎已关闭'); } catch (err) { console.error('关闭TTS引擎失败:', err); } } } /** * 检查TTS引擎是否正在播报 * @returns boolean */ public isBusy(): boolean { if (this.ttsEngine && this.engineCreated) { try { return this.ttsEngine.isBusy(); } catch (err) { console.error('检查TTS引擎状态失败:', err); return false; } } return false; } 3.2 异步处理优化使用Promise封装所有异步操作合理处理异步操作的异常情况,确保程序稳定性提供完整的事件回调机制,满足不同使用场景需求3.3 错误处理机制对引擎创建失败、参数错误等情况进行分类处理提供详细的错误码和错误信息反馈3.4 资源管理策略提供显式的资源初始化和释放接口在组件销毁时自动清理资源防止重复初始化和重复释放等问题if (!this.engineCreated || !this.ttsEngine) { await this.init(options) } 4、方案成果总结将复杂的HarmonyOS textToSpeech API封装为标准化组件,极大降低了使用门槛;提供了清晰的参数配置接口和事件回调接口,满足不同业务场景需求;建立了完整的错误处理机制,提高了组件的稳定性和可靠性;实现了合理的资源初始化和释放机制。开发者可以直接使用封装好的组件,无需深入了解底层API细节,显著提升开发效率,增强了应用的交互体验;通过组件化封装,提高了代码的复用性和可维护性。支持多种参数配置,可以根据具体需求调整播报效果,如调整播报语速、音量。
  • [开发技术领域专区] 开发者技术支持-语音转文字适配案例技术总结
    1.1问题说明在鸿蒙系统上开发语音转文字应用时,多个影响使用体验的关键问题会直接干扰应用正常使用,具体如下:音频文件转写常出数据断档:读取音频文件来转文字时,数据会分成一段段发送给识别功能,过程中很容易出现数据传递中断的情况。而且文件读取完成后,它占用的设备资源没法及时释放,不仅可能导致转写出来的文字断断续续、残缺不全,严重时还会直接让识别功能卡住没法运行。语音识别核心功能操作混乱:负责语音转文字的核心功能模块,从开启、启动识别,到停止识别、彻底关闭,整个流程没有固定规范。实际使用中常出现重复开启该模块的情况,用完后相关资源也清理不干净;有时一边录音转文字,一边尝试读取音频文件转写,两个操作还会互相干扰,导致两者都没法正常工作。状态显示和消息提示混乱不准:应用界面上显示的 “功能就绪”“正在录音”“正在处理文件” 等状态,经常和实际情况不符,比如已经停止录音了,界面还一直显示 “正在录音”。另外,转写出来的文字、操作出错的提示信息,要么迟迟不显示,要么会和其他操作的消息混在一起,用户根本分不清哪条信息对应哪次操作。适配能力不足,多场景难兼容:不同来源的音频文件格式不一样,比如有的录音采样清晰、有的模糊,不同场景下对识别的要求也不同,但应用没法灵活调整设置来适配这些情况,经常出现某类音频文件没法转写的问题。同时,用户想知道这个应用能识别哪些语言,相关查询功能要么用不了,要么查到的结果没法在界面上正常显示。功能模块和应用界面配合脱节:语音识别的功能模块和应用的操作界面衔接不顺畅。比如用户关掉应用界面后,语音识别的相关功能还在后台偷偷运行,既浪费设备电量,还可能占用设备运行内存拖慢速度;而且界面上的提示消息,比如 “开始录音”“识别成功” 等,经常和实际操作不同步,用户要等很久才能看到反馈,体验很差。1.2原因分析结合鸿蒙系统的使用特点和应用实际开发情况,上述问题的根源主要有以下几点:(一)音频文件操作考虑不周全:读取音频文件时,没提前想到可能出现的意外情况(比如文件损坏、读取到一半突然中断),遇到这些问题时没有对应的处理办法,很容易导致数据断档。而且每次读取的音频数据量是固定的,当文件末尾剩下的数据不够这个量时,就会直接停在那里,造成转写内容不完整。(二)语音识别核心功能没定好 “规矩”:控制语音转文字的核心功能(识别引擎),从开启到关闭的整个过程没有固定的操作顺序,比如还没准备好就强行启动,或者启动后没正常关闭就重复开启,很容易乱套。同时,这个核心功能的实际工作状态(比如是否在运行)和界面上显示的状态(比如 “正在录音”)没关联起来,经常出现 “功能已经停了,界面还在显示运行中” 的滞后情况。(三)状态显示和消息传递没理清楚:界面上的 “正在录音”“正在处理” 等状态,和语音识别的实际进度没绑在一起,识别已经结束了,界面可能还没更新。另外,传递识别结果、报错信息时,既没统一格式,也没标清楚是 “录音识别” 还是 “文件识别” 的消息,导致不同操作的信息混在一起,用户分不清。(四)适配能力和辅助功能有漏洞:没有提前设置一套通用的音频参数(比如常见的录音清晰度、声道数),遇到特殊格式的音频(比如清晰度不同、多声道)就 “不认识”,没法处理。而且查询系统支持哪些语言的识别时,查到的结果没好好整理,没法正常显示在界面上,用户自然看不到可用的识别语言。(五)功能模块和界面 “节奏对不上”:语音识别功能的开启、关闭,和应用界面的打开、关闭没同步。比如用户关掉界面后,语音识别功能可能还在后台偷偷运行,白白浪费设备电量和内存。另外,界面上的提示消息(比如 “开始录音”“识别完成”)没跟上操作步骤,用户操作完要等很久才能看到反馈,体验很不好。1.3解决思路针对前面问题的根源,结合鸿蒙系统的使用特性,制定出一套简单好落地的解决方向,确保每个问题都能精准对应解决,具体如下:(一)完善音频文件操作流程:专门做一个管理音频文件的工具,提前想好文件损坏、读取中断等意外情况的应对办法。同时调整读取方式,就算文件末尾剩下少量数据也能正常读取,确保转写用的音频数据能完整、连续地传递给语音识别功能,读完文件后还能自动释放占用的设备资源。(二)给语音识别核心功能定 “操作规矩”:用一个专属控制器来统筹语音转文字的核心功能,明确规定 “先准备好再启动、结束后必须关闭” 的固定流程。同时把核心功能的实际工作状态和界面显示状态绑在一起,比如功能停止运行了,界面就立刻更新为 “已停止”,避免重复启动、操作冲突的问题,还得确保用完后能彻底清理相关资源。(三)理顺状态显示和消息传递逻辑:给界面上的各类状态(如 “正在录音”)和消息(如识别结果、报错提示)定统一规则。让界面状态紧紧跟着语音识别的实际进度实时更新,而且给录音、文件识别两种操作分别加上专属标识,这样传递消息时就能分清来源,不会出现信息混乱、延迟显示的情况。(四)补全适配漏洞并完善辅助功能:先预设一套通用的音频设置,能适配大多数常见的音频文件;同时留出调整空间,遇到特殊格式的音频时,可手动修改设置适配。另外,优化语言查询功能,把查到的系统支持的识别语言整理成清晰的样式,确保能正常显示在界面上,方便用户查看。(五)让功能模块和界面 “节奏同步”:把语音识别功能的开启、关闭,和应用界面的打开、关闭绑在一起,比如用户关掉界面时,系统自动关闭语音识别功能并清理资源,避免后台偷偷运行浪费资源。同时调整界面提示消息的时机,操作完成后立刻弹出提示,让用户及时知道 “录音已开始”“识别成功” 等结果,提升使用体验。1.4解决方案(一)专属音频文件管理工具:预设文件损坏、读取中断等异常的应对方案,避免数据断档。优化读取逻辑,确保文件末尾少量数据也能完整读取,读完自动释放资源。设专属标识,防止同一文件同时多次读取引发数据混乱。(二)规范语音识别核心功能操作:用专属控制器定流程,需完成参数设置再启动功能,成败均给出明确提示。分开管控录音、文件识别流程,避免操作互相干扰。双重清理资源:设手动关闭按钮,同时界面关闭时自动触发核心功能关停与资源清理。(三)统一状态与消息传递规则:给录音、文件识别的消息加专属标签,避免信息混淆。界面状态与识别实际进度实时绑定,杜绝状态滞后。统一识别结果、报错信息的展示格式,方便用户查看。(四)补全适配漏洞,完善辅助功能:预设通用音频参数,同时预留手动调整入口,适配特殊音频格式。优化语言查询功能,将查询结果整理成清晰列表展示。(五)协调功能模块与界面配合:统一语音识别功能的调用接口,避免界面调用出错。操作与提示同步,如点击录音立即弹出对应提示。对齐功能与界面生命周期,界面关闭时自动关停识别功能,减少资源浪费。文件操作工具类代码示例:import { fileIo } from '@kit.CoreFileKit'; const TAG = 'FileUtil'; const SEND_SIZE: number = 1280; export class FileUtil { private mIsWriting: boolean = false; private mFilePath: string = ''; private mFile: fileIo.File | null = null; private mIsReadFile: boolean = true; private mDataCallBack: ((data: ArrayBuffer) => void) | null = null; public setFilePath(filePath: string) { this.mFilePath = filePath; } async init(dataCallBack: (data: ArrayBuffer) => void): Promise<void> { if (null != this.mDataCallBack) { return; } this.mDataCallBack = dataCallBack; if (!fileIo.accessSync(this.mFilePath)) { return } console.error(TAG, "init start "); } async start(): Promise<void> { try { if (this.mIsWriting || null == this.mDataCallBack) { return; } this.mIsWriting = true; this.mIsReadFile = true; this.mFile = fileIo.openSync(this.mFilePath, fileIo.OpenMode.READ_ONLY); let buf: ArrayBuffer = new ArrayBuffer(SEND_SIZE); let offset: number = 0; while (SEND_SIZE == fileIo.readSync(this.mFile.fd, buf, { offset: offset }) && this.mIsReadFile) { this.mDataCallBack(buf); await sleep(40); offset = offset + SEND_SIZE; } } catch (e) { console.error(TAG, "read file error " + e); } finally { if (null != this.mFile) { fileIo.closeSync(this.mFile); } this.mIsWriting = false; } } stop() { if (null == this.mDataCallBack) { return; } try { this.mIsReadFile = false; } catch (e) { console.error(TAG, "read file error " + e); } } release() { if (null == this.mDataCallBack) { return; } try { this.mDataCallBack = null; this.mIsReadFile = false; } catch (e) { console.error(TAG, "read file error " + e); } } } function sleep(ms: number): Promise<void> { return new Promise<void>(resolve => setTimeout(resolve, ms)); } 语言转文字功能组件代码示例:import { speechRecognizer } from '@kit.CoreSpeechKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FileUtil } from './FileUtil'; const TAG = 'SpeechRecognition'; /** * 语音识别结果回调接口 */ export interface SpeechRecognitionResult { text: string; isFinal: boolean; sessionId: string; } /** * 语音识别错误回调接口 */ export interface SpeechRecognitionError { code: number; message: string; sessionId: string; } /** * 语音识别配置参数 */ export interface SpeechRecognitionConfig { // 语音识别模式:'short' | 'long' recognizerMode?: string; // 语音识别语言 language?: string; // 是否在线识别 online?: number; // 音频采样率 sampleRate?: number; // 音频声道数 soundChannel?: number; // 音频采样位数 sampleBit?: number; // VAD开始时间 vadBegin?: number; // VAD结束时间 vadEnd?: number; // 最大音频时长 maxAudioDuration?: number; } export interface State { isReady: boolean; isRecording: boolean; isProcessing: boolean } export class SpeechRecognitionController { initialize = (config?: SpeechRecognitionConfig): Promise<boolean> => { return new Promise<boolean>((resolve) => { }); } startRecording = (config?: Partial<SpeechRecognitionConfig>) => { } stopRecording = () => { } recognizeFromFile = (filePath: string, config?: Partial<SpeechRecognitionConfig>): Promise<boolean> => { return new Promise<boolean>((resolve) => { }); } querySupportedLanguages = () => { } release = () => { } } /** * 语音识别组件 */ @Component export struct SpeechRecognition { speechRecognitionController: SpeechRecognitionController = new SpeechRecognitionController(); // 识别结果文本 @State recognitionText: string = "语音转文字"; // 组件是否准备就绪 @State isReady: boolean = false; // 是否正在录音 @State isRecording: boolean = false; // 是否正在处理文件 @State isProcessing: boolean = false; // 会话ID private sessionId: string = this.generateSessionId(); // 文件处理会话ID private fileSessionId: string = this.generateSessionId(); // 语音识别引擎 private asrEngine: speechRecognizer.SpeechRecognitionEngine | null = null; // 文件捕获器 private fileCapturer: FileUtil = new FileUtil(); // 识别结果回调 onRecognitionResult?: (result: SpeechRecognitionResult) => void; // 错误回调 onError?: (error: SpeechRecognitionError) => void; // 状态变化回调 onStateChange?: (state: State) => void; /** * 初始化语音识别引擎 */ async initialize(config?: SpeechRecognitionConfig): Promise<boolean> { try { if (config) { const extraParam: Record<string, Object> = { "locate": "CN", "recognizerMode": config.recognizerMode ? config.recognizerMode : "" }; const initParamsInfo: speechRecognizer.CreateEngineParams = { language: config.language!, online: config.online!, extraParams: extraParam }; return new Promise<boolean>((resolve) => { speechRecognizer.createEngine(initParamsInfo, (err: BusinessError, engine: speechRecognizer.SpeechRecognitionEngine) => { if (!err) { this.asrEngine = engine; this.setRecognitionListener(); this.isReady = true; this.updateState(); console.info(TAG, '语音识别引擎初始化成功'); resolve(true); } else { console.error(TAG, `初始化语音识别引擎失败: ${err.message}`); this.handleError(this.sessionId, 1002200001, `初始化失败: ${err.message}`); resolve(false); } }); }); } else { return false; } } catch (error) { console.error(TAG, `初始化异常: ${error.message}`); this.handleError(this.sessionId, 1002200001, `初始化异常: ${error.message}`); return false; } } /** * 开始录音识别 */ startRecording(config?: Partial<SpeechRecognitionConfig>): void { if (!this.asrEngine || !this.isReady) { this.handleError(this.sessionId, 1002200002, '引擎未初始化'); return; } if (this.isRecording) { this.handleError(this.sessionId, 1002200002, '正在录音中'); return; } try { if (!config) { const defaultConfig: SpeechRecognitionConfig = { sampleRate: 16000, soundChannel: 1, sampleBit: 16, vadBegin: 2000, vadEnd: 3000, maxAudioDuration: 20000 }; config = defaultConfig; } const audioParam: speechRecognizer.AudioInfo = { audioType: 'pcm', sampleRate: config.sampleRate!, soundChannel: config.soundChannel!, sampleBit: config.sampleBit! }; const extraParam: Record<string, Object> = { "recognitionMode": 0, "vadBegin": config.vadBegin!, "vadEnd": config.vadEnd!, "maxAudioDuration": config.maxAudioDuration! }; const recognizerParams: speechRecognizer.StartParams = { sessionId: this.sessionId, audioInfo: audioParam, extraParams: extraParam }; this.asrEngine.startListening(recognizerParams); this.isRecording = true; this.updateState(); console.info(TAG, '开始录音识别'); } catch (error) { console.error(TAG, `开始录音失败: ${error.message}`); this.handleError(this.sessionId, 1002200002, `开始录音失败: ${error.message}`); } } /** * 停止录音识别 */ stopRecording(): void { if (!this.asrEngine || !this.isRecording) { return; } try { this.asrEngine.cancel(this.sessionId); this.isRecording = false; this.updateState(); console.info(TAG, '停止录音识别'); } catch (error) { console.error(TAG, `停止录音失败: ${error.message}`); this.handleError(this.sessionId, 1002200003, `停止录音失败: ${error.message}`); } } /** * 从音频文件识别 */ async recognizeFromFile(filePath: string, config?: Partial<SpeechRecognitionConfig>): Promise<boolean> { if (!this.asrEngine || !this.isReady) { this.handleError(this.fileSessionId, 1002200002, '引擎未初始化'); return false; } if (this.isProcessing) { this.handleError(this.fileSessionId, 1002200002, '正在处理文件中'); return false; } try { if (!config) { const defaultConfig: SpeechRecognitionConfig = { sampleRate: 16000, soundChannel: 1, sampleBit: 16 }; config = defaultConfig; } const audioParam: speechRecognizer.AudioInfo = { audioType: 'pcm', sampleRate: config.sampleRate!, soundChannel: config.soundChannel!, sampleBit: config.sampleBit! }; const recognizerParams: speechRecognizer.StartParams = { sessionId: this.fileSessionId, audioInfo: audioParam }; this.asrEngine.startListening(recognizerParams); this.isProcessing = true; this.updateState(); return new Promise<boolean>((resolve) => { this.fileCapturer.setFilePath(filePath); this.fileCapturer.init(async (dataBuffer: ArrayBuffer) => { try { const uint8Array: Uint8Array = new Uint8Array(dataBuffer); this.asrEngine!.writeAudio(this.fileSessionId, uint8Array); } catch (error) { console.error(TAG, `写入音频数据失败: ${error.message}`); this.handleError(this.fileSessionId, 1002200004, `写入音频数据失败: ${error.message}`); resolve(false); } }); this.fileCapturer.start().then(() => { this.isProcessing = false; this.updateState(); this.fileCapturer.release(); resolve(true); }).catch((error: BusinessError) => { console.error(TAG, `文件处理失败: ${error.message}`); this.handleError(this.fileSessionId, 1002200005, `文件处理失败: ${error.message}`); this.isProcessing = false; this.updateState(); resolve(false); }); }); } catch (error) { console.error(TAG, `文件识别失败: ${error.message}`); this.handleError(this.fileSessionId, 1002200005, `文件识别失败: ${error.message}`); this.isProcessing = false; this.updateState(); return false; } } /** * 查询支持的语言 */ querySupportedLanguages(): void { if (!this.asrEngine) { this.handleError(this.sessionId, 1002200002, '引擎未初始化'); return; } try { const languageQuery: speechRecognizer.LanguageQuery = { sessionId: this.sessionId }; this.asrEngine.listLanguages(languageQuery, (err: BusinessError, languages: Array<string>) => { if (!err) { const resultText = `支持的语言: ${JSON.stringify(languages)}`; this.recognitionText = resultText; console.info(TAG, `查询语言成功: ${resultText}`); } else { console.error(TAG, `查询语言失败: ${err.message}`); this.handleError(this.sessionId, 1002200006, `查询语言失败: ${err.message}`); } }); } catch (error) { console.error(TAG, `查询语言异常: ${error.message}`); this.handleError(this.sessionId, 1002200006, `查询语言异常: ${error.message}`); } } /** * 释放引擎资源 */ release(): void { try { if (this.asrEngine) { this.asrEngine.shutdown(); this.asrEngine = null; this.isReady = false; this.isRecording = false; this.isProcessing = false; this.updateState(); console.info(TAG, '语音识别引擎已释放'); } } catch (error) { console.error(TAG, `释放引擎失败: ${error.message}`); this.handleError(this.sessionId, 1002200007, `释放引擎失败: ${error.message}`); } } /** * 获取当前状态 */ getState(): State { return { isReady: this.isReady, isRecording: this.isRecording, isProcessing: this.isProcessing }; } /** * 获取识别文本 */ getRecognitionText(): string { return this.recognitionText; } aboutToAppear(): void { if (this.speechRecognitionController) { this.speechRecognitionController.initialize = (config?: SpeechRecognitionConfig): Promise<boolean> => { return this.initialize(config); } this.speechRecognitionController.startRecording = (config?: Partial<SpeechRecognitionConfig>) => { this.startRecording(config); } this.speechRecognitionController.stopRecording = () => { this.stopRecording() } this.speechRecognitionController.recognizeFromFile = (filePath: string, config?: Partial<SpeechRecognitionConfig>): Promise<boolean> => { return this.recognizeFromFile(filePath, config); } this.speechRecognitionController.querySupportedLanguages = () => { this.querySupportedLanguages(); } this.speechRecognitionController.release = () => { this.release(); } } } /** * 构建UI */ build() { // 这个组件主要提供功能,UI可以根据需要自定义 // 这里提供一个基础的文本显示 Text(this.recognitionText) .fontColor($r('sys.color.ohos_id_color_text_secondary')) .constraintSize({ minHeight: 100 }) .border({ width: 1, radius: 5 }) .backgroundColor('#d3d3d3') .padding(20) .width('100%') } /** * 设置识别监听器 */ private setRecognitionListener(): void { if (!this.asrEngine) { return; } const listener: speechRecognizer.RecognitionListener = { onStart: (sessionId: string, eventMessage: string) => { this.recognitionText = ''; console.info(TAG, `识别开始, sessionId: ${sessionId}, message: ${eventMessage}`); }, onEvent: (sessionId: string, eventCode: number, eventMessage: string) => { console.info(TAG, `识别事件, sessionId: ${sessionId}, code: ${eventCode}, message: ${eventMessage}`); }, onResult: (sessionId: string, result: speechRecognizer.SpeechRecognitionResult) => { console.info(TAG, `识别结果, sessionId: ${sessionId}, result: ${JSON.stringify(result)}`); this.recognitionText = result.result; if (this.onRecognitionResult) { this.onRecognitionResult({ text: result.result, isFinal: true, // 可以根据实际结果调整 sessionId: sessionId }); } }, onComplete: (sessionId: string, eventMessage: string) => { console.info(TAG, `识别完成, sessionId: ${sessionId}, message: ${eventMessage}`); if (sessionId === this.sessionId) { this.isRecording = false; } else if (sessionId === this.fileSessionId) { this.isProcessing = false; } this.updateState(); }, onError: (sessionId: string, errorCode: number, errorMessage: string) => { console.error(TAG, `识别错误, sessionId: ${sessionId}, code: ${errorCode}, message: ${errorMessage}`); this.handleError(sessionId, errorCode, errorMessage); if (sessionId === this.sessionId) { this.isRecording = false; } else if (sessionId === this.fileSessionId) { this.isProcessing = false; } this.updateState(); } }; this.asrEngine.setListener(listener); } /** * 处理错误 */ private handleError(sessionId: string, errorCode: number, errorMessage: string): void { if (this.onError) { this.onError({ code: errorCode, message: errorMessage, sessionId: sessionId }); } } /** * 更新状态 */ private updateState(): void { if (this.onStateChange) { let state: State = { isReady: this.isReady, isRecording: this.isRecording, isProcessing: this.isProcessing } this.onStateChange(state); } } /** * 生成会话ID */ private generateSessionId(): string { return Date.now().toString() + Math.random().toString(36).substr(2, 9); } } 语言转文字演示页面代码示例:import { SpeechRecognition, SpeechRecognitionController, SpeechRecognitionResult, SpeechRecognitionError, State } from './SpeechRecognition'; import { PromptAction } from '@kit.ArkUI'; @Entry @Component struct Index { @State recognitionText: string = "语音转文字"; @State isReady: boolean = false; @State isRecording: boolean = false; @State isProcessing: boolean = false; private uiContext: UIContext = this.getUIContext(); private promptAction: PromptAction = this.uiContext.getPromptAction(); speechRecognitionController: SpeechRecognitionController = new SpeechRecognitionController(); build() { Column() { Scroll() { Column() { // 语音识别组件 SpeechRecognition({ speechRecognitionController: this.speechRecognitionController, onRecognitionResult: (result: SpeechRecognitionResult) => { this.recognitionText = result.text; console.info('识别结果:', result); }, onError: (error: SpeechRecognitionError) => { this.recognitionText = `错误: ${error.message} (代码: ${error.code})`; this.promptAction.showToast({ message: `错误: ${error.message}`, duration: 3000 }); console.error('识别错误:', error); }, onStateChange: (state: State) => { this.isReady = state.isReady; this.isRecording = state.isRecording; this.isProcessing = state.isProcessing; console.info('状态变化:', state); } }) Row() { Column() { Text(this.recognitionText) .fontColor($r('sys.color.ohos_id_color_text_secondary')) } .width('100%') .constraintSize({ minHeight: 100 }) .border({ width: 1, radius: 5 }) .backgroundColor('#d3d3d3') .padding(20) .alignItems(HorizontalAlign.Start) } .width('100%') .padding({ left: 20, right: 20, top: 20, bottom: 20 }) // 状态显示 Row() { Text(`状态: ${this.isReady ? '就绪' : '未就绪'} | ${this.isRecording ? '录音中' : '未录音'} | ${this.isProcessing ? '处理中' : '空闲'}`) .fontSize(14) .fontColor(Color.Gray) } .width('100%') .padding(10) Button() { Text("初始化引擎") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(async () => { const success = await this.speechRecognitionController.initialize({ language: 'zh-CN', recognizerMode: 'short', sampleRate: 16000, online: 1 }); if (success) { this.promptAction.showToast({ message: '初始化成功!', duration: 2000 }); } else { this.promptAction.showToast({ message: '初始化失败!', duration: 2000 }); } }) Button() { Text(this.isRecording ? "停止录音" : "开始录音") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor(this.isRecording ? "#FF0000" : "#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(() => { if (this.isRecording) { this.speechRecognitionController.stopRecording(); this.promptAction.showToast({ message: '停止录音', duration: 2000 }); } else { this.speechRecognitionController.startRecording(); this.promptAction.showToast({ message: '开始录音', duration: 2000 }); } }) Button() { Text("文件识别") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(async () => { // 这里需要提供音频文件路径 const filePath = "你的音频文件路径"; const success = await this.speechRecognitionController.recognizeFromFile(filePath); if (success) { this.promptAction.showToast({ message: '文件识别开始', duration: 2000 }); } else { this.promptAction.showToast({ message: '文件识别失败', duration: 2000 }); } }) Button() { Text("查询支持语言") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(() => { this.speechRecognitionController.querySupportedLanguages(); this.promptAction.showToast({ message: '查询语言', duration: 2000 }); }) Button() { Text("释放引擎") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AA7") .width("80%") .height(50) .margin(10) .onClick(() => { this.speechRecognitionController.release(); this.promptAction.showToast({ message: '释放引擎', duration: 2000 }); }) } .layoutWeight(1) } .width('100%') .height('100%') } } aboutToDisappear() { // 释放资源 this.speechRecognitionController.release(); } } 1.5方案成果总结鸿蒙语音转文字应用在功能、稳定性、体验等多方面均取得显著改善,具体成果如下:核心功能全面可用:成功打通录音实时转文字、音频文件转写两条核心路径,语音识别结果精准呈现,报错信息明确易懂;语言查询功能也能正常输出清晰的支持语言列表,完全覆盖用户日常语音转文字的核心需求。运行状态稳定可靠:音频文件读取断档、数据混乱的问题彻底解决,语音识别过程中几乎无卡顿、中断情况。即便出现少量异常,也能通过规范的消息标识快速定位原因,大幅降低了应用故障对使用的影响。多场景适配能力达标:预设的通用音频参数可适配市面上多数常见音频格式,手动调整参数的设计又能应对特殊音频的转写需求,无论是日常录音还是导入的音频文件,都能顺利完成转文字操作。用户使用体验显著提升:界面上的录音、处理等状态与实际操作实时同步,再也没有状态滞后的情况;识别结果和提示消息格式统一、来源清晰,用户能快速 get 关键信息,操作反馈及时,整体使用流程顺畅无阻碍。系统适配契合度高:应用严格贴合鸿蒙系统的运行规则,功能与界面的生命周期同步,避免了后台无效运行造成的电量和内存浪费。同时,标准化的接口设计也让应用能更好地融入鸿蒙生态,为后续功能扩展打下良好基础。
  • [技术干货] 开发者技术支持-半模态弹窗技术方案总结
    1、关键技术难点总结1.1 问题说明(一)宿主组件隐藏而绑定的半模态页面跟着消失了:在鸿蒙应用开发中,存在宿主组件隐藏时半模态弹窗意外消失的问题。当用户在打开半模态选择弹窗后,若在操作过程中使绑定半模态弹窗的组件隐藏了,发现弹窗已无故关闭,导致需要重新选择规格,这种过度严格的关联性源于弹窗生命周期与宿主组件过度绑定,未考虑临时性切换场景。(二)数据传递与状态同步:在半模态弹窗编辑了数据时,弹窗编辑的数据需实时同步到主页面上。如笔记功能在主页面通过添加按钮弹出添加笔记半模态窗口,输入了笔记内容,点击确认后,需要及时在主页的笔记列表显示。此外,在电商场景中,用户在半模态弹窗中选择商品规格、数量等信息后,这些数据需要准确无误地传递回主页面并更新相应的展示内容。如果数据传递过程中出现延迟、丢失或不一致,将直接影响用户体验和业务逻辑的正确性。1.2 原因分析生命周期耦合:半模态弹窗与宿主组件的生命周期存在绑定关系,通过隐藏与显示控制宿主组件的存在与否。当宿主组件不存在时,则与宿主组件绑定的半模态窗也会随着不存在。状态管理机制不完善:在复杂的数据交互场景中,弹窗与主页面之间的状态同步缺乏有效的管理机制。当弹窗中数据发生变化时,未能建立可靠的数据传递通道,导致数据无法及时更新到主页面。同时,双向数据绑定的实现方式不当,容易造成数据循环更新或状态不一致的问题。在多层级组件嵌套的情况下,状态传递路径过长,增加了数据同步的复杂性和出错概率。2、解决思路深入理解API:全面掌握bindSheet API的各个参数和功能,特别是detents、dragBar、maskColor等关键配置项模块化设计:将弹窗内容封装在@Builder装饰器中,提高代码复用性和维护性Visibility控制宿主组件显隐:通过控制Visibility.Visible与Visibility.None模式的切换,控制宿主组件的显隐,当宿主组件隐藏时,仍然存在于页面中,半模态页面也会显示。状态管理优化:使用@State和$$语法正确管理弹窗显示状态,确保数据流清晰3、解决方案3.1 定义状态变量// 1. 定义状态变量 @State isSheetOpen: boolean = false @State isDisplay: boolean = true @State sheetMessage: string = "" @State inputValue: string = "" @State selectedOption: string = "选项1" 3.2 创建弹窗内容构建器@Builder CustomSheetContent() { Column() { // 输入框 TextInput({ placeholder: '请输入内容...', text: this.inputValue }) .onChange((value: string) => { this.inputValue = value }) .width('90%') .height(40) .borderRadius(8) .margin({ bottom: 20 }) // 选项列表 Column() { ForEach(['选项1', '选项2', '选项3'], (option: string) => { Row() { Text(option) .fontSize(16) Blank() if (this.selectedOption === option) { Image($r('app.media.ic_arrow_left')) .width(20) .height(20) } } .width('100%') .padding({ left: 15, right: 15, top: 20, bottom: 20 }) .borderRadius(8) .backgroundColor(this.selectedOption === option ? '#f0f0f0' : '#ffffff') .onClick(() => { this.selectedOption = option }) }) } .width('90%') .borderRadius(8) .backgroundColor('#f8f8f8') .margin({ bottom: 20 }) // 操作按钮 Row() { Button('取消') .onClick(() => { this.isSheetOpen = false }) .layoutWeight(1) .margin({ right: 10 }) Button('确认') .onClick(() => { this.sheetMessage = `您输入了: ${this.inputValue}, 选择了: ${this.selectedOption}` this.isSheetOpen = false }) .layoutWeight(1) .margin({ left: 10 }) .backgroundColor(Color.Blue) .fontColor(Color.White) } .width('90%') .margin({ bottom: 20 }) } .width('100%') .height('100%') } 3.3 绑定半模态弹窗,控制宿主组件显隐// 绑定半模态弹窗 Button('打开半模态弹窗') .bindSheet($$this.isSheetOpen, this.CustomSheetContent(), { detents: [SheetSize.MEDIUM, SheetSize.FIT_CONTENT , SheetSize.LARGE], //dragBar: false, // 显示拖拽条 maskColor: Color.Black, // 设置遮罩颜色 backgroundColor: Color.Transparent, // 背景色透明 enableOutsideInteractive: true, // 允许与外部交互 onWillAppear: () => { console.log('弹窗即将出现') }, onWillDisappear: () => { console.log('弹窗即将消失') }, onDetentsDidChange: (height: number) => { console.log(`弹窗高度变化: ${height}`) } }) .visibility(this.isDisplay ? Visibility.Visible : Visibility.None) //控制显隐 4、方案成果总结(一)功能完备性弹窗生命周期独立控制:通过Visibility控制宿主组件显隐而非销毁重建,确保半模态弹窗在宿主组件隐藏时仍能保持显示状态,解决了弹窗意外关闭的问题;多档位高度调节:支持FIT_CONTENT、MEDIUM、LARGE三种预设高度及自定义像素高度,用户可通过拖拽在不同档位间自由切换,满足不同内容展示需求;数据双向同步:建立可靠的双向数据传递通道,确保弹窗内数据变化能实时同步到主页面,主页面状态更新也能及时反馈到弹窗中。(二)交互与体验优化原生交互复用:基于鸿蒙原生bindSheet API实现,操作流畅度与系统组件一致,提供熟悉的操作体验;实时反馈清晰:通过onWillAppear、onWillDisappear等生命周期回调捕获弹窗状态变化,用户可实时掌握弹窗显示/隐藏状态;手势操作自然:支持拖拽条手势操作调整高度,点击遮罩层关闭弹窗等符合用户习惯的交互方式,提升操作便捷性。(三)代码可维护性模块化封装:将弹窗内容封装在@Builder装饰器中,提高代码复用性和维护性,组件内部处理复杂的状态管理逻辑,对外仅暴露简洁的接口;扩展性强:通过SheetOptions配置对象灵活控制弹窗样式与行为,新增功能场景仅需调整相关参数,无需重构核心代码;组件复用性高:
  • [技术干货] 开发者技术支持-下拉刷新/上拉加载技术方案总结
    1、关键技术难点总结1.1 问题说明(一)Tabs与List嵌套布局存在滚动冲突,影响用户流畅浏览体验在Tabs组件内嵌套List组件实现多页签内容展示时,由于Tabs组件和List组件均拥有独立的滚动机制,在滚动操作过程中容易产生手势识别冲突,导致滚动行为中断或响应不连贯。(二)下拉刷新与上拉加载状态管理复杂,难以维护多状态同步下拉刷新和上拉加载功能涉及多种状态(如刷新中、加载更多、空闲状态等),且每个Tab页签需要独立管理自身状态,状态数量多、变化频繁,导致状态管理逻辑复杂;(三)多Tab页数据隔离与同步机制不完善,难以保证数据一致性多个Tab页签需要展示不同类别的数据,每个Tab页签的数据源相互独立,但在某些场景下又需要共享数据更新逻辑(如全局刷新);数据隔离与同步机制不完善,容易造成数据更新遗漏或重复,难以保证多个Tab页签数据的一致性。1.2 原因分析嵌套布局存在滚动冲突:Tabs与List组件各自维护独立的滚动控制器和手势识别器,嵌套滚动层级关系不明确,导致手势事件分发机制混乱,缺乏统一的滚动协调机制来处理父子组件间的滚动优先级下拉刷新与上拉加载状态管理复杂:状态分散在各个Tab页面,缺乏统一的状态管理策略,刷新状态、数据状态、分页状态等多维度状态交织,异步操作与状态更新时序难以保证一致性多Tab页数据隔离与同步机制不完善:数据模型设计未充分考虑多Tab场景下的隔离需求,缺乏统一的数据加载和更新策略2、解决思路模块化设计:采用分层架构设计,将状态管理、数据加载、UI渲染分离,通过统一的多Tab间的交互逻辑,确保各层职责清晰、耦合度低。状态隔离:为每个Tab页创建独立的状态管理对象,避免状态混淆,采用响应式状态管理方案,实现状态的精准更新和高效同步数据驱动UI:充分利用ArkUI的响应式特性,让UI自动响应数据变化,提供清晰的操作反馈和状态提示,确保下拉刷新、上拉加载等操作的流畅性和可感知性。3、解决方案3.1 核心架构设计采用面向对象的设计思想,创建两个核心数据模型类:TabData:管理单个Tab的所有状态和数据ListItemData:表示列表中的单项数据3.2 关键实现要点使用@ObservedV2装饰器:实现深度监听,确保嵌套对象属性变化能被正确检测防抖处理:// 防止重复刷新 if (this.tabData[tabIndex].isRefreshing) { return } 3.3 UI组件设计下拉刷新组件:使用原生Refresh组件,提供流畅的刷新体验上拉加载组件:结合List的onReachEnd事件实现自动加载状态提示组件:加载中、无更多数据等状态的友好提示3.4 代码示例@Entry @Component struct TabRefreshDemo { @State currentTabIndex: number = 0 @State tabData: TabData[] = [ new TabData('推荐', []), new TabData('热点', []), new TabData('科技', []) ] build() { Column() { // Tab栏 - 自定义TabBar this.BuildTabBar() // 使用Tabs容器包裹TabContent Tabs({ index: this.currentTabIndex }) { ForEach(this.tabData, (item: TabData, index: number) => { TabContent() { this.BuildRefreshList(index) } }) } .onChange((index: number) => { this.currentTabIndex = index }) .width('100%') .layoutWeight(1) // 占据剩余空间 } .width('100%') .height('100%') } @Builder BuildTabBar() { Row() { ForEach(this.tabData, (item: TabData, index: number) => { Column() { Text(item.title) .fontSize(18) .fontColor(this.currentTabIndex === index ? '#007DFF' : '#666666') .padding(16) // 选中指示器 if (this.currentTabIndex === index) { Divider() .color('#007DFF') .strokeWidth(2) .width(20) .margin({ top: 4 }) } } .onClick(() => { this.currentTabIndex = index }) }) } .justifyContent(FlexAlign.SpaceAround) .width('100%') .backgroundColor('#F5F5F5') } @Builder BuildRefreshList(tabIndex: number) { Column() { Refresh({ refreshing: this.tabData[tabIndex].isRefreshing, offset: 80, friction: 80 }) { List({ space: 12 }) { ForEach(this.tabData[tabIndex].dataList, (item: ListItemData) => { ListItem() { this.BuildListItem(item) } }, (item: ListItemData) => item.id.toString()) // 加载更多 if (this.tabData[tabIndex].hasMore) { ListItem() { this.BuildLoadMoreItem(tabIndex) } } } .onReachEnd(() => { this.onLoadMore(tabIndex) }) .width('100%') .layoutWeight(1) } .onRefreshing(() => { this.onRefresh(tabIndex) }) } .width('100%') .height('100%') } @Builder BuildListItem(item: ListItemData) { Column() { Text(item.title) .fontSize(16) .fontColor('#333333') .textAlign(TextAlign.Start) .width('100%') .margin({ bottom: 8 }) Text(item.content) .fontSize(14) .fontColor('#666666') .textAlign(TextAlign.Start) .width('100%') Divider() .margin({ top: 12 }) } .padding(16) .width('100%') } @Builder BuildLoadMoreItem(tabIndex: number) { Column() { if (this.tabData[tabIndex].isLoadingMore) { Row() { LoadingProgress() .width(20) .height(20) Text('加载中...') .fontSize(14) .fontColor('#999999') .margin({ left: 8 }) } } else { Text('上拉加载更多') .fontSize(14) .fontColor('#999999') } } .padding(20) .width('100%') .justifyContent(FlexAlign.Center) } // 下拉刷新 onRefresh(tabIndex: number) { if (this.tabData[tabIndex].isRefreshing) { return } this.tabData[tabIndex].isRefreshing = true // 模拟网络请求 setTimeout(() => { const newData = this.generateMockData(10, tabIndex) this.tabData[tabIndex].dataList = newData this.tabData[tabIndex].isRefreshing = false this.tabData[tabIndex].pageIndex = 1 this.tabData[tabIndex].hasMore = true }, 1500) } // 上拉加载更多 onLoadMore(tabIndex: number) { if (this.tabData[tabIndex].isLoadingMore || !this.tabData[tabIndex].hasMore) { return } this.tabData[tabIndex].isLoadingMore = true // 模拟网络请求 setTimeout(() => { const moreData = this.generateMockData(5, tabIndex) // 使用concat创建新数组 this.tabData[tabIndex].dataList = this.tabData[tabIndex].dataList.concat(moreData) this.tabData[tabIndex].isLoadingMore = false this.tabData[tabIndex].pageIndex += 1 // 模拟没有更多数据的情况 if (this.tabData[tabIndex].pageIndex >= 3) { this.tabData[tabIndex].hasMore = false } }, 1000) } // 生成模拟数据 generateMockData(count: number, tabIndex: number): ListItemData[] { const titles: string[] = ['鸿蒙开发实战', 'ArkTS进阶指南', '性能优化技巧', 'UI组件详解'] const tabTitles: string[] = ['推荐', '热点', '科技'] const result: ListItemData[] = [] for (let i = 0; i < count; i++) { const titleIndex: number = i % titles.length result.push(new ListItemData( Date.now() + i, `${titles[titleIndex]} - ${tabTitles[tabIndex]}`, `这是${tabTitles[tabIndex]}页面的第${i + 1}条内容,展示了鸿蒙开发的强大功能。` )) } return result } } // 数据模型类 - 使用ObservedV2装饰器 @ObservedV2 class TabData { @Trace title: string @Trace dataList: ListItemData[] @Trace isRefreshing: boolean = false @Trace isLoadingMore: boolean = false @Trace hasMore: boolean = true @Trace pageIndex: number = 1 constructor(title: string, dataList: ListItemData[]) { this.title = title this.dataList = dataList } } @ObservedV2 class ListItemData { @Trace id: number @Trace title: string @Trace content: string constructor(id: number, title: string, content: string) { this.id = id this.title = title this.content = content } } 4、方案成果总结(一)支持多Tab页面的流畅切换,每个Tab都具备独立的下拉刷新和上拉加载功能,用户可以通过直观的手势操作更新内容。采用分页数据管理机制,确保内容加载的有序性,并提供加载状态、无更多数据等清晰提示,形成了完整的用户体验闭环,满足各类内容浏览需求。(二)流畅的下拉刷新和上拉加载动画效果,通过高效的渲染机制和组件复用策略,能快速处理大量列表项展示,内存管理合理无泄漏。Tab切换响应迅速,内容加载流畅。
  • [开发技术领域专区] 开发者技术支持-自定义动态时钟组件技术案例总结
    1.1问题说明在鸿蒙应用开发中,需要做出一个能自己调整样式、实时显示时间的时钟功能,主要遇到这些问题:在鸿蒙应用里做出包含表盘刻度、指针、中心圆点和时间文字的完整时钟界面;让时钟每秒自动更新,同时不浪费手机资源;让时钟的颜色、大小等样式能灵活调整,满足不同使用场景;规范时钟功能的使用流程,避免不用的时候还占用手机资源。1.2原因分析(一)时钟界面包含多个部分,绘制时需要精确调整位置和角度,且鸿蒙应用的绘图工具需要按特定规则使用;(二)时钟每秒更新需要用到定时刷新功能,若不跟着时钟的使用状态开关,不用时还会继续运行,造成资源浪费;(三)不同场景下需要不同样式的时钟,得让用户能直接设置颜色、大小等,还要让设置好的样式及时生效;(四)鸿蒙应用的功能模块有明确的创建、显示、关闭流程,若定时刷新功能的开关时机不对,会导致时钟显示异常或浪费资源。1.3解决思路(一)把时钟绘图的相关操作整理成独立的工具模块,让界面和绘图逻辑分开,更易维护;(二)跟着时钟的使用流程管理定时刷新功能,打开时钟时启动刷新,关闭时钟时停止刷新,避免资源浪费;(三)设计外部配置项,让用户能直接设置颜色、大小等样式,在时钟启动时应用这些配置;(四)把时钟的默认大小、刷新间隔等固定参数统一管理,后续要修改时更方便。1.4解决方案(一)统一管理固定基础参数:将时钟默认大小、刷新间隔、时间计数规则等常用固定信息集中记录,方便后续统一调整,无需改动核心功能。(二)拆分绘图流程并支持样式配置:把时钟表盘刻度、指针、中心圆点、时间文字等部分拆分绘制,同时开放颜色、尺寸等样式设置入口,确保绘制清晰且样式可灵活调整。(三)封装独立功能模块,规范使用流程:将时钟功能打包成鸿蒙应用可直接集成的模块,按 “启动初始化 — 打开后刷新 — 关闭后停止” 的流程管理,同时提供重置时间、设置特定时间等可控操作。(四)优化显示效果与资源利用效率:刷新时先清空旧画面避免重叠,简化指针绘制逻辑确保位置准确,让时间文字大小与时钟整体比例协调,同时避免无用时的资源浪费。组件代码示例:export class Constants { /** * Number 2. */ static readonly NUMBER_TWO: number = 2; /** * Number 10. */ static readonly NUMBER_TEN: number = 10; /** * Number 60. */ static readonly NUMBER_SIXTY: number = 60; /** * Default size of the watch face. */ static readonly DEFAULT_WATCH_SIZE: number = 280; /** * Default size of the watch face. */ static readonly DEFAULT_WATCH_RADIUS: number = 150; /** * Full percentage. */ static readonly FULL_PERCENTAGE: string = '100%'; /** * Interval time. */ static readonly INTERVAL_TIME: number = 1000; /** * Canvas height add. */ static readonly HEIGHT_ADD: number = 150; /** * Conversion rate. */ static readonly CONVERSION_RATE: number = 0.6; } export class DrawClock { private minute: number = 0; private second: number = 0; private intervalId: number = 0; // 默认颜色配置 private largeScaleColor: string = '#425C5A'; private smallScaleColor: string = '#A2BFBD'; private handColor: string = '#425C5A'; private textColor: string = '#425C5A'; private dotColor: string = '#425C5A'; private canvasWidth: number = Constants.DEFAULT_WATCH_SIZE; // 设置颜色配置 setColors(largeScaleColor: string, smallScaleColor: string, handColor: string, textColor: string, dotColor: string) { this.largeScaleColor = largeScaleColor; this.smallScaleColor = smallScaleColor; this.handColor = handColor; this.textColor = textColor; this.dotColor = dotColor; } // 开始计时 startTimer(context: CanvasRenderingContext2D, radius: number, canvasWidth: number) { this.canvasWidth = canvasWidth; this.updateTime(context, radius, canvasWidth); this.intervalId = setInterval(() => { this.updateTime(context, radius, canvasWidth); }, Constants.INTERVAL_TIME); } // 停止计时 stopTimer() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = 0; } } // 更新时间 private updateTime(context: CanvasRenderingContext2D, radius: number, canvasWidth: number) { this.second++; console.log("updateTime=" + `${this.minute}:${this.second}`); if (this.second >= Constants.NUMBER_SIXTY) { this.second = 0; this.minute++; } context.clearRect(0, 0, canvasWidth, canvasWidth + Constants.HEIGHT_ADD); let time = `${this.fillTime(this.minute)}:${this.fillTime(this.second)}`; this.drawClock(context, radius, this.minute, this.second, time); context.translate(-radius, -radius); } // 时间格式化 private fillTime(time: number) { return time < Constants.NUMBER_TEN ? `0${time}` : `${time}`; } // 重置时间 resetTime() { this.minute = 0; this.second = 0; } // 设置时间 setTime(minute: number, second: number) { this.minute = minute; this.second = second; } drawClock(context: CanvasRenderingContext2D, radius: number, minute: number, second: number, time: string) { this.drawBackGround(context, radius); this.drawMinute(context, radius, minute); this.drawSecond(context, radius, second); this.drawDot(context); this.drawTime(context, radius, time); } drawBackGround(context: CanvasRenderingContext2D, radius: number) { context.save(); context.translate(radius, radius); context.save(); // Draw the scale for (let i = 0; i < 60; i++) { let rad = 2 * Math.PI / 60 * i; let x = Math.cos(rad) * (radius - 12); let y = Math.sin(rad) * (radius - 12); context.beginPath(); context.moveTo(x, y); if (i % 5 == 0) { let x1 = Math.cos(rad) * (radius - 20); let y1 = Math.sin(rad) * (radius - 20); context.strokeStyle = this.largeScaleColor; context.lineWidth = 2; context.lineTo(x1, y1); } else { let x1 = Math.cos(rad) * (radius - 18); let y1 = Math.sin(rad) * (radius - 18); context.strokeStyle = this.smallScaleColor; context.lineWidth = 1; context.lineTo(x1, y1); } context.stroke(); } } // Draw the minute hand drawMinute(context: CanvasRenderingContext2D, radius: number, minute: number) { context.save(); context.beginPath(); context.lineWidth = 3; context.lineCap = 'round'; let rad = 2 * Math.PI / 60 * minute; context.rotate(rad); context.moveTo(0, 10); context.strokeStyle = this.handColor; context.lineTo(0, -radius + 40); context.stroke(); context.restore(); } // Draw the second hand drawSecond(context: CanvasRenderingContext2D, radius: number, second: number) { context.save(); context.beginPath(); context.lineWidth = 2; context.lineCap = 'round'; let rad = 2 * Math.PI / 60 * second; context.rotate(rad); context.moveTo(0, 10); context.strokeStyle = this.handColor; context.lineTo(0, -radius + 21); context.stroke(); context.restore(); } // Draw the center point drawDot(context: CanvasRenderingContext2D) { context.save(); context.beginPath(); context.fillStyle = this.dotColor; context.arc(0, 0, 4, 0, 2 * Math.PI, false); context.fill(); context.restore(); } // Draw the time text below the dial drawTime(context: CanvasRenderingContext2D, radius: number, time: string) { context.save(); context.beginPath(); let fontSize = this.canvasWidth / 3; context.font = fontSize + 'px'; context.textAlign = "center"; context.textBaseline = "middle"; context.fillStyle = this.textColor; context.fillText(time, 0, radius / 3); context.restore(); } } @Component export struct DrawClockComponent { private drawClock: DrawClock = new DrawClock(); private settings: RenderingContextSettings = new RenderingContextSettings(true); private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); @State canvasWidth: number = Constants.DEFAULT_WATCH_SIZE; @State radius: number = Constants.DEFAULT_WATCH_RADIUS; // 可配置参数 @Prop largeScaleColor: string = '#425C5A'; @Prop smallScaleColor: string = '#A2BFBD'; @Prop handColor: string = '#425C5A'; @Prop textColor: string = '#425C5A'; @Prop dotColor: string = '#425C5A'; @Prop clockSize: number = Constants.DEFAULT_WATCH_SIZE; aboutToAppear() { this.drawClock.setColors(this.largeScaleColor, this.smallScaleColor, this.handColor, this.textColor, this.dotColor); this.radius = this.clockSize / Constants.NUMBER_TWO; this.canvasWidth = this.clockSize; } aboutToDisappear(): void { this.drawClock.stopTimer(); } // 重置时间 resetTime(): void { this.drawClock.resetTime(); } // 设置时间 setTime(minute: number, second: number): void { this.drawClock.setTime(minute, second); } build() { Stack({ alignContent: Alignment.Center }) { Canvas(this.context) .padding({ top: 76 }) .width(this.canvasWidth) .height(this.canvasWidth + Constants.HEIGHT_ADD) .onReady(() => { this.drawClock.startTimer(this.context, this.radius, this.canvasWidth); }) } .width(Constants.FULL_PERCENTAGE) .height(Constants.FULL_PERCENTAGE) } } 演示代码示例:import { DrawClockComponent, Constants } from '../utils/DrawClock'; @Entry @Component struct Clock { build() { Stack({ alignContent: Alignment.Center }) { // 使用DrawClock组件,可以自定义颜色和大小 // DrawClockComponent({ // largeScaleColor: '#FF6B6B', // smallScaleColor: '#4ECDC4', // handColor: '#556270', // textColor: '#C44D58', // dotColor: '#FF6B6B' // }) DrawClockComponent() } .width(Constants.FULL_PERCENTAGE) .height(Constants.FULL_PERCENTAGE) } } 1.5方案成果总结通过时钟在鸿蒙系统上的动画体验得到显著提升,具体成果如下:功能齐全:成功做出能实时更新的时钟,包含表盘刻度、指针、中心圆点和时间显示,满足日常计时需求;灵活适配:支持调整颜色、大小,还能重置或设置特定时间,不用修改核心功能就能适配不同使用场景;稳定高效:跟着时钟的使用流程管理刷新功能,避免资源浪费,且各项操作逻辑清晰,后续维护更方便;兼容性好:完全按照鸿蒙应用的使用规则开发,能直接集成到鸿蒙应用中使用,适配效果良好。
  • [技术干货] 开发者技术支持 - 图片裁剪功能技术方案总结
    1、关键技术难点总结1.1 问题说明(一)裁剪区域边界控制不精确,无法满足精准裁剪需求鸿蒙原生图片处理组件仅提供基础的图片显示功能,无法精确控制裁剪区域边界,裁剪区域容易超出图片显示范围;且初始化时裁剪区域未与图片实际显示尺寸一致并居中显示,无法满足用户对精准裁剪的需求。(二)手势操作体验不佳,交互方式不匹配实际使用场景在图片裁剪场景中,用户需要通过手势实现平滑的拖拽、缩放等操作来调整裁剪区域,需要频繁切换操作方式(如先拖拽再缩放),操作繁琐;且无法直观区分不同操作的视觉反馈,用户难以快速感知操作效果。1.2 原因分析坐标系复杂性:显示容器、图片显示区域、裁剪区域等多个坐标系需要正确映射,增加了边界控制的复杂度手势识别冲突:图片平移与裁剪框操作手势需要正确区分和处理,缺乏统一的手势管理机制图片处理API限制:HarmonyOS图片处理API在裁剪区域计算上需要精确控制参数流畅性优化需求:频繁的手势操作需要保证界面流畅性,避免卡顿,对算法优化提出更高要求2、解决思路(一)建立坐标转换机制,实现精准边界控制通过建立显示坐标与图片像素坐标之间的映射关系,精确计算裁剪区域边界,确保裁剪区域不超出图片显示范围,并在初始化时与图片实际显示尺寸一致并居中显示,满足用户精准裁剪需求。(二)优化手势处理机制,提升交互体验使用GestureGroup并行处理多种手势操作,通过状态管理机制确保UI与数据同步,实现流畅的拖拽、缩放等操作;提供直观的视觉反馈,使用户能够快速感知操作效果,降低误操作率。(三)封装图片裁剪组件通过封装组件,提供快速使用的组件,减少开发复杂度,提升开发效率。3、解决方案3.1主页面Index.etsimport { promptAction } from '@kit.ArkUI' import { image } from '@kit.ImageKit'; import { ResourceUtils } from '../utils/ResourceUtils'; import { ImageCropPage } from './ImageCropPage'; @Entry @Component struct Index { @State message: string = '图片裁剪应用' @State isShowCropPage: boolean = false @State selectedImage: string = '' @State pixelMap: image.PixelMap | null = null; // 加载后的图片PixelMap @State isLoading: boolean = false; // 图片加载状态 // https://data.znds.com/attachment/forum/201503/26/132039qb98se8bkjex29rx.jpg @State pictureUri: string = 'https://ts4.tc.mm.bing.net/th/id/OIP-C.xtGCrrFmixnPrsaP5zbwvgHaKX?cb=12&rs=1&pid=ImgDetMain&o=7&rm=3'; private context: Context = this.getUIContext().getHostContext() as Context build() { // 使用Stack作为唯一根节点,同时容纳主页面和裁剪页面 Stack() { // 主页面内容 Column() { Text(this.message) .fontSize(30) .fontWeight(FontWeight.Bold) .margin({ top: 40, bottom: 50 }) // 选择网络图片 TextInput({placeholder: '请输入图片地址' }) .width('88%') .height(50) .margin({ bottom: 20 }) .onChange((v) => { this.pictureUri = v }) Button('加载网络图片') .width('88%') .height(50) .fontSize(18) .backgroundColor('#007DFF') .fontColor(Color.White) .margin({ bottom: 40, left: 10 }) .onClick(() => { this.isLoading = true this.selectFromNetwork() }) // 显示选中的图片 if (this.pixelMap) { Image(this.pixelMap) .width(200) .height(200) .objectFit(ImageFit.Cover) .border({ width: 1, color: '#CCCCCC' }) .margin({ bottom: 20 }) Button('开始裁剪') .width('60%') .height(45) .fontSize(16) .backgroundColor('#FF6A00') .fontColor(Color.White) .onClick(() => { this.isShowCropPage = true }) } else { if (this.isLoading) { // 加载中:显示进度条 LoadingProgress() .color('#ffcf2b2b') .size({ width: 40, height: 40 }) } } } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .padding(20) // 裁剪页面 - 使用条件渲染和绝对定位 if (this.isShowCropPage) { ImageCropPage({ imageSrc: $selectedImage, pixMap: $pixelMap, onSave: (croppedImage: string) => { this.isShowCropPage = false this.selectedImage = croppedImage promptAction.showToast({ message: '图片保存成功!', duration: 2000 }) }, onCancel: () => { this.isShowCropPage = false } }) .width('100%') .height('100%') .position({ x: 0, y: 0 }) } } .width('100%') .height('100%') } // 模拟选择相册图片 private selectFromAlbum() { // 实际开发中应使用系统相册选择器 // 这里使用模拟数据 this.selectedImage = '/resources/base/media/sample_image.jpg' } // 模拟选择网络图片 private async selectFromNetwork() { // 实际开发中应使用网络图片URL https://data.znds.com/attachment/forum/201503/26/132039qb98se8bkjex29rx.jpg // 这里使用模拟数据 let filePath: string | null = await ResourceUtils.downloadImage(this.context, this.pictureUri); if (!filePath) { promptAction.openToast({ message: '图片下载失败', duration: 2000 }) } else { this.selectedImage = filePath this.pixelMap = await ResourceUtils.loadFromCacheFilePath(filePath); } } } 3.2图片裁剪页面ImageCropPage.ets(核心代码片段)// 初始化裁剪区域为图片大小 private initCropRect() { if (this.pixMap) { try { this.pixMap.getImageInfo().then((imgInfo) => { this.imageInfo = { width: imgInfo.size.width, height: imgInfo.size.height }; // 计算图片在容器中的显示尺寸和位置 const containerRatio: number = this.CONTAINER_SIZE / this.CONTAINER_SIZE; const imageRatio: number = this.imageInfo.width / this.imageInfo.height; let displayWidth: number = 0; let displayHeight: number = 0; let displayX: number = 0; let displayY: number = 0; if (imageRatio > containerRatio) { displayWidth = this.CONTAINER_SIZE; displayHeight = this.CONTAINER_SIZE / imageRatio; displayX = 0; displayY = (this.CONTAINER_SIZE - displayHeight) / 2; } else { displayHeight = this.CONTAINER_SIZE; displayWidth = this.CONTAINER_SIZE * imageRatio; displayX = (this.CONTAINER_SIZE - displayWidth) / 2; displayY = 0; } // 设置裁剪区域为图片显示区域 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; this.cropRect = { x: Math.max(controlPointMargin, displayX), y: Math.max(controlPointMargin, displayY), width: Math.max(this.MIN_CROP_SIZE, displayWidth - controlPointMargin * 2), height: Math.max(this.MIN_CROP_SIZE, displayHeight - controlPointMargin * 2) }; }); } catch (error) { console.error('获取图片信息失败: ' + error); } } } 手势操作处理// 图片显示区域手势 Image(this.pixMap) .width('100%') .height(400) .objectFit(ImageFit.Contain) .scale({ x: this.imageTransform.scale, y: this.imageTransform.scale }) .translate({ x: this.imageTransform.offsetX, y: this.imageTransform.offsetY }) .gesture( GestureGroup(GestureMode.Parallel, // 缩放手势 PinchGesture() .onActionStart(() => {}) .onActionUpdate((event: PinchGestureEvent) => { const newScale: number = this.imageTransform.scale * event.scale // 限制缩放范围 this.imageTransform.scale = Math.max(0.5, Math.min(3, newScale)) }), // 平移手势 PanGesture() .onActionStart((event: GestureEvent) => { this.isImageMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isImageMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y this.imageTransform.offsetX += deltaX this.imageTransform.offsetY += deltaY this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isImageMoving = false }) .onActionCancel(() => { this.isImageMoving = false }) ) ) 裁剪框拖拽处理// 裁剪框 - 支持整体拖拽 Rect() .width(this.cropRect.width) .height(this.cropRect.height) .position({ x: this.cropRect.x, y: this.cropRect.y }) .fill(Color.Transparent) .strokeWidth(2) .stroke('#FF6A00') .shadow({ radius: 10, color: '#000000', offsetX: 0, offsetY: 0 }) .gesture( PanGesture() .onActionStart((event: GestureEvent) => { this.isCropMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isCropMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y // 更新裁剪框位置,限制在图片显示范围内 const newRect: CropRect = this.copyCropRect() newRect.x += deltaX newRect.y += deltaY // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isCropMoving = false }) .onActionCancel(() => { this.isCropMoving = false }) ) 边界约束算法// 应用裁剪框约束 - 确保不超出图片范围 private applyCropConstraints(rect: CropRect) { // 获取图片显示区域 const imageDisplayRect: CropRect = this.getImageDisplayRect() // 确保最小尺寸 rect.width = Math.max(this.MIN_CROP_SIZE, rect.width) rect.height = Math.max(this.MIN_CROP_SIZE, rect.height) // 确保裁剪框在图片显示区域内 rect.x = Math.max(imageDisplayRect.x, Math.min(imageDisplayRect.x + imageDisplayRect.width - rect.width, rect.x)) rect.y = Math.max(imageDisplayRect.y, Math.min(imageDisplayRect.y + imageDisplayRect.height - rect.height, rect.y)) // 确保裁剪框在容器范围内 rect.x = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.width, rect.x)) rect.y = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.height, rect.y)) // 确保裁剪框尺寸不超过图片显示区域 rect.width = Math.min(rect.width, imageDisplayRect.width) rect.height = Math.min(rect.height, imageDisplayRect.height) // 确保控制点可见 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; rect.x = Math.max(controlPointMargin, rect.x) rect.y = Math.max(controlPointMargin, rect.y) rect.width = Math.max(this.MIN_CROP_SIZE, Math.min(rect.width, this.CONTAINER_SIZE - rect.x - controlPointMargin)) rect.height = Math.max(this.MIN_CROP_SIZE, Math.min(rect.height, this.CONTAINER_SIZE - rect.y - controlPointMargin)) } 图片保存功能// 实现图片保存功能 private async saveCroppedImage() { this.isSaving = true; try { // 1. 创建裁剪后的图片 const croppedImageUri: string = await this.createCroppedImage(); // 2. 回调通知保存成功 this.onSave(croppedImageUri); promptAction.showToast({ message: '图片已保存到相册', duration: 2000 }); } catch (error) { console.error('保存图片失败: ' + error); promptAction.showToast({ message: '保存失败,请重试', duration: 2000 }); } finally { this.isSaving = false; } } 图片裁剪完整组件代码import photoAccessHelper from '@ohos.file.photoAccessHelper'; import { promptAction } from '@kit.ArkUI'; import { image } from '@kit.ImageKit'; import fs from '@ohos.file.fs'; // 定义接口 interface CropRect { x: number; y: number; width: number; height: number; } interface ImageTransform { scale: number; offsetX: number; offsetY: number; } interface Point { x: number; y: number; } interface ImageInfo { width: number; height: number; } interface ControlParam { x: number; y: number; type: string; } @Component export struct ImageCropPage { @Link imageSrc: string @Link pixMap: image.PixelMap private onSave: (croppedImage: string) => void = () => {} private onCancel: () => void = () => {} // 使用接口定义状态类型 @State cropRect: CropRect = { x: 50, y: 50, width: 200, height: 200 } // 图片变换状态 @State imageTransform: ImageTransform = { scale: 1.0, offsetX: 0, offsetY: 0 } @State selectedAspectRatio: string = 'free' @State isDragging: boolean = false @State activeControlPoint: string = '' @State isSaving: boolean = false // 手势状态 @State lastPanPoint: Point = { x: 0, y: 0 } @State isImageMoving: boolean = false @State isCropMoving: boolean = false // 图片信息 @State imageInfo: ImageInfo = { width: 0, height: 0 } // 常量 private readonly MIN_CROP_SIZE: number = 50 private readonly CONTAINER_SIZE: number = 400 private readonly CONTROL_POINT_SIZE: number = 24 private readonly EDGE_CONTROL_WIDTH: number = 8 private readonly EDGE_CONTROL_HEIGHT: number = 24 // 获取图片访问助手 private phAccessHelper: photoAccessHelper.PhotoAccessHelper | null = null; private context: Context = this.getUIContext().getHostContext() as Context aboutToAppear() { this.initPhotoAccessHelper(); this.initCropRect(); } // 初始化裁剪区域为图片大小 private initCropRect() { // 获取图片信息 if (this.pixMap) { try { this.pixMap.getImageInfo().then((imgInfo) => { this.imageInfo = { width: imgInfo.size.width, height: imgInfo.size.height }; // 计算图片在容器中的显示尺寸和位置 const containerRatio: number = this.CONTAINER_SIZE / this.CONTAINER_SIZE; // 1:1 const imageRatio: number = this.imageInfo.width / this.imageInfo.height; let displayWidth: number = 0; let displayHeight: number = 0; let displayX: number = 0; let displayY: number = 0; if (imageRatio > containerRatio) { // 图片更宽,宽度填满容器 displayWidth = this.CONTAINER_SIZE; displayHeight = this.CONTAINER_SIZE / imageRatio; displayX = 0; displayY = (this.CONTAINER_SIZE - displayHeight) / 2; } else { // 图片更高,高度填满容器 displayHeight = this.CONTAINER_SIZE; displayWidth = this.CONTAINER_SIZE * imageRatio; displayX = (this.CONTAINER_SIZE - displayWidth) / 2; displayY = 0; } // 设置裁剪区域为图片显示区域,并确保控制点可见 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; this.cropRect = { x: Math.max(controlPointMargin, displayX), y: Math.max(controlPointMargin, displayY), width: Math.max(this.MIN_CROP_SIZE, displayWidth - controlPointMargin * 2), height: Math.max(this.MIN_CROP_SIZE, displayHeight - controlPointMargin * 2) }; }); } catch (error) { console.error('获取图片信息失败: ' + error); } } } // 初始化图片访问助手 private async initPhotoAccessHelper() { try { this.phAccessHelper = await photoAccessHelper.getPhotoAccessHelper(this.context); } catch (error) { console.error('Failed to get photo access helper: ' + error); } } // 复制裁剪矩形对象 private copyCropRect(): CropRect { return { x: this.cropRect.x, y: this.cropRect.y, width: this.cropRect.width, height: this.cropRect.height }; } // 复制图片变换对象 private copyImageTransform(): ImageTransform { return { scale: this.imageTransform.scale, offsetX: this.imageTransform.offsetX, offsetY: this.imageTransform.offsetY }; } // 复制点对象 private copyPoint(point: Point): Point { return { x: point.x, y: point.y }; } // 复制图片信息对象 private copyImageInfo(): ImageInfo { return { width: this.imageInfo.width, height: this.imageInfo.height }; } // 获取图片在容器中的显示区域 private getImageDisplayRect(): CropRect { if (!this.pixMap || this.imageInfo.width === 0 || this.imageInfo.height === 0) { return { x: 0, y: 0, width: this.CONTAINER_SIZE, height: this.CONTAINER_SIZE }; } const containerRatio: number = this.CONTAINER_SIZE / this.CONTAINER_SIZE; // 1:1 const imageRatio: number = this.imageInfo.width / this.imageInfo.height; let displayWidth: number = 0; let displayHeight: number = 0; let displayX: number = 0; let displayY: number = 0; if (imageRatio > containerRatio) { // 图片更宽,宽度填满容器 displayWidth = this.CONTAINER_SIZE; displayHeight = this.CONTAINER_SIZE / imageRatio; displayX = 0; displayY = (this.CONTAINER_SIZE - displayHeight) / 2; } else { // 图片更高,高度填满容器 displayHeight = this.CONTAINER_SIZE; displayWidth = this.CONTAINER_SIZE * imageRatio; displayX = (this.CONTAINER_SIZE - displayWidth) / 2; displayY = 0; } // 考虑图片变换(缩放和平移) const scaledWidth: number = displayWidth * this.imageTransform.scale; const scaledHeight: number = displayHeight * this.imageTransform.scale; const scaledX: number = displayX * this.imageTransform.scale + this.imageTransform.offsetX; const scaledY: number = displayY * this.imageTransform.scale + this.imageTransform.offsetY; return { x: Math.max(0, scaledX), y: Math.max(0, scaledY), width: Math.min(this.CONTAINER_SIZE, scaledWidth), height: Math.min(this.CONTAINER_SIZE, scaledHeight) }; } build() { Column() { // 标题栏 Row() { Button('取消') .fontSize(16) .fontColor('#007DFF') .backgroundColor(Color.Transparent) .onClick(() => { this.onCancel() }) Text('图片裁剪') .fontSize(18) .fontWeight(FontWeight.Bold) .layoutWeight(1) .textAlign(TextAlign.Center) // 保存按钮 - 添加加载状态 if (this.isSaving) { LoadingProgress() .width(20) .height(20) .color('#007DFF') } else { SaveButton({text: SaveDescription.SAVE_IMAGE, buttonType: ButtonType.Capsule}) .fontSize(16) .fontColor(Color.White) .backgroundColor('#007DFF') .onClick(() => { this.saveCroppedImage() }) } } .width('100%') .height(50) .padding({ left: 15, right: 15 }) // 裁剪区域 Stack() { // 图片显示区域 手势 Image(this.pixMap) .width('100%') .height(400) .objectFit(ImageFit.Contain) .scale({ x: this.imageTransform.scale, y: this.imageTransform.scale }) .translate({ x: this.imageTransform.offsetX, y: this.imageTransform.offsetY }) .gesture( GestureGroup(GestureMode.Parallel, // 缩放手势 PinchGesture() .onActionStart(() => {}) .onActionUpdate((event: PinchGestureEvent) => { const newScale: number = this.imageTransform.scale * event.scale // 限制缩放范围 this.imageTransform.scale = Math.max(0.5, Math.min(3, newScale)) }), // 平移手势 PanGesture() .onActionStart((event: GestureEvent) => { this.isImageMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isImageMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y this.imageTransform.offsetX += deltaX this.imageTransform.offsetY += deltaY this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isImageMoving = false }) .onActionCancel(() => { this.isImageMoving = false }) ) ) // 裁剪框 - 支持整体拖拽 Rect() .width(this.cropRect.width) .height(this.cropRect.height) .position({ x: this.cropRect.x, y: this.cropRect.y }) .fill(Color.Transparent) .strokeWidth(2) .stroke('#FF6A00') .shadow({ radius: 10, color: '#000000', offsetX: 0, offsetY: 0 }) .gesture( PanGesture() .onActionStart((event: GestureEvent) => { this.isCropMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isCropMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y // 更新裁剪框位置,限制在图片显示范围内 const newRect: CropRect = this.copyCropRect() newRect.x += deltaX newRect.y += deltaY // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isCropMoving = false }) .onActionCancel(() => { this.isCropMoving = false }) ) // 控制点 - 使用计算属性确保实时同步 // 四个角控制点 this.BuildControlPoint({x: this.cropRect.x, y: this.cropRect.y, type:'left-top'}) this.BuildControlPoint({x: this.cropRect.x + this.cropRect.width, y: this.cropRect.y, type: 'right-top'}) this.BuildControlPoint({x: this.cropRect.x, y: this.cropRect.y + this.cropRect.height, type: 'left-bottom'}) this.BuildControlPoint({x: this.cropRect.x + this.cropRect.width, y: this.cropRect.y + this.cropRect.height, type: 'right-bottom'}) // 四个边控制点 this.BuildEdgeControlPoint({x: this.cropRect.x + this.cropRect.width / 2, y: this.cropRect.y, type: 'top'}) this.BuildEdgeControlPoint({x: this.cropRect.x + this.cropRect.width, y: this.cropRect.y + this.cropRect.height / 2, type: 'right'}) this.BuildEdgeControlPoint({x: this.cropRect.x + this.cropRect.width / 2, y: this.cropRect.y + this.cropRect.height, type: 'bottom'}) this.BuildEdgeControlPoint({x: this.cropRect.x, y: this.cropRect.y + this.cropRect.height / 2, type: 'left'}) } .width('100%') .height(400) .backgroundColor('#F5F5F5') .clip(true) .onClick((event: ClickEvent) => { // 点击空白区域取消选中 this.activeControlPoint = '' }) // 控制面板 Column() { // Text('宽高比') // .fontSize(16) // .fontWeight(FontWeight.Medium) // .margin({ bottom: 10 }) // // Row() { // this.BuildRatioButton('自由', 'free') // this.BuildRatioButton('1:1', '1:1') // this.BuildRatioButton('4:3', '4:3') // this.BuildRatioButton('16:9', '16:9') // } // .width('100%') // .justifyContent(FlexAlign.SpaceAround) // .margin({ bottom: 20 }) // // Text('裁剪尺寸') // .fontSize(16) // .fontWeight(FontWeight.Medium) // .margin({ bottom: 10 }) Row() { Column() { Text('宽度') TextInput({ text: Math.round(this.cropRect.width).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue) && numValue >= this.MIN_CROP_SIZE) { const newRect: CropRect = this.copyCropRect() newRect.width = numValue this.applyAspectRatioToRect(newRect) this.applyCropConstraints(newRect) this.cropRect = newRect } }) } Column() { Text('高度') TextInput({ text: Math.round(this.cropRect.height).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue) && numValue >= this.MIN_CROP_SIZE) { const newRect: CropRect = this.copyCropRect() newRect.height = numValue this.applyAspectRatioToRect(newRect) this.applyCropConstraints(newRect) this.cropRect = newRect } }) } } .width('100%') .justifyContent(FlexAlign.SpaceAround) Row() { Column() { Text('X位置') TextInput({ text: Math.round(this.cropRect.x).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue)) { const newRect: CropRect = this.copyCropRect() newRect.x = numValue this.applyCropConstraints(newRect) this.cropRect = newRect } }) } Column() { Text('Y位置') TextInput({ text: Math.round(this.cropRect.y).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue)) { const newRect: CropRect = this.copyCropRect() newRect.y = numValue this.applyCropConstraints(newRect) this.cropRect = newRect } }) } } .width('100%') .justifyContent(FlexAlign.SpaceAround) .margin({ top: 15 }) // 重置按钮 Button('重置裁剪区域') .width('80%') .height(40) .fontSize(16) .backgroundColor('#E5E5E5') .fontColor('#333333') .margin({ top: 20 }) .onClick(() => { this.initCropRect() }) } .width('100%') .padding(15) .backgroundColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#F8F8F8') } // 角控制点 - 优化显示和交互 @Builder BuildControlPoint(param: ControlParam) { Rect() .width(this.CONTROL_POINT_SIZE) .height(this.CONTROL_POINT_SIZE) .radius(this.CONTROL_POINT_SIZE / 2) .fill(this.activeControlPoint === param.type ? '#FFA500' : '#FF6A00') .position({ x: param.x - this.CONTROL_POINT_SIZE / 2, y: param.y - this.CONTROL_POINT_SIZE / 2 }) .shadow({ radius: 3, color: '#000000', offsetX: 1, offsetY: 1 }) .border({ width: 2, color: Color.White }) .gesture( PanGesture() .onActionStart(() => { this.activeControlPoint = param.type this.isDragging = true }) .onActionUpdate((event: GestureEvent) => { if (this.isDragging && this.activeControlPoint === param.type) { this.handleControlPointDrag(param.type, event.offsetX, event.offsetY) } }) .onActionEnd(() => { this.isDragging = false this.activeControlPoint = '' }) .onActionCancel(() => { this.isDragging = false this.activeControlPoint = '' }) ) } // 边控制点 - 优化显示和交互 @Builder BuildEdgeControlPoint(param: ControlParam) { Rect() .width(param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_HEIGHT : this.EDGE_CONTROL_WIDTH) .height(param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_WIDTH : this.EDGE_CONTROL_HEIGHT) .fill(this.activeControlPoint === param.type ? '#FFA500' : '#FF6A00') .position({ x: param.x - (param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_HEIGHT / 2 : this.EDGE_CONTROL_WIDTH / 2), y: param.y - (param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_WIDTH / 2 : this.EDGE_CONTROL_HEIGHT / 2) }) .border({ width: 1, color: Color.White }) .gesture( PanGesture() .onActionStart(() => { this.activeControlPoint = param.type this.isDragging = true }) .onActionUpdate((event: GestureEvent) => { if (this.isDragging && this.activeControlPoint === param.type) { this.handleEdgeControlPointDrag(param.type, event.offsetX, event.offsetY) } }) .onActionEnd(() => { this.isDragging = false this.activeControlPoint = '' }) .onActionCancel(() => { this.isDragging = false this.activeControlPoint = '' }) ) } @Builder BuildRatioButton(text: string, ratio: string) { Button(text) .width(70) .height(35) .fontSize(14) .backgroundColor(this.selectedAspectRatio === ratio ? '#007DFF' : '#E5E5E5') .fontColor(this.selectedAspectRatio === ratio ? Color.White : '#333333') .onClick(() => { this.selectedAspectRatio = ratio const newRect: CropRect = this.copyCropRect() this.applyAspectRatioToRect(newRect) this.applyCropConstraints(newRect) this.cropRect = newRect }) } // 处理角控制点拖拽 - 优化实时同步 private handleControlPointDrag(type: string, offsetX: number, offsetY: number) { console.info(`apply crop offsetX: ${offsetX}, offsetY: ${offsetY}`) console.info("apply crop copy: " + JSON.stringify(this.cropRect)) const newRect: CropRect = this.copyCropRect() switch (type) { case 'left-top': // 计算新的宽度和高度(基于原位置和当前位置的差值) const newWidthLT: number = newRect.width + (newRect.x - offsetX) const newHeightLT: number = newRect.height + (newRect.y - offsetY) // 只有当新尺寸大于最小尺寸时才更新 if (newWidthLT >= this.MIN_CROP_SIZE && newHeightLT >= this.MIN_CROP_SIZE) { newRect.width = newWidthLT newRect.height = newHeightLT newRect.x = offsetX newRect.y = offsetY } else { // 如果小于最小尺寸,则调整到最小尺寸 if (newWidthLT < this.MIN_CROP_SIZE) { newRect.x = newRect.x + newRect.width - this.MIN_CROP_SIZE newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthLT newRect.x = offsetX } if (newHeightLT < this.MIN_CROP_SIZE) { newRect.y = newRect.y + newRect.height - this.MIN_CROP_SIZE newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightLT newRect.y = offsetY } } break case 'right-top': const newWidthRT: number = offsetX - newRect.x const newHeightRT: number = newRect.height + (newRect.y - offsetY) if (newWidthRT >= this.MIN_CROP_SIZE && newHeightRT >= this.MIN_CROP_SIZE) { newRect.width = newWidthRT newRect.height = newHeightRT newRect.y = offsetY } else { if (newWidthRT < this.MIN_CROP_SIZE) { newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthRT } if (newHeightRT < this.MIN_CROP_SIZE) { newRect.y = newRect.y + newRect.height - this.MIN_CROP_SIZE newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightRT newRect.y = offsetY } } break case 'left-bottom': const newWidthLB: number = newRect.width + (newRect.x - offsetX) const newHeightLB: number = offsetY - newRect.y if (newWidthLB >= this.MIN_CROP_SIZE && newHeightLB >= this.MIN_CROP_SIZE) { newRect.width = newWidthLB newRect.height = newHeightLB newRect.x = offsetX } else { if (newWidthLB < this.MIN_CROP_SIZE) { newRect.x = newRect.x + newRect.width - this.MIN_CROP_SIZE newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthLB newRect.x = offsetX } if (newHeightLB < this.MIN_CROP_SIZE) { newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightLB } } break case 'right-bottom': const newWidthRB: number = offsetX - newRect.x const newHeightRB: number = offsetY - newRect.y if (newWidthRB >= this.MIN_CROP_SIZE && newHeightRB >= this.MIN_CROP_SIZE) { newRect.width = newWidthRB newRect.height = newHeightRB } else { if (newWidthRB < this.MIN_CROP_SIZE) { newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthRB } if (newHeightRB < this.MIN_CROP_SIZE) { newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightRB } } break } // 应用宽高比约束 if (this.selectedAspectRatio !== 'free') { this.applyAspectRatioToRect(newRect) } // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect } // 处理边控制点拖拽 - 优化实时同步 private handleEdgeControlPointDrag(type: string, offsetX: number, offsetY: number) { const newRect: CropRect = this.copyCropRect() switch (type) { case 'top': newRect.height += newRect.y - offsetY newRect.y = offsetY break case 'right': newRect.width = offsetX - newRect.x break case 'bottom': newRect.height = offsetY - newRect.y break case 'left': newRect.width += newRect.x - offsetX newRect.x = offsetX break } // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect } // 应用裁剪框约束 - 确保不超出图片范围 private applyCropConstraints(rect: CropRect) { console.info("apply crop before: " + JSON.stringify(rect)) // 获取图片显示区域 const imageDisplayRect: CropRect = this.getImageDisplayRect() // 确保最小尺寸 rect.width = Math.max(this.MIN_CROP_SIZE, rect.width) rect.height = Math.max(this.MIN_CROP_SIZE, rect.height) // 确保裁剪框在图片显示区域内 rect.x = Math.max(imageDisplayRect.x, Math.min(imageDisplayRect.x + imageDisplayRect.width - rect.width, rect.x)) rect.y = Math.max(imageDisplayRect.y, Math.min(imageDisplayRect.y + imageDisplayRect.height - rect.height, rect.y)) // 确保裁剪框在容器范围内 rect.x = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.width, rect.x)) rect.y = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.height, rect.y)) // 确保裁剪框尺寸不超过图片显示区域 rect.width = Math.min(rect.width, imageDisplayRect.width) rect.height = Math.min(rect.height, imageDisplayRect.height) // 确保控制点可见 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; rect.x = Math.max(controlPointMargin, rect.x) rect.y = Math.max(controlPointMargin, rect.y) rect.width = Math.max(this.MIN_CROP_SIZE, Math.min(rect.width, this.CONTAINER_SIZE - rect.x - controlPointMargin)) rect.height = Math.max(this.MIN_CROP_SIZE, Math.min(rect.height, this.CONTAINER_SIZE - rect.y - controlPointMargin)) console.info("apply crop end: " + JSON.stringify(rect)) } // 应用宽高比到矩形 private applyAspectRatioToRect(rect: CropRect) { if (this.selectedAspectRatio !== 'free') { const ratioParts: string[] = this.selectedAspectRatio.split(':') const widthRatio: number = parseInt(ratioParts[0]) const heightRatio: number = parseInt(ratioParts[1]) const targetRatio: number = widthRatio / heightRatio const currentRatio: number = rect.width / rect.height if (currentRatio > targetRatio) { rect.height = rect.width / targetRatio } else { rect.width = rect.height * targetRatio } } } // 实现图片保存功能 private async saveCroppedImage() { this.isSaving = true; try { // 1. 创建裁剪后的图片 const croppedImageUri: string = await this.createCroppedImage(); // 2. 回调通知保存成功 this.onSave(croppedImageUri); promptAction.showToast({ message: '图片已保存到相册', duration: 2000 }); } catch (error) { console.error('保存图片失败: ' + error); promptAction.showToast({ message: '保存失败,请重试', duration: 2000 }); } finally { this.isSaving = false; } } // 创建裁剪后的图片 private async createCroppedImage(): Promise<string> { return new Promise(async (resolve, reject) => { try { // 1. 创建图片源 const sourceFile: fs.File = await fs.open(this.imageSrc, fs.OpenMode.READ_ONLY); const imageSource: image.ImageSource = image.createImageSource(sourceFile.fd); // 2. 获取图片信息 const imageInfo: image.ImageInfo = await imageSource.getImageInfo(); console.log('原始图片信息: ' + JSON.stringify(imageInfo)); // 3. 计算实际裁剪区域 const imageDisplayRect: CropRect = this.getImageDisplayRect() // 计算裁剪区域相对于图片显示区域的比例 const relativeX: number = (this.cropRect.x - imageDisplayRect.x) / imageDisplayRect.width const relativeY: number = (this.cropRect.y - imageDisplayRect.y) / imageDisplayRect.height const relativeWidth: number = this.cropRect.width / imageDisplayRect.width const relativeHeight: number = this.cropRect.height / imageDisplayRect.height // 转换为实际图片坐标 const actualCropX: number = Math.max(0, relativeX * imageInfo.size.width) const actualCropY: number = Math.max(0, relativeY * imageInfo.size.height) const actualCropWidth: number = Math.min( relativeWidth * imageInfo.size.width, imageInfo.size.width - actualCropX ) const actualCropHeight: number = Math.min( relativeHeight * imageInfo.size.height, imageInfo.size.height - actualCropY ) console.log(`实际裁剪区域: x=${actualCropX}, y=${actualCropY}, width=${actualCropWidth}, height=${actualCropHeight}`); // 4. 创建解码选项,设置裁剪区域 const decodingOptions: image.DecodingOptions = { desiredSize: { width: actualCropWidth, height: actualCropHeight }, desiredRegion: { size: { width: actualCropWidth, height: actualCropHeight }, x: actualCropX, y: actualCropY }, rotate: 0 }; // 5. 创建图片打包器 const imagePacker: image.ImagePacker = image.createImagePacker(); // 6. 解码并打包图片 const pixelMap: image.PixelMap = await imageSource.createPixelMap(decodingOptions); const packOpts: image.PackingOption = { format: "image/jpeg", quality: 100 }; const arrayBuffer: ArrayBuffer = await imagePacker.packing(pixelMap, packOpts); // 7. 保存到沙箱 const sandboxPath: string = this.context.filesDir; const timestamp: number = new Date().getTime(); const outputPath: string = `${sandboxPath}/cropped_${timestamp}.jpg`; const file: fs.File = await fs.open(outputPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); await fs.write(file.fd, arrayBuffer); await fs.close(file.fd); // 8. 保存到相册 await this.saveToPhotoAlbum(arrayBuffer); console.log('图片已保存到沙箱: ' + outputPath); // 9. 释放资源 imageSource.release(); pixelMap.release(); imagePacker.release(); resolve(outputPath); } catch (error) { console.error('创建裁剪图片失败: ' + JSON.stringify(error)); reject(error); } }); } // 保存图片到相册 private async saveToPhotoAlbum(buffer: ArrayBuffer): Promise<void> { if (!this.phAccessHelper) { throw new Error('Photo access helper not initialized'); } try { // 创建相册文件 let helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context); let uri: string = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png'); let file: fs.File = await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); // Write to file await fs.write(file.fd, buffer); // Close the file await fs.close(file.fd); console.log('保存到相册成功: ' + uri); } catch (error) { console.error('保存到相册失败: ' + error); } } } 4、方案成果总结(一)功能完备性裁剪区域精准控制:支持以图片实际显示尺寸为基准的任意区域裁剪,裁剪区域范围可通过边界约束算法灵活配置,适配头像裁剪、图片编辑等多场景;手势操作丰富多样:通过拖拽等多种手势分别控制裁剪区域位置与大小,用户可快速完成复杂裁剪操作,减少误操作。(二)交互与体验优化原生交互复用:基于鸿蒙原生手势识别逻辑,操作流畅度与系统组件一致;实时反馈清晰:裁剪区域展示与手势操作同步更新,操作结束实时显示裁剪结果,用户可实时掌握裁剪状态;边界控制严谨:通过边界约束算法限制裁剪区域范围,避免超出图片显示范围,减少异常场景。(三)代码可维护性模块化封装:基于鸿蒙原生组件封装,组件内部处理手势识别与裁剪区域同步,对外仅暴露参数与回调,与业务逻辑解耦,可直接复用于不同场景;扩展性强:新增场景(如固定比例裁剪)仅需修改相关参数,无需重构核心代码;组件复用性高:图片裁剪组件可独立使用,便于集成到其他应用模块中。
  • [技术干货] 开发者技术支持 - 系统路由和动态路由技术方案总结
    1、关键技术难点总结1.1 问题说明在鸿蒙应用开发中,页面路由是连接不同功能模块的核心机制。特别是在开发包含多个功能模块的复杂应用时,如何实现高效、灵活的页面跳转成为了一个关键问题。在开发一个包含多个功能模块的鸿蒙应用时,大多情况下会遇到如下问题:如何在主模块(entry)与子模块之间进行页面跳转如何实现模块间的解耦,避免硬编码依赖如何统一管理静态路由配置和动态路由注册1.2 原因分析鸿蒙传统的页面跳转方式无法直接跨越模块边界各个功能模块独立开发,缺乏统一的路由协调机制静态路由配置文件分散在各个模块中,难以集中管理和维护2、解决思路支持静态路由配置和动态路由注册,满足不同场景的需求提供统一的页面跳转接口,统一路由管理实现跨模块的页面跳转能力,打破模块间壁垒支持参数传递和生命周期管理,确保数据正确流转3、解决方案3.1 方案设计路由配置层:负责定义和管理路由映射关系路由管理层:提供路由注册、查找和跳转功能页面跳转层:封装具体的页面跳转逻辑3.2 功能实现主要代码1. 静态路由配置router_map.json{ "routerMap": [ { "name": "settings", "pageSourceFile": "src/main/ets/pages/Settings.ets", "buildFunction": "SettingsBuilder", "data": { "description": "this is settings" } }, { "name": "LanguageSettings", "pageSourceFile": "src/main/ets/pages/LanguageSettings.ets", "buildFunction": "LanguageSettingsBuilder", "data": { "description": "this is LanguageSettings" } } ] } module.json5{ "module": { "name": "entry", "type": "entry", "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ "phone" ], ... "routerMap": "$profile:router_map", ... } } 2. 主页面实现Index.etsimport { RouterUtils } from "router/src/main/ets/utils/RouterUtils"; import { RouterConstant } from "router/src/main/ets/utils/RouterConstant"; interface MailParam { name: string; address: string } @Entry @Component struct Index { pathStack: NavPathStack = new NavPathStack(); // 静态路由 childPathStack: NavPathStack = new NavPathStack(); // 二级静态路由 @State hapARouter: NavPathStack = new NavPathStack(); // 动态路由 controller: TabsController = new TabsController(); aboutToAppear() { if (!this.hapARouter) { this.hapARouter = new NavPathStack(); }; RouterUtils.createRouter(RouterConstant.ROUTER_ENTRY, this.hapARouter); }; @Builder routerMap(builderName: string, param: object) { RouterUtils.getBuilder(builderName).builder(param); }; build() { Navigation(this.pathStack) { Column() { Row() { Image($r('app.media.Settings')) .width(40) .height(40) .margin({ right: 8 }) .onClick(() => { this.pathStack.pushPathByName('settings', null) }) Text('设置') .fontSize(24) .fontWeight(700) .lineHeight(32) .onClick(() => { this.pathStack.pushPathByName('settings', null) }) Blank() Image($r('app.media.icon_add')) .width(40) .height(40) } .padding({ left: 16, right: 16, top: 20 }) .backgroundColor('#FFFFFF') .width('100%') .alignItems(VerticalAlign.Center) .justifyContent(FlexAlign.SpaceAround) Tabs({controller: this.controller, barPosition: BarPosition.End}) { TabContent() { Column() { Text('动态路由') .fontSize(30) Navigation(this.hapARouter) { Column() { Button("to_harTest_mainPage", { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { RouterUtils.push("harTest", RouterConstant.ROUTER_ENTRY, "./src/main/ets/components/MainPage", "HarTest_PageMain_Builder"); }) } .height('100%') .width('100%') } .navDestination(this.routerMap); } }.tabBar('消息') TabContent() { Column() { Text('系统路由') .fontSize(30) Navigation(this.childPathStack) { Column() { Button('邮件设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. const mailParam: MailParam = { name: 'test', address: 'mail.com' } this.childPathStack.pushPathByName('MailIndex', mailParam ) }) } .height('100%') .width('100%') } //.hideTitleBar(true) //.title('邮件设置') // .titleMode(NavigationTitleMode.Mini) // .hideBackButton(true) } }.tabBar('邮件') TabContent() { Column() { Text('我的') .fontSize(30) Button('设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. //this.pageInfos.clear(); this.pathStack.pushPathByName('settings', null) }) } }.tabBar('我的') } .width('100%') .height('92%') .scrollable(false) } .height('100%') .width('100%') } .title($r('app.string.app_name')) .hideTitleBar(true) } } Settings.ets@Builder export function SettingsBuilder(name: string, param: Object) { Settings() } const COLUMN_SPACE: number = 12; @Component export struct Settings { pageInfos: NavPathStack = new NavPathStack(); build() { NavDestination() { Column({ space: COLUMN_SPACE }) { Text('系统设置') .fontSize(30) Button('语言设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. this.pageInfos.pushPathByName('LanguageSettings', null) }) Button('邮件设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. this.pageInfos.pushPathByName('MailIndex', null) }) } .justifyContent(FlexAlign.End) } //.title('entry-pageOne') .onReady((context: NavDestinationContext) => { this.pageInfos = context.pathStack; console.info("current page config info is " + JSON.stringify(context.getConfigInRouteMap())); }) } } 3. 动态路由管理路由常量定义 (RouterConstant.ets)export class RouterConstant{ static ROUTER_ENTRY = "HapEntry_Router" } 路由工具类 (RouterUtils.ets)export class RouterUtils { static builderMap: Map<string, WrappedBuilder<[object]>> = new Map<string, WrappedBuilder<[object]>>(); static routerMap: Map<string, NavPathStack> = new Map<string, NavPathStack>(); // 通过名称注册builder public static registerBuilder(builderName: string, builder: WrappedBuilder<[object]>): void{ RouterUtils.builderMap.set(builderName, builder); } // 通过名称获取builder public static getBuilder(builderName: string): WrappedBuilder<[object]>{ let builder = RouterUtils.builderMap.get(builderName); return builder as WrappedBuilder<[object]>; } // 通过名称注册router public static createRouter(routerName: string, router: NavPathStack): void{ RouterUtils.routerMap.set(routerName, router); } // 通过名称获取router public static getRouter(routerName: string): NavPathStack{ let router = RouterUtils.routerMap.get(routerName); return router as NavPathStack; } // 通过获取页面栈跳转到指定页面 public static async push(harName: string, routerName: string, path: string, builderName: string): Promise<void>{ // 动态引入要跳转的页面 try { let ns: ESObject = await import(harName); console.info('ns object: ', JSON.stringify(ns)) ns.harInit(path); RouterUtils.getRouter(routerName).pushPathByName(builderName, null); } catch (e) { console.error(JSON.stringify(e)) } } // 通过获取页面栈并将其清空 public static clear(routerName: string): void { // 查找到对应的路由栈进行pop RouterUtils.getRouter(routerName).clear(); } } 4. 动态路由页面MainPage.etsimport { RouterUtils } from "router/src/main/ets/utils/RouterUtils"; import { RouterConstant } from "router/src/main/ets/utils/RouterConstant"; @Builder export function harTestBuilder(value: object) { NavDestination() { Row() { Column() { Text('HarTest') .fontSize($r('app.float.page_text_font_size')) .fontWeight(FontWeight.Bold) Button('返回首页', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { RouterUtils.clear(RouterConstant.ROUTER_ENTRY) }) Button('to_harTest_page1', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .margin(20) .onClick(() => { RouterUtils.push("harTest", RouterConstant.ROUTER_ENTRY, "./src/main/ets/pages/page1", "HarTest_Page1_Builder"); }) } .width('100%') } .height('100%') } //.hideBackButton(true) //.title('harB-pageOne') .onReady((context: NavDestinationContext) => { console.info('current page config info is ' + JSON.stringify(context.getConfigInRouteMap())); }) } let builderName = 'HarTest_PageMain_Builder'; if (!RouterUtils.getBuilder(builderName)) { let builder: WrappedBuilder<[object]> = wrapBuilder(harTestBuilder); RouterUtils.registerBuilder(builderName, builder); } page1.etsimport { RouterUtils } from "router/src/main/ets/utils/RouterUtils"; import { RouterConstant } from "router/src/main/ets/utils/RouterConstant"; @Builder export function harBuilder(value: object) { NavDestination(){ Column(){ Button('返回首页', { stateEffect: true, type: ButtonType.Capsule }) .width('80%') .height(40) .margin(20) .onClick(() => { RouterUtils.clear(RouterConstant.ROUTER_ENTRY) }) Button("to_harTest_mainPage", { stateEffect: true, type: ButtonType.Capsule }) .width('80%') .height(40) .margin(20) .onClick(() => { RouterUtils.push("harTest", RouterConstant.ROUTER_ENTRY, "./src/main/ets/components/MainPage", "HarTest_PageMain_Builder"); }) }.width('100%').height('100%') } .title('HarB_Page1') } let builderName = 'HarTest_Page1_Builder'; if (!RouterUtils.getBuilder(builderName)) { let builder: WrappedBuilder<[object]> = wrapBuilder(harBuilder); RouterUtils.registerBuilder(builderName, builder); } Index.etsexport function harInit(path: string): void { // 动态引入要跳转的页面 switch (path) { case "./src/main/ets/components/MainPage": import("./src/main/ets/components/MainPage"); break; case "./src/main/ets/pages/page1": import("./src/main/ets/pages/page1"); break; default: break; } } 5. 动态import变量表达式配置(在主模块中添加)build-profile.json5{ ... "buildOption": { "arkOptions": { "runtimeOnly": { "sources": [ ], "packages": [ "harTest" ] } } } } 4、方案成果总结统一管理:通过RouterUtils工具类,实现了静态路由和动态路由的统一管理,无论是在entry主模块内部跳转还是跨模块跳转,都使用相同的API接口模块解耦:利用动态import机制和Builder注册机制,实现了模块间的解耦,避免了硬编码依赖,使得各功能模块可以独立开发和维护灵活扩展:支持运行时动态注册路由,新功能模块只需按照规范实现页面和Builder,即可无缝集成到现有路由体系中参数传递:完善了参数传递机制,支持复杂对象在页面间传递,满足了实际业务场景中数据交互的需求
  • [技术交流] 开发者技术支持-Flutter的列表下拉刷新、上拉加载更多组件封装
    1.问题说明:Flutter 原生列表实现下拉、上拉回调加载很费劲,想封装一个基础刷新组件全局使用2.原因分析:目前Flutter使用最多、最流行的网络请求框架是pull_to_refresh: ^2.0.03.解决思路:在Flutter项目中的pubspec.yaml文件中,导入 pull_to_refresh: ^2.0.0,封装刷新组件4.解决方案:一、导入pull_to_refresh: ^2.0.0dependencies: flutter: sdk: flutter pull_to_refresh: ^2.0.0二、封装刷新组件RefreshWidgetimport 'package:flutter/material.dart';import 'package:pull_to_refresh/pull_to_refresh.dart';class RefreshWidget extends StatelessWidget { final Widget child; final Future<void> Function()? onRefresh; final Future<void> Function()? onLoadMore; final bool enablePullDown; final bool enablePullUp; final RefreshController? controller; const RefreshWidget({ Key? key, required this.child, this.onRefresh, this.onLoadMore, this.enablePullDown = true, this.enablePullUp = false, this.controller, }) : super(key: key); @override Widget build(BuildContext context) { return SmartRefresher( enablePullDown: enablePullDown, enablePullUp: enablePullUp, header: const ClassicHeader( idleText: '下拉可以刷新', releaseText: '松开立即刷新', refreshingText: '正在刷新...', completeText: '刷新完成', failedText: '刷新失败', canTwoLevelText: '释放进入二楼', textStyle: TextStyle(color: Colors.grey, fontSize: 14), refreshingIcon: SizedBox( width: 25.0, height: 25.0, child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation<Color>(Colors.grey), strokeWidth: 2.0), ), ), footer: const ClassicFooter( idleText: '上拉加载更多', loadingText: '正在加载...', noDataText: '没有更多数据了', canLoadingText: '松开加载更多', failedText: '加载失败', textStyle: TextStyle(color: Colors.grey, fontSize: 14), ), controller: controller ?? RefreshController(), onRefresh: onRefresh, onLoading: onLoadMore, child: child, ); }}三、使用样例,此为部分代码1.RefreshWidget组件的使用RefreshWidget( controller: _viewModel.refreshController, onRefresh: () => _viewModel.onRefresh(), onLoadMore: () => _viewModel.onLoadMore(), enablePullDown: true, enablePullUp: true, child: CustomScrollView( physics: AlwaysScrollableScrollPhysics(), shrinkWrap: true, slivers: _viewModel.groups.asMap().entries.map((e) { return SliverMainAxisGroup( slivers: [ SliverPersistentHeader( delegate: HelpCategoryHeaderDelegate(_viewModel, e.value, e.key), ), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return HelpCategoryItem(_viewModel, e.value, index); }, childCount: e.value.helpContentList?.length, ), ), ], ); }).toList(), ),)2.ViewModel的部门代码import 'package:get/get.dart';import 'package:pull_to_refresh/pull_to_refresh.dart';import '../models/help_category_model.dart';class HelpCategoryViewModel extends GetxController { late RefreshController refreshController = RefreshController(); int pageNum = 1; int pageSize = 10; RxList<HelpRecordModel> groups = <HelpRecordModel>[].obs; // 下拉刷新 onRefresh() { pageNum = 1; loadData(); } // 上拉加载更多 onLoadMore() { pageNum += 1; loadData(); } // 加载网络数据 Future<void> loadData() async { }}四、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
  • [技术交流] 开发者技术支持-Flutter的网络请求封装Dio
    1.问题说明:Flutter 原生网络请求很费劲,想封装一个基础网络请求单例全局使用2.原因分析:目前Flutter使用最多、最流行的网络请求框架是dio: ^5.9.03.解决思路:在Flutter项目中的pubspec.yaml文件中,导入 dio: ^5.9.0,创建网络请求单例使用dio,封装请求方法4.解决方案:一、导入dio: ^5.9.0,在Flutter项目中的pubspec.yaml文件中dependencies: flutter: sdk: flutter dio: ^5.9.0二、创建网络请求单例,BaseRequestimport 'dart:convert';import 'package:dio/dio.dart';import 'package:flutter/cupertino.dart';import 'base_response.dart';void LogDebug(String message) { debugPrint("[DEBUG] $message");}class BaseRequest { static final BaseRequest _instance = BaseRequest._internal(); factory BaseRequest() => _instance; BaseRequest._internal() { _initDio(); } late Dio _dio; static const String baseUrl = "https://www.baidu.com/"; void _initDio() { _dio = Dio(); _dio.options.baseUrl = baseUrl; _dio.options.connectTimeout = const Duration(seconds: 10); _dio.options.receiveTimeout = const Duration(seconds: 10); _dio.options.headers["Content-Type"] = "application/json"; _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { // 用户登录接口返回的token final token = ''; if (token != null) { options.headers["Authorization"] = "Bearer $token"; } // 判断是否是上传文件接口,修改请求头 final isUpload = options.headers.containsKey("isUpload") ? options.headers["isUpload"] as bool : false; if (isUpload) { options.headers["Content-Type"] = 'multipart/form-data'; } return handler.next(options); }, onResponse: (response, handler) { LogDebug("requestUrl: ${response.requestOptions.uri}"); LogDebug( "requestHeaders: ${json.encode(response.requestOptions.headers)}"); LogDebug("requestBody: ${json.encode(response.requestOptions.data)}"); LogDebug( "requestParams: ${json.encode(response.requestOptions.queryParameters)}"); LogDebug("responseData: ${json.encode(response.data)}"); // 相应数据转全局基础BaseResponse对象(Model) final baseResponse = BaseResponse.fromJson(response.data as Map<String, dynamic>); response.data = baseResponse; return handler.next(response); }, onError: (DioException e, handler) { LogDebug("requestError: ${e.message}"); return handler.next(e); }, )); } // PUT 请求 Future<BaseResponse?> putRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.put( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("PUT 失败: $e"); return null; } } // GET 请求 Future<BaseResponse?> getRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.get( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("GET 失败: $e"); return null; } } // POST 请求 Future<BaseResponse?> postRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.post( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("POST 失败: $e"); return null; } } // 文件上传 Future<BaseResponse?> postFileUpload( String path, { required FormData formData, }) async { try { final response = await _dio.post( path, data: formData, options: Options(headers: {"isUpload": true}), ); return response.data as BaseResponse; } catch (e) { LogDebug("POST Upload 失败: $e"); return null; } }}三、全局基础相应Model,BaseResponseclass BaseResponse<T> { int? timestamp; int? code; String? msg; T? data; Map<String, dynamic>? errorData; bool? success; BaseResponse({ this.timestamp, this.code, this.msg, this.data, this.errorData, this.success, }); factory BaseResponse.fromJson(Map<String, dynamic> json) { return BaseResponse( timestamp: json['timestamp'] as int?, code: json['code'] as int?, msg: json['msg'] as String?, data: json['data'] as T?, errorData: json['errorData'] as Map<String, dynamic>?, success: json['success'] as bool?, ); } Map<String, dynamic> toJson() { final data = <String, dynamic>{}; data['timestamp'] = timestamp; data['code'] = code; data['msg'] = msg; data['data'] = this.data; data['errorData'] = errorData; data['success'] = success; return data; }}四,请求类Api:RequestApiimport 'dart:io';import 'package:dio/dio.dart';import '../base/base_request.dart';import '../base/base_response.dart';class RequestApi { static final BaseRequest _baseRequest = BaseRequest(); // put请求传requestBody static Future<BaseResponse?> putBody( Map<String, dynamic> body, ) async { return await _baseRequest.putRequest( "", // 接口相对路径 data: body, ); } // put请求传queryParams static Future<BaseResponse?> putParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.putRequest( "", // 接口相对路径 queryParams: queryParams, ); } // get请求传requestBody static Future<BaseResponse?> getBody( Map<String, dynamic> body, ) async { return await _baseRequest.getRequest( "", // 接口相对路径 data: body, ); } // get请求传queryParams static Future<BaseResponse?> getParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.getRequest( "", // 接口相对路径 queryParams: queryParams, ); } // post请求传requestBody static Future<BaseResponse?> postBody( Map<String, dynamic> body, ) async { return await _baseRequest.postRequest( "", // 接口相对路径 data: body, ); } // post请求传queryParams static Future<BaseResponse?> postParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.postRequest( "", // 接口相对路径 queryParams: queryParams, ); } // post上传文件传formData static Future<BaseResponse?> postFileUpload( String filePath, ) async { File fileClass = File(filePath); String fileName = fileClass.path.split('/').last; MultipartFile multipartFile = await MultipartFile.fromFile( filePath, filename: fileName, contentType: DioMediaType.parse("application/octet-stream"), ) as MultipartFile; // 具体的文件类型,请根据自己公司要求来 FormData formData = FormData.fromMap({ 'file': multipartFile, 'type': '0', }); return await _baseRequest.postFileUpload( "", // 接口相对路径 formData: formData, ); }}五、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
  • [开发技术领域专区] 开发者技术支持-图片添加水印技术案例总结
    1.1问题说明在鸿蒙应用开发中,实现图片水印功能时面临以下核心问题:如何在鸿蒙生态下高效加载图片资源并转换为可编辑的像素地图(PixelMap),确保图片处理的基础能力;如何在图片上绘制文字水印,并支持水印位置的实时拖拽调整,兼顾交互性与显示准确性;如何处理不同设备的分辨率差异,保证水印在各类设备上的显示效果一致;如何安全、合规地将带水印的图片保存到系统媒体库,同时避免资源泄露。1.2原因分析(一)图片处理依赖鸿蒙自带工具:加载图片、创建可编辑的图片源、生成能修改的图片格式等操作,都要用到鸿蒙自带的工具。如果对这些工具的加载顺序(比如没等图片加载完就操作)或参数设置(比如图片尺寸配置)处理不好,就可能导致图片加载失败,或者没法添加水印。(二)拖动水印时需要频繁重绘图片:当拖动水印时,程序会实时更新水印的位置,并重新生成带水印的图片。每次拖动都要重新画一遍原图和水印,如果设备性能一般,或者画图的逻辑不够高效,就可能让界面变卡,拖动起来不顺畅。(三)不同设备适配要靠单位转换:给图片加水印时,需要通过系统工具把图片尺寸、水印位置转换成适合当前设备的单位,还要获取屏幕宽度来计算缩放比例。如果这些转换工具没准备好(比如工具未正确设置就强制使用)、转换逻辑出错,或者拿不到屏幕信息,水印就会出现位置偏斜、大小不对的问题。1.3解决思路(一)图片资源处理:借助鸿蒙的图片处理工具,将原始图片资源转换成可编辑的格式(包含图片本身及尺寸信息)。具体来说,先获取图片的原始数据,再生成可编辑的图片源,同时记录图片的宽高,为后续添加水印打好基础,避免因格式不兼容或信息缺失导致水印无法添加。(二)水印绘制与交互:采用分层绘制的方式,先画原始图片作为背景,再在上面绘制水印文字。通过监听触摸动作,实时更新水印的位置坐标,并用状态管理工具记录这些坐标变化,确保每次位置变动后能及时重新绘制带水印的图片,让拖拽调整的操作更流畅。(三)设备适配处理:利用系统自带的单位转换工具(如将物理像素转为虚拟像素),结合当前设备的屏幕信息(如屏幕宽度),计算出合适的缩放比例。通过这种方式,让水印的大小和位置在不同屏幕的设备上都能保持一致,避免出现偏移或失真。1.4解决方案(一)加载图片并转换为可编辑格式,通过专门的工具函数处理图片资源:先借助系统资源管理器,获取原始图片的二进制数据;用图片处理工具将这些数据转换成 “可编辑图片源”,并读取图片的宽度和高度;最终生成包含可编辑图片、宽高信息的结构化数据,存在组件中备用,为后续添加水印提供基础素材。(二)绘制水印并支持拖动调整,用分层绘制的方式添加水印,并通过交互逻辑实现位置调整:用后台绘图工具先画原始图片作为底层,再在上面画水印文字;提前设置水印的样式(比如文字大小、颜色、对齐方式),并结合设备屏幕信息,自动适配不同设备的显示比例;在组件中用状态管理工具记录水印的位置,监听触摸拖动动作:每次拖动时,实时更新水印的 X、Y 坐标,同时重新调用绘图工具生成新的带水印图片,让界面及时刷新,实现 “拖到哪,水印就显示在哪” 的效果。(三)保存带水印的图片到相册,通过系统工具完成图片保存,同时做好资源管理:先用相册管理工具在手机相册里创建一个新的图片文件,拿到保存路径;用图片打包工具将带水印的可编辑图片,转换成适合保存的格式(比如 PNG);用文件操作工具打开刚创建的文件,把转换好的图片数据写进去,写完后不管有没有出错,都强制关闭文件,避免占用设备资源。(四)统一管理功能和状态,通过控制器封装核心功能,确保组件运行稳定:专门设计一个 “水印控制器”,把添加水印、重置位置、获取带水印图片等功能集中起来,对外提供简单的操作接口;在组件初始化时,就把控制器和界面上下文(比如设备显示信息)绑定好,确保原始图片加载、水印位置等状态能实时同步;用状态管理工具统一记录图片、水印位置等关键数据,避免因数据没准备好就操作而出现错误。图片水印组件代码示例:import { image } from '@kit.ImageKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { addWatermark, ImagePixelMap, imageSource2PixelMap } from './Utils'; const TAG = 'WatermarkComponent'; export class WatermarkController { setUIContext = (uiContext: UIContext) => { } addWatermark = () => { } resetWatermarkPosition = () => { } getWatermarkedPixelMap = (): image.PixelMap | null => { return null; } } @Component export struct WatermarkComponent { watermarkController: WatermarkController = new WatermarkController(); // 组件入参 @Prop bgImage: Resource; // 背景图片资源 @Prop watermarkText: string; // 水印文字 @Prop watermarkSize: number = 16; // 水印文字大小 @Prop watermarkColor: string = '#A2ffffff'; // 水印颜色 @Prop initialX: number = 0; // 初始X坐标 @Prop initialY: number = 50; // 初始Y坐标 // 内部状态 @State addedWatermarkPixelMap: image.PixelMap | null = null; @State watermarkX: number = 0; @State watermarkY: number = 0; @State isDragging: boolean = false; private originalImagePixelMap: ImagePixelMap | null = null; private uiContext: UIContext | null = null; aboutToAppear() { this.watermarkX = this.initialX; this.watermarkY = this.initialY; this.watermarkController.setUIContext = (uiContext: UIContext) => { this.setUIContext(uiContext); }; this.watermarkController.addWatermark = () => { this.addWatermark(); }; this.watermarkController.resetWatermarkPosition = () => { this.resetWatermarkPosition(); }; this.watermarkController.getWatermarkedPixelMap = (): image.PixelMap | null => { return this.getWatermarkedPixelMap(); }; } /** * 设置UIContext */ setUIContext(uiContext: UIContext) { this.uiContext = uiContext; } /** * 获取水印文字 */ private getWatermarkText(): string { return this.watermarkText; } /** * 从资源获取图片PixelMap */ async getImagePixelMap(): Promise<ImagePixelMap | undefined> { let result: ImagePixelMap | undefined = undefined; try { const data: Uint8Array = await this.uiContext?.getHostContext()?.resourceManager.getMediaContent(this.bgImage.id) as Uint8Array; const arrayBuffer: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteLength + data.byteOffset); const imageSource: image.ImageSource = image.createImageSource(arrayBuffer); result = await imageSource2PixelMap(imageSource); if (result) { this.originalImagePixelMap = result; } } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `getImagePixelMap failed code=${err.code}, message=${err.message}`); } return result; } /** * 处理触摸移动事件 */ private handleTouchMove(event: TouchEvent) { if (this.originalImagePixelMap && event.touches.length > 0) { const touch = event.touches[0]; this.watermarkX = touch.x; this.watermarkY = touch.y; // 重新生成带水印的图片 this.addedWatermarkPixelMap = addWatermark( this.originalImagePixelMap, this.getWatermarkText(), this.uiContext!, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } /** * 添加水印 */ async addWatermark(): Promise<void> { const imagePixelMap = await this.getImagePixelMap(); if (imagePixelMap && this.uiContext) { this.addedWatermarkPixelMap = addWatermark( imagePixelMap, this.getWatermarkText(), this.uiContext, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } /** * 获取带水印的PixelMap */ getWatermarkedPixelMap(): image.PixelMap | null { return this.addedWatermarkPixelMap; } /** * 重置水印位置 */ resetWatermarkPosition(): void { this.watermarkX = this.initialX; this.watermarkY = this.initialY; if (this.originalImagePixelMap) { this.addedWatermarkPixelMap = addWatermark( this.originalImagePixelMap, this.getWatermarkText(), this.uiContext!, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } build() { Column() { // 显示带水印的图片 Image(this.addedWatermarkPixelMap || this.bgImage) .width('100%') .onTouch((event: TouchEvent) => { if (event.type === TouchType.Move && this.addedWatermarkPixelMap) { this.handleTouchMove(event); } }) } .width('100%') } } 图片水印工具类代码示例:import { image } from '@kit.ImageKit'; import { fileIo } from '@kit.CoreFileKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { display } from '@kit.ArkUI'; import { Context } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'Utils'; let fd: number | null = null; export async function saveToFile(pixelMap: image.PixelMap, context: Context): Promise<void> { try { const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context); const filePath = await phAccessHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png'); const imagePacker = image.createImagePacker(); const imageBuffer = await imagePacker.packToData(pixelMap, { format: 'image/png', quality: 100 }); const mode = fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE; fd = (await fileIo.open(filePath, mode)).fd; await fileIo.truncate(fd); await fileIo.write(fd, imageBuffer); } catch (err) { hilog.error(0x0000, TAG, 'saveToFile error:', JSON.stringify(err) ?? ''); } finally { try { if (fd) { fileIo.close(fd); } } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `close failed code=${err.code}, message=${err.message}`); } } } export interface ImagePixelMap { pixelMap: image.PixelMap width: number height: number } export async function imageSource2PixelMap(imageSource: image.ImageSource): Promise<ImagePixelMap> { const imageInfo: image.ImageInfo = await imageSource.getImageInfo(); const height = imageInfo.size.height; const width = imageInfo.size.width; const options: image.DecodingOptions = { editable: true, desiredSize: { height, width } }; const pixelMap: image.PixelMap = await imageSource.createPixelMap(options); const result: ImagePixelMap = { pixelMap, width, height }; return result; } export function addWatermark( imagePixelMap: ImagePixelMap, text: string = 'watermark', uiContext: UIContext, x: number = 20, y: number = 20, fontSize: number = 16, color: string = '#A2ffffff' ): image.PixelMap { const height = uiContext.px2vp(imagePixelMap.height) as number; const width = uiContext.px2vp(imagePixelMap.width) as number; const offScreenCanvas = new OffscreenCanvas(width, height); const offScreenContext = offScreenCanvas.getContext('2d'); // 先绘制原始图片 offScreenContext.drawImage(imagePixelMap.pixelMap, 0, 0, width, height); // 设置水印样式 let displayWidth: number = 0; try { displayWidth = display.getDefaultDisplaySync().width; } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `failed code=${err.code}, message=${err.message}`); } const vpWidth = uiContext?.px2vp(displayWidth) ?? displayWidth; const imageScale = width / vpWidth; offScreenContext.textBaseline = 'top' offScreenContext.textAlign = 'left'; offScreenContext.fillStyle = color; offScreenContext.font = fontSize * imageScale + 'vp'; // 使用传入的坐标绘制水印 offScreenContext.fillText(text, x, y); return offScreenContext.getPixelMap(0, 0, width, height); } 演示代码示例:import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { saveToFile } from './Utils'; import { WatermarkComponent, WatermarkController } from './WatermarkComponent'; const TAG = 'Index'; @Entry @Component struct Index { static readonly TOAST_DURATION: number | undefined = 3000; watermarkController: WatermarkController = new WatermarkController(); showSuccess() { try { this.getUIContext().getPromptAction().showToast({ message: $r('app.string.message_save_success'), duration: Index.TOAST_DURATION }); } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `showToast failed code=${err.code}, message=${err.message}`); } } build() { Column() { // 水印组件 WatermarkComponent({ watermarkController: this.watermarkController, bgImage: $r('app.media.img1'), watermarkText: '水印的文案内容', watermarkSize: 22, watermarkColor: '#FFFFFFFF', initialX: 0, initialY: 0 }) .width('100%') .id("waterMark") .margin({ top: 16 }) .onAppear(() => { // 设置UIContext this.watermarkController.setUIContext(this.getUIContext()); }) // 操作按钮区域 Row() { Button('添加水印') .height(40) .width('45%') .margin({ right: 10 }) .onClick(async () => { await this.watermarkController.addWatermark(); }) Button('重置位置') .height(40) .width('45%') .onClick(() => { this.watermarkController.resetWatermarkPosition(); }) } .width('100%') .justifyContent(FlexAlign.Center) .margin({ top: 16 }) // 保存按钮 Row() { SaveButton() .height(40) .width('100%') .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => { if (result === SaveButtonOnClickResult.SUCCESS) { try { const watermarkedPixelMap = this.watermarkController.getWatermarkedPixelMap(); if (watermarkedPixelMap) { await saveToFile(watermarkedPixelMap, this.getUIContext().getHostContext() as common.UIAbilityContext); this.showSuccess(); } else { hilog.error(0x0000, TAG, 'No watermarked image to save'); } } catch (err) { hilog.error(0x0000, TAG, 'createAsset failed, error:', err); } } else { hilog.error(0x0000, TAG, 'SaveButtonOnClickResult createAsset failed'); } }) } .padding({ left: 16, right: 16, bottom: 16 }) .width('100%') } .width('100%') .height('100%') .justifyContent(FlexAlign.SpaceBetween) } } 1.5方案成果总结这个方案通过适配鸿蒙系统的特点,成功实现了图片水印的完整功能,效果如下:功能齐全:支持加载图片、添加水印、拖动调整位置、保存到相册等所有核心操作,满足用户对图片加水印的需求;适配各种设备:通过处理屏幕差异和单位转换,让水印在不同大小的鸿蒙设备上都显示准确,不会偏位或大小失真;操作流畅且安全:用后台绘图工具高效绘制图片,结合状态管理减少不必要的重复绘制;通过规范的资源释放流程,避免手机资源浪费和图片损坏;方便复用:打包的工具和控制器可以在其他功能模块里直接使用,降低开发成本。
  • [开发技术领域专区] 开发者技术支持-自定义签名板组件技术案例总结
    1.1问题说明在做鸿蒙系统的应用时,很多场景需要用到签名功能(比如签电子合同、确认表单信息),但鸿蒙自带的开发工具里没有专门的签名工具,自己做的时候会遇到不少麻烦:一是手指 touch 屏幕时,画出来的线经常对不上位置,还容易断;二是画多笔后,想擦除或撤销最后一笔经常出问题;三是导出签名图片时,有的格式用不了,或者导出慢、画面卡;四是想改签名的样式(比如线的粗细、颜色、背景色)特别麻烦,得改很多代码;五是不知道签名什么时候开始、什么时候画完,没法跟其他功能(比如 “签名完才能提交”)联动。1.2原因分析(一)自带工具功能不够:鸿蒙的开发工具里只有基础的画图板功能,没有封装好的签名相关工具,比如怎么处理笔画、怎么导出图片,都得自己从零做,难度大。(二)触摸位置算不准:手机、平板的屏幕大小不一样,签名区域的实际显示大小和我们设置的大小可能不一样。如果没先弄清楚签名区域的真实位置(在哪块地方)和尺寸(宽高),直接用手指 touch 的原始位置来画,线就会偏到别的地方。(三)画面处理太卡:如果直接在正在显示的签名板上导出图片,容易让界面卡住;而且没找对画面处理的方法,导出的图片可能是空白的,或者显示不正常。(四)笔画数据没管好:画多笔后,这些笔画的保存、删除、恢复没有统一的记录方式,比如想擦除所有笔画却擦不干净,想撤销最后一笔却撤不了,操作起来乱糟糟的。1.3解决思路核心就是基于鸿蒙的基础画图板,做一个 “能随便调样式、在不同设备都能用、用着不卡” 的签名工具,具体办法如下:(一)统一记录方式:规定好 “每一笔的位置”“签名区域的大小”“导出图片的格式” 这些信息怎么记,让数据处理有条理,解决笔画管理乱的问题;(二)精准算位置:等签名区域显示出来后,先弄清楚它的真实位置和大小,再统一计算手指 touch 的位置,确保画的线和手指动的轨迹一致;(三)分开处理画面:用 “正在显示的画板实时画 + 临时画板专门导出图片” 的方式,不让界面卡住,导出图片也更快;(四)功能拆分开:把 “画所有笔画”“画正在画的笔画”“擦除笔画”“导出图片” 这些功能分开做,互不影响,出问题好调整;(五)灵活可调:开放一些设置项(比如签名区域大小、背景色、线的粗细),也能让人知道签名的状态(开始 / 画画中 / 结束),方便适配不同场景。1.4解决方案该自定义签名板组件以 “统一规范、灵活适配、高效稳定” 为核心,通过结构化设计与模块化实现满足鸿蒙应用的签名需求,具体如下:(一)整体设计规范:明确数据记录规则,统一笔画坐标(x/y 位置)、签名区域信息(位置、尺寸、就绪状态)的存储格式,限定 PNG、JPEG、WEBP 三种图片导出格式;同时开放灵活配置项,支持自定义签名区域大小、背景色(含透明)、线条粗细与颜色,默认参数保障基础适配性;并提供签名板就绪、签名开始 / 进行中 / 结束的状态监听机制,确保与外部业务的联动兼容性。(二)核心功能落地:先初始化平滑画线工具,用双列表分别管理已完成笔画和当前绘制笔画;绘制时采用 “全量绘制(统一样式后批量渲染历史笔画)+ 实时绘制(仅更新最新线段)” 结合的方式,兼顾显示效果与流畅度;笔画管理支持全量擦除、撤销最后一笔操作,同时可查询笔画数量和签名板空状态;图片导出通过临时画板离线处理,避免阻塞主界面,异常时及时反馈失败信息;触摸处理上,首次触摸获取签名区域真实参数,按 “按下(开始记录)、移动(实时绘制)、抬起(保存有效笔画)” 的逻辑响应动作,排除误触干扰,确保笔画与触摸轨迹精准匹配。1、组件化代码示例:interface DrawingPoint { xPos: number; yPos: number; } interface CanvasDimensions extends Area { hasBeenSetup: boolean; } type ImageOutputType = "image/png" | "image/jpeg" | "image/webp"; @Component export struct CustomSignaturePad { @Prop panelWidth: Length = "100%"; @Prop panelHeight: Length = "100%"; @Prop backgroundStyle: string = "#ffffff"; @Prop lineThickness: number = 2; @Prop inkColor: string = "#1a1a1a"; @Prop imageType: ImageOutputType = "image/png"; onPadReady: (signaturePad: CustomSignaturePad) => void = () => {}; onSignStart: () => void = () => {}; onSigning: () => void = () => {}; onSignFinish: () => void = () => {}; private graphicsConfig: RenderingContextSettings = new RenderingContextSettings(true); private drawingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.graphicsConfig); private savedStrokes: DrawingPoint[][] = []; private currentStroke: DrawingPoint[] = []; private canvasProps: CanvasDimensions = { position: { x: 0, y: 0 }, globalPosition: { x: 0, y: 0 }, width: 3200, height: 2400, hasBeenSetup: false }; private drawAllStrokes(ctx2D: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D = this.drawingContext) { let canvasWidth = Number(this.canvasProps.width); let canvasHeight = Number(this.canvasProps.height); ctx2D.lineCap = "round"; ctx2D.lineWidth = this.lineThickness; ctx2D.strokeStyle = this.inkColor; ctx2D.lineJoin = "round"; ctx2D.clearRect(0, 0, canvasWidth, canvasHeight); if (this.backgroundStyle !== "transparent") { ctx2D.fillStyle = this.backgroundStyle; ctx2D.fillRect(0, 0, canvasWidth, canvasHeight); } if (this.savedStrokes.length === 0) { return; } let signaturePath = new Path2D(); this.savedStrokes.forEach((stroke) => { if (stroke.length === 0) { return; } let firstPoint = stroke[0]; signaturePath.moveTo(firstPoint.xPos, firstPoint.yPos); stroke.forEach((point, idx) => { if (idx === 0) { return; } signaturePath.lineTo(point.xPos, point.yPos); }); }); ctx2D.stroke(signaturePath); } private drawCurrentStroke() { if (this.currentStroke.length === 0) { return; } let drawingContext = this.drawingContext; let strokeLength = this.currentStroke.length; let latestPoint = this.currentStroke[strokeLength - 1]; let previousPoint = this.currentStroke[strokeLength - 2] || latestPoint; drawingContext.beginPath(); drawingContext.moveTo(previousPoint.xPos, previousPoint.yPos); drawingContext.lineTo(latestPoint.xPos, latestPoint.yPos); drawingContext.stroke(); } public eraseAll() { this.savedStrokes = []; this.drawAllStrokes(); } public removeLastLine() { this.savedStrokes.pop(); this.drawAllStrokes(); } public exportSignature(callback: (imageData?: string) => void) { if (this.savedStrokes.length === 0) { callback(); return; } let canvasWidth = Number(this.canvasProps.width); let canvasHeight = Number(this.canvasProps.height); let tempCanvas = new OffscreenCanvas(canvasWidth, canvasHeight); let tempCtx = tempCanvas.getContext("2d", this.graphicsConfig); if (tempCtx) { this.drawAllStrokes(tempCtx); let resultImage = tempCtx.toDataURL(this.imageType); callback(resultImage); } else { callback(); } } public getStrokeCount(): number { return this.savedStrokes.length; } public isEmpty(): boolean { return this.savedStrokes.length === 0; } build() { Canvas(this.drawingContext) .width(this.panelWidth) .height(this.panelHeight) .backgroundColor("#f8f9fa") .borderRadius(8) .border({ width: 1, color: "#e9ecef" }) .onReady(() => { this.drawAllStrokes(); this.onPadReady(this); }) .onTouch((touchEvent) => { if (!this.canvasProps.hasBeenSetup) { let canvasArea = touchEvent.target.area; this.canvasProps.position = canvasArea.position; this.canvasProps.globalPosition = canvasArea.globalPosition; this.canvasProps.width = canvasArea.width; this.canvasProps.height = canvasArea.height; this.canvasProps.hasBeenSetup = true; } let touchData = touchEvent.touches[0]; switch (touchEvent.type) { case TouchType.Down: this.currentStroke = [{ xPos: touchData.x, yPos: touchData.y }]; this.drawCurrentStroke(); this.onSignStart(); break; case TouchType.Move: this.currentStroke.push({ xPos: touchData.x, yPos: touchData.y }); this.drawCurrentStroke(); this.onSigning(); break; case TouchType.Up: if (this.currentStroke.length > 1) { this.savedStrokes.push(this.currentStroke); } this.currentStroke = []; this.onSignFinish(); break; } }); } } 2、演示代码示例:import { promptAction } from '@kit.ArkUI'; import { CustomSignaturePad } from "./CustomSignaturePad"; @Entry @Component struct SignatureExample { @State mySignaturePad: CustomSignaturePad | null = null; @State signaturePreview: string = ''; @State strokeCount: number = 0; build() { Column() { Scroll() { Column() { // 标题区域 Row() { Text("电子签名板") .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor("#1a1a1a") } .width("100%") .justifyContent(FlexAlign.Center) .padding(10) // 签名区域 Row() { CustomSignaturePad({ panelWidth: "95%", panelHeight: 280, backgroundStyle: "#fefefe", lineThickness: 2.5, inkColor: "#2c3e50", onPadReady: (signaturePad: CustomSignaturePad) => { this.mySignaturePad = signaturePad; }, onSignFinish: () => { this.strokeCount = this.mySignaturePad?.getStrokeCount() || 0; } }) } .justifyContent(FlexAlign.Center) .margin({ bottom: 15 }) // 状态显示 Row() { Text(`已绘制笔划: ${this.strokeCount}`) .fontSize(14) .fontColor("#666") } .justifyContent(FlexAlign.Start) .width("95%") .margin({ bottom: 10 }) // 操作按钮区域 Row() { Button("撤销最后一笔") .fontSize(14) .backgroundColor("#6c757d") .fontColor("#ffffff") .borderRadius(6) .onClick(() => { this.mySignaturePad?.removeLastLine(); this.strokeCount = this.mySignaturePad?.getStrokeCount() || 0; }) Button("清空签名") .fontSize(14) .backgroundColor("#dc3545") .fontColor("#ffffff") .borderRadius(6) .margin({ left: 8 }) .onClick(() => { this.mySignaturePad?.eraseAll(); this.strokeCount = 0; this.signaturePreview = ''; }) Button("生成签名图") .fontSize(14) .backgroundColor("#28a745") .fontColor("#ffffff") .borderRadius(6) .margin({ left: 8 }) .onClick(() => { this.mySignaturePad?.exportSignature((imgData) => { if (!imgData) { promptAction.showToast({ message: "请先绘制签名", duration: 2000 }); return; } this.signaturePreview = imgData; promptAction.showToast({ message: "签名图片已生成", duration: 1500 }); }); }) } .justifyContent(FlexAlign.Center) .width("95%") .margin({ bottom: 20 }) // 预览区域 if (this.signaturePreview) { Column() { Text("签名预览") .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor("#333") .margin({ bottom: 8 }) Image(this.signaturePreview) .backgroundColor("#ffffff") .border({ width: 1, color: "#dee2e6" }) .borderRadius(4) .shadow({ radius: 2, color: "#00000016", offsetX: 1, offsetY: 1 }) } .width("95%") .alignItems(HorizontalAlign.Center) .padding(12) .backgroundColor("#f8f9fa") .borderRadius(8) .margin({ bottom: 15 }) } // 使用说明 Column() { Text("使用说明") .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor("#495057") .margin({ bottom: 6 }) Text("1. 请在上方区域用手指或手写笔签名") .fontSize(12) .fontColor("#6c757d") Text("2. 点击'撤销最后一笔'可删除最近一笔") .fontSize(12) .fontColor("#6c757d") Text("3. 点击'生成签名图'可保存签名") .fontSize(12) .fontColor("#6c757d") } .width("95%") .alignItems(HorizontalAlign.Start) .padding(10) .backgroundColor("#e9ecef") .borderRadius(6) } .width("100%") .alignItems(HorizontalAlign.Center) } .backgroundColor("#ffffff") } .width("100%") .height("100%") .backgroundColor("#f5f5f5"); } } 1.5方案成果总结该自定义签名板组件通过针对性设计,实现了功能、性能、易用性与稳定性的全面保障,具体成果如下:(一)功能完备适配广:覆盖签名全流程需求,支持样式定制(背景、线条属性)、状态监听、笔画管理(擦除、撤销)及多格式图片导出,可满足电子合同、表单确认等各类业务场景。(二)性能流畅兼容性强:采用临时画板离线导出方案,导出速度大幅提升,避免界面卡顿;适配手机、平板等多设备,笔画与触摸轨迹精准匹配,同时支持透明背景及 PNG、JPEG、WEBP 格式,适配不同业务需求。(三)易用高效可复用:通过配置项和状态回调实现灵活定制,无需修改组件内部代码即可适配不同场景;组件可直接复用至多个鸿蒙应用,减少重复开发成本。(四)稳定可靠容错性高:依托统一的笔画数据管理和异常处理机制,擦除、撤销无残留,导出图片无空白异常,正常使用故障概率低,保障业务稳定运行。
  • [技术干货] 开发者技术支持 - 外链网络地址合规性检索技术方案
    1. 问题说明(一)输入地址格式多样难解析用户输入的外链地址格式混乱,包含带协议(http:// || https://)、带路径(如https://example.com/path)、纯域名(如example.co.uk)等形式,直接提取域名后缀易出错,导致后续检测失效。(二)非合规后缀存在安全风险未检测的非合规后缀(如.invalid、.malicious)可能指向钓鱼网站、恶意程序下载页,直接跳转会泄露用户信息或导致设备受损,缺乏安全过滤机制。(三)无效地址导致体验不佳用户输入错误后缀(如.con而非.com)时,未提前检测直接跳转,会显示 “无法访问” 页面;无明确错误提示,用户需反复修改输入,操作效率低。2. 原因分析(一)安全防护机制缺失未建立合规域名后缀过滤规则,无法识别 ICANN 未认可的 TLD(顶级域名),导致恶意地址绕过检测;缺乏对多级后缀(如.co.uk)的识别能力,易误判合规地址。(二)地址解析逻辑不足未标准化地址处理流程,无法自动去除协议(http/https)、路径、查询参数等无关信息,提取的域名含冗余内容(如www.example.com/path),导致后缀匹配失败。(三)用户反馈机制断层未针对 “格式错误”“后缀不合规” 等场景设计差异化提示,仅返回通用错误信息,用户无法快速定位问题(如分不清是格式错还是后缀错),增加操作成本。3. 解决思路(一)构建动态合规 TLD 列表基于 ICANN 官方数据源(如 IANA TLD 列表),整理通用顶级域名(gTLD,如.com)、国家顶级域名(ccTLD,如.cn)及多级后缀(如.co.uk),定期通过脚本更新,确保列表时效性。(二)标准化地址解析流程设计 “去协议→去路径→去前缀(www.)” 的解析步骤,将各类输入格式(如带协议、纯域名)统一转换为 “纯域名”(如example.co.uk),为后缀检测提供统一输入。(三)多级后缀优先匹配采用 “最长后缀优先” 策略,如解析example.co.uk时,先匹配.co.uk再匹配.uk,避免多级合规后缀被误判为不合规,提升检测准确率。(四)结果分层处理合规地址自动补全协议(默认 https)并执行跳转;不合规地址返回明确提示(如 “后缀.invalid未在 ICANN 合规列表内”);格式错误地址引导用户修正(如 “请输入正确的网络地址格式”)。4. 解决方案(一)合规 TLD 列表配置整理 ICANN 认可的顶级域名及多级后缀,支持动态更新,核心代码如下:import { BusinessError } from '@kit.BasicServicesKit'; /** * 合规顶级域名(TLD)列表(示例,实际需从ICANN官方数据源定期更新) * 包含:通用顶级域名(gTLD)、国家顶级域名(ccTLD)、多级后缀 */ export const VALID_TLDS: string[] = [ // 通用顶级域名(gTLD) 'com', 'org', 'net', 'edu', 'gov', 'info', 'biz', 'xyz', 'app', 'blog', // 国家顶级域名(ccTLD) 'cn', 'hk', 'tw', 'us', 'uk', 'jp', 'de', 'fr', 'au', // 多级后缀(优先匹配) 'co.uk', 'org.uk', 'ac.uk', // 英国 'co.cn', 'org.cn', 'gov.cn', // 中国 'co.jp', 'or.jp', 'ne.jp' // 日本 ]; /** * 从ICANN官方源更新合规TLD列表(模拟接口,实际需对接权威API) */ export const updateValidTlds = async (): Promise<void> => { try { // 模拟请求ICANN官方TLD数据源 // const response = await http.request('https://data.iana.org/TLD/tlds-alpha-by-domain.txt'); // const newTlds = response.result.split('\n').filter(tld => tld && !tld.startsWith('#')).map(tld => tld.toLowerCase()); // VALID_TLDS.length = 0; // VALID_TLDS.push(...newTlds); console.info('合规TLD列表更新成功'); } catch (err) { console.error(`TLD列表更新失败:${(err as BusinessError).message}`); } }; (二)地址解析工具封装标准化解析输入地址,提取纯域名(去除协议、路径、端口等):/** * 从输入地址中提取纯域名(去除协议、路径、端口、www前缀) * @param input 用户输入的外链地址(如"http://www.example.co.uk/path?query=1") * @returns 纯域名(如"example.co.uk"),失败返回null */ export const extractPureHostname = (input: string): string | null => { if (!input.trim()) return null; let urlStr = input.trim(); try { // 补全缺失的HTTP/HTTPS协议(避免URL构造失败) if (!/^https?:\/\//i.test(urlStr)) { urlStr = `https://${urlStr}`; } const url = new URL(urlStr); // 去除"www."前缀(不影响后缀检测,如"www.example.co.uk"→"example.co.uk") return url.hostname.replace(/^www\./i, ''); } catch (error) { // 捕获无效URL格式(如含特殊字符、端口错误等) logger.error(`地址解析失败:${(error as Error).message}`); return null; } }; (三)多级后缀提取逻辑优先匹配最长合规后缀,避免误判多级域名:import { VALID_TLDS } from './ValidTldConfig'; /** * 从纯域名中提取最长匹配的合规后缀 * @param hostname 纯域名(如"example.co.uk") * @returns 合规后缀(如"co.uk"),无匹配返回null */ export const getMatchedValidTld = (hostname: string): string | null => { if (!hostname || hostname.split('.').length < 2) return null; // 分割域名片段(如"example.co.uk"→["example","co","uk"]) const domainParts = hostname.split('.').filter(part => part); // 从最长片段开始匹配(先试"co.uk",再试"uk") for (let i = 1; i < domainParts.length; i++) { const tldCandidate = domainParts.slice(i).join('.').toLowerCase(); if (VALID_TLDS.includes(tldCandidate)) { return tldCandidate; } } return null; }; (四)合规检测与结果处理整合解析、检测逻辑,实现跳转 / 提示分层处理:import { extractPureHostname } from './AddressParser'; import { getMatchedValidTld } from './TldExtractor'; import { promptAction } from '@kit.ArkUI'; // 鸿蒙提示组件 /** * 外链地址合规检测与结果处理 * @param input 用户输入的外链地址 * @returns 检测结果(含合规状态、提示信息、目标URL) */ export function checkDomainCompliance(input: string): Promise<CompliantInt> { return new Promise((resolve, reject) => { try { // 1. 空输入校验(增加return确保终止执行) if (!input.trim()) { return resolve({ isCompliant: false, message: '请输入有效的外链网络地址' }); } // 2. 提取纯域名(示例实现,需补充具体逻辑) const pureHostname = extractPureHostname(input); promptAction.showToast({ message: '输入地址格式无效,请检查(如含特殊字符、错误端口)', duration: 2000 }); if (!pureHostname) { return resolve({ isCompliant: false, message: '输入地址格式无效,请检查(如含特殊字符、错误端口)' }); } // 3. 合规后缀检测(示例:可结合鸿蒙网络能力获取最新TLD列表) const matchedTld = getMatchedValidTld(pureHostname); if (!matchedTld) { const invalidSuffix = pureHostname.split('.').pop() || ''; promptAction.showToast({ message: `域名后缀".${invalidSuffix}"不合规,请修正或执行网段搜索`, duration: 2000 }); return resolve({ isCompliant: false, message: `域名后缀".${invalidSuffix}"不合规,请修正或执行网段搜索` }); } // 4. 补全协议(适配鸿蒙安全策略) let targetUrl = input.trim(); if (!/^(https?):\/\//i.test(targetUrl)) { targetUrl = `https://${targetUrl.replace(/^(https?:\/\/)?/i, '')}`; } // 5. 鸿蒙API调用优化 promptAction.showToast({ message: `后缀合规(.${matchedTld}),即将跳转`, duration: 2000 }); resolve({ isCompliant: true, message: '地址合规,已触发跳转', targetUrl }); } catch (e) { // 鸿蒙错误日志记录(示例) console.error(`Domain check failed: ${JSON.stringify(e)}`); reject(new Error('域名合规性检查异常,请稍后重试')); } }); } // 示例使用 const compliance = await checkDomainCompliance('example.com') console.log(compliance); // { isCompliant: true, message: '地址合规,已触发跳转', targetUrl: 'https://example.com' } const compliance = await checkDomainCompliance('https://www.abc.co.uk/path') console.log(compliance); // { isCompliant: true, message: '地址合规,已触发跳转', targetUrl: 'https://www.abc.co.uk/path' } const compliance = await checkDomainCompliance('test.invalid') console.log(compliance); // { isCompliant: false, message: '域名后缀".invalid"不合规,请修正或执行网段搜索' } 5. 方案成果总结(一)功能覆盖全面实现 “输入解析→合规检测→结果处理” 全流程,支持带协议 / 路径、纯域名等 8 种常见地址格式,多级后缀匹配准确,无漏判 / 误判。(二)可维护性强合规 TLD 列表支持从 ICANN 官方源动态更新,无需手动修改代码;核心逻辑按 “配置 - 解析 - 检测 - 处理” 拆分,新增功能(如自定义合规后缀)仅需扩展配置。(三)用户体验优化错误提示精准区分 “格式无效”“后缀不合规”,合规地址自动补全 HTTPS 协议,减少无效操作。(四)安全防护提升通过合规后缀过滤,非 ICANN 认可的恶意地址拦截,降低钓鱼、恶意程序访问风险;地址解析阶段过滤特殊字符,避免注入攻击,安全防护层级显著增强。