-
1.1问题说明在鸿蒙(HarmonyOS)应用开发中,需要实现多样化的 “碰一碰分享” 功能,满足不同场景下的分享需求,具体包括:支持图片、链接、文档等多种类型内容的分享;实现普通分享、拒绝分享、延迟更新分享等差异化交互逻辑;支持指定窗口单向发送分享内容,适配多窗口场景;确保分享功能不会因为程序启动、关闭不当,出现卡顿或出错的情况。1.2原因分析(一)鸿蒙系统原生碰一碰分享仅提供基础能力,缺乏对多样化场景的适配,无法满足复杂应用的需求;(二)分享功能涉及组件生命周期管理,若未妥善处理监听状态,易出现重复监听、内存泄漏等问题;(三)不同设备和系统版本对碰一碰分享的支持存在差异,需通过场景化提示降低用户操作门槛;(四)分享内容类型多样,需统一基于系统 ShareKit 接口封装,确保兼容性和一致性。1.3解决思路采用 “组件化封装 + 生命周期联动 + 场景化适配” 的设计思路,具体如下:组件化拆分:按功能场景拆分独立组件,每个组件负责特定分享能力,降低耦合度;状态管理:通过 @State 装饰器维护分享监听状态,确保 UI 显示与实际状态一致;生命周期联动:在组件aboutToAppear(初始化)时开启监听、注册事件;aboutToDisappear(销毁)时取消监听、移除事件,避免内存泄漏;场景化适配:基于 ShareKit 接口封装不同分享回调,支持图片、链接、文档等多种内容类型;用户指引:单独封装 Tips 组件,明确不同设备 / 系统版本的适配要求,通过文字 + 动图指引提升易用性;冲突处理:添加监听状态校验(isNoListening),避免同时开启多个分享监听,通过 Toast 提示用户操作冲突。1.4解决方案基于鸿蒙系统的开发工具,把碰一碰分享功能拆成 4 个独立的 “功能模块”,每个模块负责不同的场景,通过系统自带的 “分享工具”“文件管理工具” 实现分享,确保操作简单、运行稳定。基础组件代码示例:import { harmonyShare, systemShare } from '@kit.ShareKit'; import { fileUri } from '@kit.CoreFileKit'; import { Context, UIContext } from '@ohos.ui'; import Logger from '../../utils/Logger'; let logger = Logger.getLogger('[BaseKnockShare]'); abstract class BaseKnockShare { // 监听状态(子类实现具体状态变量) abstract get isListening(): boolean; // 初始化:开启监听+注册聚焦/后台事件 protected initListening(uiContext: UIContext): void { const context = uiContext.getHostContext() as Context; // 初始开启监听 if (!this.isListening) { this.startListening(); } // 页面聚焦时重启监听 context.eventHub.on('onFocus', () => { if (!this.isListening) { this.startListening(); } }); // 页面后台时关闭监听 context.eventHub.on('onBackGround', () => { this.stopAllListening(); }); } // 销毁:关闭监听+移除事件 protected destroyListening(uiContext: UIContext): void { const context = uiContext.getHostContext() as Context; this.stopAllListening(); context.eventHub.off('onFocus'); context.eventHub.off('onBackGround'); logger.info('Listening destroyed.'); } // 开启监听(子类实现具体逻辑) protected abstract startListening(): void; // 关闭所有监听(子类实现具体逻辑) protected abstract stopAllListening(): void; // 显示Toast提示(通用工具方法) protected showToast(uiContext: UIContext, message: string): void { try { uiContext.getPromptAction().showToast({ message }); } catch (error) { logger.error(`Toast error: ${error?.message}`); } } } 核心组件代码示例:@Component export default struct KnockShareApi extends BaseKnockShare { @Consume('pageStack') pageStack: NavPathStack; @State ShareStatus: boolean = false; // 普通分享状态 @State rejectStatus: boolean = false; // 拒绝分享状态 @State updateStatus: boolean = false; // 更新分享状态 // 监听状态校验(是否无任何监听) get isListening(): boolean { return this.ShareStatus || this.rejectStatus || this.updateStatus; } aboutToAppear(): void { logger.info('Component appeared.'); this.initListening(this.getUIContext()); } aboutToDisappear(): void { logger.info('Component disappeared.'); this.destroyListening(this.getUIContext()); } // 开启监听(根据状态选择对应回调) protected startListening(): void { if (!this.ShareStatus) { harmonyShare.on('knockShare', this.shareCallback); this.ShareStatus = true; } else if (!this.rejectStatus) { harmonyShare.on('knockShare', this.rejectCallback); this.rejectStatus = true; } else if (!this.updateStatus) { harmonyShare.on('knockShare', this.updateCallback); this.updateStatus = true; } else { this.showToast(this.getUIContext(), $r('app.string.knock_close_other')); } } // 关闭所有监听 protected stopAllListening(): void { if (this.ShareStatus) { harmonyShare.off('knockShare', this.shareCallback); this.ShareStatus = false; } if (this.rejectStatus) { harmonyShare.off('knockShare', this.rejectCallback); this.rejectStatus = false; } if (this.updateStatus) { harmonyShare.off('knockShare', this.updateCallback); this.updateStatus = false; } } // 普通分享回调(分享图片) private shareCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; const filePath = context.filesDir + '/exampleKnock3.jpg'; const shareData = new systemShare.SharedData({ utd: utd.UniformDataType.JPEG, uri: fileUri.getUriFromPath(filePath), thumbnailUri: fileUri.getUriFromPath(filePath), }); target.share(shareData); // 执行分享 }; // 拒绝分享回调(1秒后返回错误) private rejectCallback = (target: harmonyShare.SharableTarget) => { setTimeout(() => { target.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR); }, 1000); }; // 更新分享回调(先分享链接,3秒后更新缩略图) private updateCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; // 初始分享链接 let shareData = new systemShare.SharedData({ utd: utd.UniformDataType.HYPERLINK, content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p', title: context.resourceManager.getStringSync($r('app.string.white_title').id), }); target.share(shareData); // 3秒后更新缩略图 setTimeout(() => { const imgPath = context.filesDir + '/exampleKnock2.png'; target.updateShareData({ thumbnailUri: fileUri.getUriFromPath(imgPath) }); }, 3000); }; // UI构建(分享模式选择界面) build() { NavDestination() { Scroll() { Column() { // 普通分享模式(@Builder封装,略) this.ShareMode() // 拒绝分享模式(@Builder封装,略) this.RejectMode() // 更新分享模式(@Builder封装,略) this.UpdateMode() }.width('100%').padding(20) } }.title($r("app.string.navigation_toolbar_function")) } // 分享模式UI(@Builder实现,略) @Builder ShareMode() { /* ... */ } @Builder RejectMode() { /* ... */ } @Builder UpdateMode() { /* ... */ } } 扩展组件代码示例:@Component export default struct KnockShareAttr extends BaseKnockShare { @Consume('pageStack') pageStack: NavPathStack; @Prop windowId: number | undefined; // 指定窗口ID @State sendOnlyStatus: boolean = false; // 单向发送状态 get isListening(): boolean { return this.sendOnlyStatus; } aboutToAppear(): void { this.initListening(this.getUIContext()); } aboutToDisappear(): void { this.destroyListening(this.getUIContext()); } // 开启单向发送监听(绑定指定窗口) protected startListening(): void { if (!this.sendOnlyStatus && this.windowId) { const registry: harmonyShare.SendCapabilityRegistry = { windowId: this.windowId, sendOnly: true // 单向发送(仅发送,不接收) }; harmonyShare.on('knockShare', registry, this.sendCallback); this.sendOnlyStatus = true; } else { this.showToast(this.getUIContext(), $r('app.string.knock_close_other')); } } // 关闭监听 protected stopAllListening(): void { if (this.sendOnlyStatus && this.windowId) { const registry: harmonyShare.SendCapabilityRegistry = { windowId: this.windowId, sendOnly: true }; harmonyShare.off('knockShare', registry, this.sendCallback); this.sendOnlyStatus = false; } } // 单向发送回调(分享链接+缩略图) private sendCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; const filePath = context.filesDir + '/exampleKnock2.png'; const shareData = new systemShare.SharedData({ utd: utd.UniformDataType.HYPERLINK, content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p', thumbnailUri: fileUri.getUriFromPath(filePath), title: context.resourceManager.getStringSync($r('app.string.white_title').id), }); target.share(shareData); }; // UI构建(略,同KnockShareApi结构) build() { /* ... */ } } 指引组件代码示例:@Component export default struct KnockShareTips extends BaseKnockShare { @Consume('pageStack') pageStack: NavPathStack; @Prop windowId: number | undefined; @State tipsStatus: boolean = false; // 动图指引资源配置 private readonly cardResources = [ { text: '手机需HarmonyOS 5+', prefix: 'knock_share_guide/phone_', frameCount: 128 }, { text: 'PC需HarmonyOS 6+', prefix: 'knock_share_guide/pc_', frameCount: 104 } ]; get isListening(): boolean { return this.tipsStatus; } aboutToAppear(): void { this.initListening(this.getUIContext()); } aboutToDisappear(): void { this.destroyListening(this.getUIContext()); } protected startListening(): void { if (!this.tipsStatus) { harmonyShare.on('knockShare', this.tipsCallback); this.tipsStatus = true; } } protected stopAllListening(): void { if (this.tipsStatus) { harmonyShare.off('knockShare', this.tipsCallback); this.tipsStatus = false; } } // 指引场景分享回调(分享文档) private tipsCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; const filePath = context.filesDir + '/exampleDocx.docx'; // 自动识别文件类型 const utdType = utd.getUniformDataTypeByFilenameExtension('.docx', utd.UniformDataType.FILE); const shareData = new systemShare.SharedData({ utd: utdType, uri: fileUri.getUriFromPath(filePath), title: '文档分享示例', }); target.share(shareData); }; // UI构建(文字说明+动图指引) build() { NavDestination() { Scroll() { Column() { Text($r('app.string.tap_to_share_tips')).fontSize(18).margin(20) // 动图指引组件(KnockShareGuideCard) this.cardResources.forEach(res => { Column() { Text(res.text).fontSize(16).margin(10) KnockShareGuideCard({ cardSwiperResources: [{ text: res.text, rawfilePrefix: res.prefix, framesCount: res.frameCount }] }) }.margin(10) }) }.width('100%') } }.title('碰一碰分享指引') } } 1.5方案成果总结碰一碰分享方案,既解决了鸿蒙系统自带分享功能的不足,又通过简单的操作、清晰的指引,让不同需求(比如分享图片、拒绝分享)、不同设备(手机、电脑)的用户都能轻松使用,同时保证运行稳定,不会给手机、电脑带来额外负担。(一)能满足多种分享需求:可以分享图片、链接、文档,支持正常分享、拒绝分享、指定窗口分享等 6 种常用场景;(二)适配不同设备:手机(鸿蒙 5.0 以上)、电脑(鸿蒙 6.0 以上)都能用,有清晰的说明告诉用户哪些设备能使用;(三)运行稳定:不会出现卡顿、出错的情况,因为分享功能会跟着程序启动、关闭自动调整;(四)操作简单:所有分享功能的按钮样式、操作方式都一样,点击开启,再点击关闭。
-
1.1问题说明在鸿蒙应用开发中,相册与视频访问功能常面临两大核心问题。一是权限申请相关困扰,传统相册访问方式需向用户申请存储权限,不仅增加用户操作步骤,若用户拒绝权限申请,会直接导致相册访问功能失效,影响应用核心流程;二是多主题适配与组件联动问题,应用需适配系统、浅色、深色等多种主题以满足不同用户视觉需求,同时相册选择组件需与图片视频展示组件实现精准联动,确保用户选择相册后,对应内容能及时刷新,而传统开发模式中易出现主题切换异常、组件通信卡顿或失效等问题。此外,模态框的显示控制、标签页切换时组件状态同步等细节问题,也会影响整体功能的稳定性与用户体验。1.2原因分析(一)权限问题根源:鸿蒙系统对存储目录实行严格的权限划分,私有目录需申请权限才能访问,但公共目录中存储的相册与视频数据本身面向所有应用开放。此前多数开发方案未充分利用系统对公共目录的权限豁免机制,仍沿用访问私有目录的权限申请流程,导致冗余的权限操作。而本次使用的 @kit.MediaLibraryKit 中的 AlbumPickerComponent 组件,本身已适配系统公共目录访问规则,无需额外申请权限即可调用。(二)主题与联动问题成因:多主题适配问题源于不同主题下组件的配色、样式参数需独立配置,若缺乏统一的初始化与管理逻辑,易出现主题切换时样式错乱。组件联动问题则是因相册选择与内容展示分属不同组件,若未设计规范的回调通信机制,两者间的数据传递易出现断层。同时,标签页切换时若未精准控制组件的可见性与状态索引,会导致组件重复渲染或显示异常,这也是代码中设置 currentIndex 状态与 Visibility 控制的核心原因。(三)交互体验问题诱因:模态框的显示隐藏依赖外部状态联动,若未通过 Link 装饰器实现双向绑定,易出现状态同步延迟;相册选择后的回调逻辑若未封装统一方法,可能导致不同主题下的选择操作出现差异化异常,影响功能一致性。1.3解决思路针对上述问题,结合鸿蒙系统特性与组件能力,制定以下解决思路:(一)借助 MediaLibraryKit 提供的 AlbumPickerComponent 组件,利用其无需权限访问公共目录相册的特性,规避权限申请流程;(二)设计多套主题配置参数,通过标签页切换机制实现不同主题的快速切换,并通过状态变量控制组件可见性,避免渲染冲突;(三)封装统一的事件回调函数,实现相册选择事件的集中处理,同时预留外部回调接口,确保与 PhotoPickerComponent 组件的联动;(四)通过模态框容器封装整体功能,利用双向绑定状态控制显示隐藏,搭配半透明背景与点击关闭逻辑,提升交互体验。1.4解决方案通过使用AlbumPickerComponent和PhotoPickerComponent,应用无需申请权限,即可访问公共目录中的相册列表。需配合PhotoPickerComponent一起使用,用户通过AlbumPickerComponent组件选择对应相册并通知PhotoPickerComponent组件刷新成对应相册的图片和视频。组件代码示例:import { AlbumPickerComponent, AlbumPickerOptions, AlbumInfo, PickerColorMode } from '@kit.MediaLibraryKit'; @Component export struct AlbumPickerModal { @Link isVisible: boolean; private onAlbumSelected?: (albumInfo: AlbumInfo) => void; private onClose?: () => void; @State currentIndex: number = 0; private controller: TabsController = new TabsController(); // 主题配置 private albumOptionsAuto = new AlbumPickerOptions(); private albumOptionsLight = new AlbumPickerOptions(); private albumOptionsDark = new AlbumPickerOptions(); // 颜色配置 @State fontColor: string = '#182431'; @State selectedFontColor: string = '#ff4d6f92'; aboutToAppear() { // 初始化主题配置 this.albumOptionsAuto.themeColorMode = PickerColorMode.AUTO; this.albumOptionsLight.themeColorMode = PickerColorMode.LIGHT; this.albumOptionsDark.themeColorMode = PickerColorMode.DARK; } // 相册点击处理 private onAlbumClick(albumInfo: AlbumInfo): boolean { if (this.onAlbumSelected) { this.onAlbumSelected(albumInfo); } return true; } // 关闭模态框 private closeModal(): void { this.isVisible = false; if (this.onClose) { this.onClose(); } } // Tab构建器 @Builder tabBuilder(index: number, name: string) { Column() { Text(name) .fontColor(this.currentIndex === index ? this.selectedFontColor : this.fontColor) .fontSize(16) .fontWeight(this.currentIndex === index ? 500 : 400) .lineHeight(22) .margin({ top: 17, bottom: 7 }) Divider() .strokeWidth(2) .color('#007DFF') .opacity(this.currentIndex === index ? 1 : 0) } .width('100%') } build() { if (this.isVisible) Stack() { // 半透明背景 Column() .width('100%') .height('100%') .backgroundColor('#000000') .opacity(0.5) .onClick(() => this.closeModal()) // 相册选择器内容 Column() { // 顶部标题栏 Row() { Text('选择相册') .fontSize(18) .fontWeight(500) .fontColor('#182431') Blank() Image($r('app.media.ic_close')) .width(24) .height(24) .onClick(() => this.closeModal()) } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') // 相册列表区域 Column() { Tabs({ barPosition: BarPosition.Start, index: this.currentIndex, controller: this.controller }) { TabContent() { AlbumPickerComponent({ albumPickerOptions: this.albumOptionsAuto, onAlbumClick: (albumInfo: AlbumInfo): boolean => { return this.onAlbumClick(albumInfo); }, }) .height('100%') .width('100%') .visibility(this.currentIndex == 0 ? Visibility.Visible : Visibility.None) } .tabBar(this.tabBuilder(0, '系统主题')) TabContent() { AlbumPickerComponent({ albumPickerOptions: this.albumOptionsLight, onAlbumClick: (albumInfo: AlbumInfo): boolean => { return this.onAlbumClick(albumInfo); } }) .height('100%') .width('100%') .visibility(this.currentIndex == 1 ? Visibility.Visible : Visibility.None) } .tabBar(this.tabBuilder(1, '浅色主题')) TabContent() { AlbumPickerComponent({ albumPickerOptions: this.albumOptionsDark, onAlbumClick: (albumInfo: AlbumInfo): boolean => { return this.onAlbumClick(albumInfo); } }) .height('100%') .width('100%') .visibility(this.currentIndex == 2 ? Visibility.Visible : Visibility.None) } .tabBar(this.tabBuilder(2, '深色主题')) } .vertical(false) .barWidth('100%') .barHeight(56) .animationDuration(100) .scrollable(false) .onChange((index: number) => { this.currentIndex = index; }) .width('100%') .height('100%') } .width('100%') .height('80%') .backgroundColor('#FFFFFF') } .width('100%') .height('80%') .position({ x: 0, y: '20%' }) } .width('100%') .height('100%') } } 演示代码示例:import { PhotoPickerComponent, PickerController, AlbumInfo, DataType } from '@kit.MediaLibraryKit'; import { AlbumPickerModal } from './AlbumPickerModal'; @Entry @Component struct Index { @State pickerController: PickerController = new PickerController(); @State isShowAlbum: boolean = false; @State currentAlbumName: string = '全部相册'; // 相册被选中回调 private onAlbumSelected(albumInfo: AlbumInfo): void { this.isShowAlbum = false; if (albumInfo?.uri) { // 根据相册url更新宫格页内容 this.pickerController.setData(DataType.SET_ALBUM_URI, albumInfo.uri); // 更新当前相册名称显示 this.currentAlbumName = albumInfo.albumName || '未知相册'; } } // 打开相册选择器 private openAlbumPicker(): void { this.isShowAlbum = true; } build() { Stack() { Column() { // 顶部操作栏 Row() { Button(this.currentAlbumName) .width('95%') .height(40) .fontSize(16) .backgroundColor('#ff4d6f92') .fontColor('#FFFFFF') .onClick(() => this.openAlbumPicker()) } .margin({ top: 40, bottom: 10 }) .width('100%') .justifyContent(FlexAlign.Center) // 照片显示区域 Column() { PhotoPickerComponent({ pickerController: this.pickerController, }) .width('100%') .height('100%') } .width('100%') .height('100%') .alignItems(HorizontalAlign.Center) } // 相册选择模态框 if (this.isShowAlbum) { AlbumPickerModal({ isVisible: this.isShowAlbum, onAlbumSelected: (albumInfo: AlbumInfo) => this.onAlbumSelected(albumInfo), onClose: () => { this.isShowAlbum = false; } }) } } .width('100%') .height('100%') .backgroundColor('#F1F3F5') } } 1.5方案成果总结(一)核心问题解决:成功实现无需权限访问公共目录相册与视频,彻底规避权限申请流程,降低用户操作成本,避免因权限拒绝导致的功能失效问题。通过主题配置与标签页切换,完美适配系统、浅色、深色三种主题,满足不同用户的视觉偏好与应用场景需求。(二)交互体验优化:模态框搭配半透明背景与便捷关闭按钮,标签页切换带有平滑动画,相册选择反馈及时,整体交互流程流畅自然。统一的回调逻辑确保组件间通信稳定,PhotoPickerComponent 能精准接收相册选择信息并快速刷新内容,无卡顿或数据延迟问题。(三)开发价值提升:组件封装程度高,可直接复用至各类需相册选择功能的鸿蒙应用中,降低开发成本。代码结构清晰,主题配置、事件处理模块化,便于后续扩展更多主题或新增功能,同时为鸿蒙应用中同类无权限访问公共资源的开发场景提供了可参考的实现范式。
-
1、关键技术难点总结1.1 问题说明 在鸿蒙应用UI开发过程中,处理长文本内容是一个高频且常见的需求。由于移动设备屏幕尺寸有限,为了保持界面整洁、美观并提供良好的用户体验,我们经常需要将超出显示区域的文本内容以省略号(…)的形式截断。然而,在实际业务场景中,仅有省略是不够的。用户往往需要能够查看被截断的完整内容,并在阅读后能够重新收起文本以节省空间。原生的Text组件并未提供“展开/收起”交互功能的直接支持。如果每个遇到此需求的页面都从头开始实现这一功能,会导致大量重复代码,开发效率低下,且难以保证UI和交互体验的一致性。1.2 原因分析(一) 原生能力局限ArkUI的Text组件提供了基础的省略能力,但将其扩展为可交互的“展开/收起”功能需要开发者自行管理状态(是否展开)、计算文本高度、动态切换maxLines属性以及添加操作按钮,这是一个相对复杂的过程。(二) 代码冗余与维护成本在没有统一封装的情况下,不同开发人员或在不同页面中可能会以不同的方式实现该功能实现,导致代码冗余。后续若需调整交互样式(如按钮文字、位置)或逻辑,需要在所有实现的地方逐一修改,维护成本极高。(三) 体验不一致风险分散的实现方式容易造成应用内不同页面的展开收起交互不一致(例如有的在文本末尾加按钮,有的在下一行右侧),破坏应用的整体体验。2、解决思路 为解决长文本省略与交互问题,我们的核心思路是:封装一个高可定制性、高性能的TextEllipsis自定义组件。该组件不仅提供基础的展开/收起功能,还将通过丰富的参数暴露最大程度的定制能力,确保其能灵活融入各种UI设计风格。(一) 核心功能组件化封装组件内部完整实现状态管理、条件渲染和交互逻辑,对外提供简洁的调用接口。(二) 全面的样式定制、灵活的配置选项允许使用者传入参数,自定义文本内容的字体颜色、字体大小、字重等所有Text组件支持的样式属性。同样允许自定义展开/收起操作按钮的文案及其文字样式。支持自定义收起时的最大行数、支持通过参数控制初始状态是展开还是收起(三) 智能判断集成高效的文本溢出判断逻辑,仅在文本确实需要截断时才显示操作按钮,避免不必要的渲染。同时优化测量时机,减少性能开销3、解决方案(一)封装TextEllipsis组件import { componentUtils, ComponentUtils, MeasureOptions } from "@kit.ArkUI" /** * 组件信息类型 */ interface ComponentsInfoType { width: number height: number localLeft: number localTop: number screenLeft: number screenTop: number windowTop: number windowLeft: number } @ComponentV2 export struct TextEllipsis { /** * 显示文本内容 */ @Param @Require text: string /** * 显示文本的字体大小 */ @Param textFontSize: string | number | Resource = 14 /** * 显示文本的颜色 */ @Param textColor: ResourceColor = "#000000" /** * 显示文本的字体字重 */ @Param textFontWeight: string | number | FontWeight = FontWeight.Normal /** * 行高 */ @Param lineHeight: string | number = 20 /** * 展示的行数 */ @Param rows: number = 1 /** * 是否显示操作 */ @Param showAction: boolean = false /** * 显示文本的颜色 */ @Param actionTextColor: ResourceColor = "#1989fa" /** * 显示文本的字体字重 */ @Param actionTextFontWeight: string | number | FontWeight = FontWeight.Normal /** * 展开操作文案 */ @Param expandText: string = "展开" /** * 收起操作文案 */ @Param collapseText: string = "收起" /** * 省略号内容 */ @Param omitContent: string = "…" /** * 默认是否展开 */ @Param defaultExtend: boolean = false; // @Local uniId: number = 0 @Local showText: string = "" @Local textWidth: number = 0 @Local textHeight: number = 0 @Local maxLineHeight: number = 0 @Local isExpand: boolean = false // private uiContext = this.getUIContext() aboutToAppear(): void { this.uniId = this.getUniqueId() this.formatText() this.isExpand = this.defaultExtend } @Monitor("text", "rows") formatText() { setTimeout(() => { this.textWidth = this.getComponentsInfo(this.uiContext, `text_ellipsis_${this.uniId}`).width this.textHeight = this.measureTextHeight(this.text) this.maxLineHeight = this.measureTextHeight(this.text, this.rows) if (this.textHeight > this.maxLineHeight) { this.getTextByWidth() } else { this.showText = this.text } }, 100) } getTextByWidth() { let clipText = this.text let textHeight = this.textHeight let omitText = this.omitContent let expandText = this.expandText while (textHeight > this.maxLineHeight) { clipText = clipText.substring(0, clipText.length - 1) textHeight = this.measureTextHeight(clipText + (this.textHeight > this.maxLineHeight ? omitText : "") + (this.showAction ? expandText : "")) } this.showText = clipText } /** * 获取组件信息 * @param {context} UIContext * @param {id} 组件id * */ getComponentsInfo(context: UIContext, id: string): ComponentsInfoType { let comUtils: ComponentUtils = context.getComponentUtils() let info: componentUtils.ComponentInfo = comUtils.getRectangleById(id) return { width: context.px2vp(info.size.width), height: context.px2vp(info.size.height), localLeft: context.px2vp(info.localOffset.x), localTop: context.px2vp(info.localOffset.y), screenLeft: context.px2vp(info.screenOffset.x), screenTop: context.px2vp(info.screenOffset.y), windowLeft: context.px2vp(info.windowOffset.x), windowTop: context.px2vp(info.windowOffset.y) } } /** * 测量文字尺寸 */ measureTextSize(context: UIContext, option: MeasureOptions): Size { const measureUtils = context.getMeasureUtils() const sizeOptions = measureUtils.measureTextSize(option) return { width: context.px2vp(sizeOptions.width as number), height: context.px2vp(sizeOptions.height as number) } } /** * 获取文本尺寸高度 * @param text 文本内容 * @param rows 显示的行数 * @returns */ measureTextHeight(text: string, rows?: number): number { return this.measureTextSize(this.uiContext, { textContent: text, constraintWidth: this.textWidth, fontSize: this.textFontSize, lineHeight: this.lineHeight, maxLines: rows }).height } build() { Text() { Span(this.isExpand ? this.text : this.showText) .fontSize(this.textFontSize) .fontColor(this.textColor) .fontWeight(this.textFontWeight) if (this.textHeight > this.maxLineHeight && !this.isExpand) { Span(this.omitContent) .fontSize(this.textFontSize) .fontColor(this.textColor) .fontWeight(this.textFontWeight) } if (this.showAction && this.textHeight > this.maxLineHeight) { Span(this.isExpand ? this.collapseText : this.expandText) .fontSize(this.textFontSize) .fontColor(this.actionTextColor) .fontWeight(this.actionTextFontWeight) .onClick(() => { this.isExpand = !this.isExpand }) } } .id(`text_ellipsis_${this.uniId}`) .width("100%") .lineHeight(this.lineHeight) } } (二)使用示例import { TextEllipsis} from './TextEllipsis TextEllipsis({ text: “为了确保时序正确性,建议开发者自行监听字体缩放变化,以保证测算结果的准确性。在测算裁剪后的文本时,由于某些Unicode字符(如emoji)的码位长度大于1,直接按字符串长度裁剪会导致不准确的结果。建议基于Unicode码点进行迭代处理,避免错误截断字符,确保测算结果准确。”, showAction: true, rows: 1 }) 4、方案成果总结(一) 开发效率的倍增,开发者无需再关心复杂的展开收起逻辑,只需通过一行声明式代码即可引入该功能,极大缩短了开发时间。(二) 用户体验与一致性保障,确保了整个应用程序内部,所有长文本的展开收起操作在交互逻辑、动画效果(可扩展)和视觉风格上保持高度一致,提升了产品的专业度和用户体验。(三) 极致的可定制性与灵活性,提供从文本内容、行数到文本样式乃至操作按钮文案和样式的全方位定制能力,使组件能无缝适配任何UI设计风格。还可根据需要深度定制与扩展。
-
1.1问题说明在鸿蒙应用图形验证码功能开发中,存在几类核心问题:一是组件之间信息传递不顺畅,点击 “刷新” 按钮时,验证码无法同步更新;二是验证码的显示内容与实际记录的状态不一致,比如点击验证码图片刷新后,页面记录的验证码信息没有跟着变,导致验证功能失效;三是重复创建工具类,造成资源浪费,且全局控制变量可能引发状态混乱;四是验证码初始化或用户操作时,可能出现显示空白或状态错乱的情况。1.2原因分析(一)信息传递方式不合理:依赖全局变量实现组件间通信,未采用系统原生的状态关联机制,导致 “刷新” 操作无法触发验证码组件的内容更新与信息同步;(二)信息更新流程缺失:新验证码生成后,未将结果同步至记录变量;点击验证码图片刷新时,新内容也未被记录,造成 “显示内容” 与 “记录信息” 脱节;(三)工具复用设计缺失:生成验证码的工具未做复用处理,每次调用都需重新创建实例、初始化字符库等基础数据,额外增加资源消耗;(四)控制模块形式化设计:控制工具中的刷新方法仅简单返回参数,未关联验证码生成与信息更新的核心逻辑,属于冗余环节。1.3解决思路(一)优化组件信息传递:摒弃全局变量通信,采用系统原生的状态关联功能,实现父子组件信息实时同步与操作联动;(二)统一信息更新逻辑:在页面初始化、点击验证码图片、点击 “刷新” 按钮等所有场景中,生成新验证码后立即同步更新记录信息,确保显示内容与记录信息一致;(三)实现工具复用设计:将生成验证码的工具设计为 “单实例” 模式,避免重复创建与初始化,减少资源浪费;(四)精简冗余控制逻辑:删除无实际作用的全局控制工具,让验证码组件通过自身方法直接实现刷新功能,降低代码复杂度。1.4解决方案(1)打通组件联动与状态同步采用系统原生状态关联机制替代全局变量,实现父子组件信息双向绑定,确保状态实时同步;统一验证码生成与刷新入口,在初始化、点击验证码、点击 “刷新” 按钮时,均通过同一方法生成新内容并同步更新记录,解决 “显示与记录不一致” 问题;优化点击事件逻辑,刷新前清空旧状态并延迟生成新内容,避免空白或同步延迟。(2)优化工具类设计将验证码生成工具改造为单实例模式,通过静态方法获取唯一实例,禁止重复创建,减少基础数据(如数字字符库)重复初始化的资源消耗;保留 4 位数字、随机旋转、干扰线 / 点等核心生成逻辑,优化绘制流程,采用状态保存 / 恢复机制提升 Canvas 稳定性,确保显示清晰。(3)精简组件结构与逻辑删除无实际功能的全局控制模块,剥离组件多余依赖,让验证码组件自主管理生成与刷新逻辑,明确职责边界;精简入参(仅保留状态关联所需参数),统一字体、背景等样式配置,降低代码冗余,便于后续调整尺寸、样式等。验证码工具类代码示例:import { VerifyCodeHelper } from './VerifyCodeHelper'; export class RefreshController { refreshCode = () => { } } // 重构验证码组件,优化交互逻辑 @Component export struct ImageVerify { refreshController: RefreshController | null = null; // Canvas配置 private renderSettings: RenderingContextSettings = new RenderingContextSettings(true); canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings); // 组件尺寸与状态绑定 @State compWidth: number = 140; @State compHeight: number = 40; // 双向绑定验证码文本 @Link verifyText: string; aboutToAppear(): void { if (this.refreshController) { this.refreshController.refreshCode = () => { this.refreshCode(); } } } // 统一刷新方法(触发重新生成验证码) refreshCode() { this.verifyText = VerifyCodeHelper.getInstance() .generateVerifyCode(this.canvasCtx, this.compWidth, this.compHeight); } build() { Row() { Canvas(this.canvasCtx) .width(this.compWidth) .height(this.compHeight) .backgroundColor('#e0e0e0') .onReady(() => { // 组件初始化时生成验证码 this.refreshCode(); }) .onClick(() => { // 点击验证码刷新 this.refreshCode(); }) } .width('100%') .height('100%') } } 验证码组件代码示例:// 单例工具类,重构原绘制逻辑 export class VerifyCodeHelper { private static singleInstance: VerifyCodeHelper; // 重构原数字字符库配置 private digitSource: string = "0,1,2,3,4,5,6,7,8,9"; private digitArray: string[] = this.digitSource.split(","); private arrayLength: number = this.digitArray.length; // 私有构造,禁止外部实例化 private constructor() { } // 获取单例实例 public static getInstance(): VerifyCodeHelper { if (!VerifyCodeHelper.singleInstance) { VerifyCodeHelper.singleInstance = new VerifyCodeHelper(); } return VerifyCodeHelper.singleInstance; } // 生成图形验证码(重构原drawImgCode方法) generateVerifyCode( ctx: CanvasRenderingContext2D, width: number = 100, height: number = 40 ): string { let verifyText: string = ""; // 清空画布 ctx.clearRect(0, 0, width, height); // 绘制4位随机数字(保留原旋转、位置逻辑) for (let i = 0; i < 4; i++) { const randomIdx = Math.floor(Math.random() * this.arrayLength); const rotateRad = Math.random() - 0.5; // 随机弧度(-0.5~0.5) const currentDigit = this.digitArray[randomIdx]; verifyText += currentDigit.toLowerCase(); // 计算文字位置(参考原坐标逻辑) const textX = 10 + i * 20; const textY = height / 2 + Math.random() * 8; // 文字绘制与旋转(重构原绘制流程) ctx.font = "20vp sans-serif"; ctx.save(); ctx.translate(textX, textY); ctx.rotate(rotateRad); ctx.fillStyle = this.getRandomRgbColor(); ctx.fillText(currentDigit, 0, 0); ctx.restore(); } // 绘制干扰线(5条,保留原数量) for (let i = 0; i <= 5; i++) { ctx.strokeStyle = this.getRandomRgbColor(); ctx.beginPath(); ctx.moveTo(Math.random() * width, Math.random() * height); ctx.lineTo(Math.random() * width, Math.random() * height); ctx.stroke(); } // 绘制干扰点(20个,保留原数量) for (let i = 0; i <= 20; i++) { ctx.strokeStyle = this.getRandomRgbColor(); ctx.beginPath(); const dotX = Math.random() * width; const dotY = Math.random() * height; ctx.moveTo(dotX, dotY); ctx.lineTo(dotX + 1, dotY + 1); ctx.stroke(); } return verifyText; } // 生成随机RGB颜色(重构原getColor方法) private getRandomRgbColor(): string { const red = Math.floor(Math.random() * 256); const green = Math.floor(Math.random() * 256); const blue = Math.floor(Math.random() * 256); return `rgb(${red},${green},${blue})`; } } 演示代码示例:import { ImageVerify, RefreshController } from './ImageVerify'; @Entry @Component struct Index { // 验证码状态(与子组件双向绑定) @State currentVerifyText: string = ''; refreshController: RefreshController = new RefreshController(); build() { Column() { Row() { // 验证码输入框 TextInput({ placeholder: '请输入右侧验证码' }) .layoutWeight(1) .padding(10) .border({ width: 1, color: '#dddddd', radius: 6 }) .margin({ right: 12 }); // 验证码组件(双向绑定状态) ImageVerify({ verifyText: $currentVerifyText, refreshController: this.refreshController }) .width(140) .height(40); // 刷新按钮 Text('刷新') .fontSize(16) .fontWeight(FontWeight.Medium) .padding({ left: 14, right: 14, top: 10, bottom: 10 }) .backgroundColor('#f0f0f0') .borderRadius(6) .margin({ left: 12 }) .onClick(() => { this.refreshController.refreshCode(); }); } // 验证按钮(扩展功能,用于测试验证码匹配) Text('验证') .fontSize(18) .fontWeight(FontWeight.Bold) .padding(12) .backgroundColor('#007aff') .borderRadius(8) .margin({ top: 30 }) .onClick(() => { // 此处可扩展验证码匹配逻辑 console.log('当前验证码:', this.currentVerifyText); }) } .height('100%') .width('100%') .padding(30) .justifyContent(FlexAlign.Center); } } 1.5方案成果总结(一)功能稳定性显著提升:组件间交互顺畅,点击刷新按钮或验证码图片均可实时更新内容,显示内容与记录信息完全一致,验证功能稳定可用;(二)运行效率大幅优化:工具复用设计避免了重复创建与初始化,资源消耗明显减少,组件刷新响应速度显著提升;(三)代码可维护性增强:移除全局变量与冗余控制逻辑,采用系统原生机制管理信息,代码结构更简洁,后续调整验证码长度、样式等操作更便捷;(四)用户体验持续优化:验证码刷新无延迟、无空白现象,操作流程符合应用使用习惯,用户操作流畅度与使用体验全面提升。
-
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,即可无缝集成到现有路由体系中参数传递:完善了参数传递机制,支持复杂对象在页面间传递,满足了实际业务场景中数据交互的需求
-
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初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
上滑加载中
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签