-
1.1问题说明在鸿蒙应用开发中,表单校验是用户交互场景的核心环节,需解决多类问题。传统开发中,在鸿蒙(HarmonyOS)应用开发中,打造收款二维码生成组件时,一系列核心问题直接影响功能实用性与用户体验,具体可归纳如下几点:收款金额调整后,二维码内容无法自动同步更新,需手动操作才生效,且金额从“分”到“元”的转换常出现显示误差;为防止支付链接过期需定时刷新二维码,但盲目刷新易导致手机资源浪费,若管控不当还会引发程序异常;需要在二维码中心叠加品牌Logo,既要实现两层内容的叠加显示,又要支持用户自主控制Logo的显示/隐藏及尺寸调整;组件缺乏完善的状态提示,未设置金额时二维码区域空白、生成中无加载提示,且用户点击金额设置、复制链接等操作后,没有明确的结果反馈;1.2原因分析(一)动态更新问题:二维码内容与金额强关联,需通过状态管理机制实现数据变更后的 UI 联动,若仅手动更新易导致数据与视图不一致;(二)定时器管理问题:鸿蒙组件有独立的生命周期(aboutToAppear/aboutToDisappear),若未在组件销毁时停止定时器,会引发内存泄漏;(三)叠加显示问题:二维码与 Logo 属于层级布局需求,普通线性布局无法满足叠加效果,需依赖鸿蒙的 Stack 布局能力;(四)交互体验问题:用户操作后无即时反馈会降低易用性,且鸿蒙系统对敏感权限(如剪贴板)有严格管控,直接调用易触发异常;1.3解决思路(一)状态响应:采用鸿蒙@State装饰器定义核心状态(金额、二维码内容),结合@Watch监听金额变化,自动触发二维码内容更新;(二)生命周期管控:利用组件aboutToAppear初始化二维码并启动定时器,aboutToDisappear停止定时器,确保资源按需释放;(三)叠加布局:通过Stack布局实现二维码(背景)与 Logo(前景)的层级叠加,结合条件渲染控制 Logo 的显示状态;(四)交互优化:操作后通过promptAction.showToast提供即时反馈,对权限受限功能,如复制,加载、空金额等异常状态提示,为用户操作添加即时反馈;1.4解决方案该方案从四方面落地,精准应对核心问题:一是借助鸿蒙状态关联功能绑定金额与二维码内容,通过“变化监听器”实现金额调整后二维码自动刷新,同步完成“分转元”精准转换及两位小数显示,并加入时间戳防链接过期;二是遵循鸿蒙组件生命周期规则管控刷新程序,组件显示时启动每分钟定时刷新,消失时立即停止以避免资源泄漏;三是采用叠加布局实现二维码与Logo层级显示,搭配“显示/隐藏”“放大/缩小”按钮及弹窗反馈,实现Logo灵活控制;四是完善状态提示与操作反馈,覆盖加载、空金额等场景,用户操作后即时弹窗告知结果;代码示例:import promptAction from '@ohos.promptAction'; import { BusinessError } from '@ohos.base'; @Entry @Component export struct QRCodeComponent { // 支持动态变化的收款金额(单位:分) @State @Watch('onAmountChange') amount: number = 0; // 最终的二维码内容字符串 @State private qrValue: string = ''; // 控制二维码自动刷新(例如每分钟一次) private refreshTimer: number | null = null; // Logo图片资源 @State private qrLogo: Resource = $r('app.media.qr_logo'); // 默认Logo // Logo尺寸 @State private logoSize: number = 40; // 是否显示Logo @State private showLogo: boolean = true; // 监听金额变化,并更新二维码内容 onAmountChange(): void { this.updateQRCodeValue(); } // 更新二维码内容,这里模拟生成一个支付链接 updateQRCodeValue(): void { // 示例:生成一个模拟的支付URL,实际开发中请替换为你的业务逻辑 // 参数说明:amount为金额(单位分),t为时间戳防止缓存 const baseUrl: string = 'https://your-payment-server.com/pay'; const timestamp: number = new Date().getTime(); this.qrValue = `${baseUrl}?amount=${this.amount}&t=${timestamp}`; console.info(`QRCode updated: ${this.qrValue}`); } // 启动定时器,定期刷新二维码(例如用于更新支付状态或防止过期) startAutoRefresh(): void { // 每分钟刷新一次(60000毫秒) this.refreshTimer = setInterval(() => { console.info('Refreshing QR code...'); this.updateQRCodeValue(); }, 60000); } // 停止定时器,节省资源 stopAutoRefresh(): void { if (this.refreshTimer !== null) { clearInterval(this.refreshTimer); this.refreshTimer = null; } } // 复制二维码内容到剪贴板,方便商户操作 async copyQRContent(): Promise<void> { try { promptAction.showToast({ message: '复制功能需要添加剪贴板权限', duration: 2000 }); } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Copy failed: ${err.message}`); promptAction.showToast({ message: '复制失败,请手动记录', duration: 2000 }); } } // 设置金额的方法 setAmount(newAmount: number): void { this.amount = newAmount; } // 切换Logo显示状态 toggleLogo(): void { this.showLogo = !this.showLogo; promptAction.showToast({ message: this.showLogo ? '已显示Logo' : '已隐藏Logo', duration: 1500 }); } // 调整Logo大小 adjustLogoSize(increase: boolean): void { if (increase && this.logoSize < 60) { this.logoSize += 5; } else if (!increase && this.logoSize > 20) { this.logoSize -= 5; } promptAction.showToast({ message: `Logo大小: ${this.logoSize}`, duration: 1000 }); } // 组件即将出现时,初始化二维码并启动定时刷新 aboutToAppear(): void { this.updateQRCodeValue(); this.startAutoRefresh(); } // 组件即将消失时,清理定时器 aboutToDisappear(): void { this.stopAutoRefresh(); } build() { Column({ space: 20 }) { // 标题 Text('收款二维码') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(Color.Black) // 二维码显示区域 if (this.qrValue) { Column({ space: 10 }) { // 使用Stack布局实现二维码+Logo的叠加效果 Stack({ alignContent: Alignment.Center }) { // 核心二维码组件 - 作为背景 QRCode(this.qrValue) .width(200) .height(200) .color(Color.Black) .backgroundColor(Color.White) // 在二维码中间显示Logo if (this.showLogo) { Image(this.qrLogo) .width(this.logoSize) .height(this.logoSize) .borderRadius(this.logoSize / 2) // 圆形Logo .backgroundColor(Color.White) .padding(4) .border({ width: 2, color: '#F0F0F0' }) .shadow({ radius: 4, color: '#40000000', offsetX: 1, offsetY: 1 }) } } .width(200) .height(200) // 显示当前收款金额 if (this.amount > 0) { Text(`金额: ${(this.amount / 100).toFixed(2)}元`) .fontSize(16) .fontColor('#FF5000') .fontWeight(FontWeight.Medium) } else { Text('请输入金额') .fontSize(16) .fontColor(Color.Gray) } } .padding(20) .border({ width: 1, color: '#F0F0F0', radius: 8 }) .backgroundColor('#F8F8F8') } else { // 加载状态或空状态提示 Text('正在生成二维码...') .fontSize(16) .fontColor(Color.Gray) } // Logo控制区域 Column({ space: 10 }) { Text('Logo设置') .fontSize(16) .fontColor(Color.Black) .fontWeight(FontWeight.Medium) .width('100%') .textAlign(TextAlign.Start) Row({ space: 10 }) { // 显示/隐藏Logo按钮 Button(this.showLogo ? '隐藏Logo' : '显示Logo') .fontSize(14) .backgroundColor(this.showLogo ? '#909399' : '#409EFF') .fontColor(Color.White) .borderRadius(6) .layoutWeight(1) .height(35) .onClick(() => { this.toggleLogo(); }) // 减小Logo尺寸 Button('缩小') .fontSize(14) .backgroundColor('#E6A23C') .fontColor(Color.White) .borderRadius(6) .layoutWeight(1) .height(35) .onClick(() => { this.adjustLogoSize(false); }) // 增大Logo尺寸 Button('放大') .fontSize(14) .backgroundColor('#E6A23C') .fontColor(Color.White) .borderRadius(6) .layoutWeight(1) .height(35) .onClick(() => { this.adjustLogoSize(true); }) } .width('100%') } .width('100%') .padding(10) .border({ width: 1, color: '#F0F0F0', radius: 8 }) .backgroundColor('#F8F8F8') // 操作按钮区域 Row({ space: 15 }) { // 设置金额按钮 - 添加多个预设金额 Button('100元') .fontSize(16) .backgroundColor('#007DFF') .fontColor(Color.White) .borderRadius(8) .width(100) .height(40) .onClick(() => { this.setAmount(10000); promptAction.showToast({ message: '金额已设置为100元', duration: 1500 }); }) // 复制二维码按钮 Button('复制链接') .fontSize(16) .backgroundColor('#34C759') .fontColor(Color.White) .borderRadius(8) .width(100) .height(40) .onClick(() => { this.copyQRContent(); }) } .margin({ top: 10 }) // 更多金额选项 Row({ space: 10 }) { Button('50元') .fontSize(14) .backgroundColor('#409EFF') .fontColor(Color.White) .borderRadius(6) .width(80) .height(35) .onClick(() => { this.setAmount(5000); promptAction.showToast({ message: '金额已设置为50元', duration: 1500 }); }) Button('200元') .fontSize(14) .backgroundColor('#409EFF') .fontColor(Color.White) .borderRadius(6) .width(80) .height(35) .onClick(() => { this.setAmount(20000); promptAction.showToast({ message: '金额已设置为200元', duration: 1500 }); }) Button('清空') .fontSize(14) .backgroundColor('#909399') .fontColor(Color.White) .borderRadius(6) .width(80) .height(35) .onClick(() => { this.setAmount(0); promptAction.showToast({ message: '金额已清空', duration: 1500 }); }) } .margin({ top: 10 }) // 使用说明文本 Text('请让对方扫描此二维码完成支付') .fontSize(14) .fontColor('#666666') .margin({ top: 25 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') .padding(20) .alignItems(HorizontalAlign.Center) .backgroundColor('#FFFFFF') } } 1.5方案成果总结经过优化后的二维码组件,全面解决了开发初期的核心问题,达成“实用、稳定、易用”的目标:功能上,实现了金额与二维码的动态联动、每分钟定时刷新、Logo灵活控制、权限兼容等全场景需求,金额转换精准无误差;稳定性上,通过生命周期管控避免资源泄漏,权限兼容处理减少程序异常,在不同鸿蒙设备上均能稳定运行;易用性上,状态提示清晰、操作反馈及时,无论是商户设置金额、调整Logo,还是付款方识别二维码,都能快速上手,无需额外学习成本。
-
1. 关键技术难点总结1.1 问题说明在APP开发中,导航栏(顶部/底部)是连接不同功能模块(如资讯分类、应用功能入口)的核心 UI 组件,广泛应用于需要快速切换内容页面的场景。用户在使用过程中,常需通过横向滑动导航栏浏览更多选项,期望滑动停止后,系统能自动选中最靠近屏幕中心的导航项,无需手动点击即可切换到对应内容页面。但实际开发中,这一核心交互需求面临诸多落地问题:当导航项数量超过屏幕显示范围时,滑动停止后无法精准定位中心导航项;导航项选中状态与内容页面切换不同步(如导航项已切换,内容页仍停留在原页面);滚动过程中中心项计算偏差(如因位置算法问题,导致选中项与用户视觉中心不一致)。1.2 原因分析滚动偏移量处理不当:系统无法准确知道导航栏当前滚动到了什么位置,这是因为在处理滚动事件时,错误地使用了绝对值函数[Math.abs],导致丢失了滚动方向信息,同时需要处理负数时校正。中心点计算不准确:导航栏和内容页面由不同的组件控制,它们之间缺乏有效的协同机制,导致一个动了另一个没跟上。这体现在滚动过程中和滚动结束时采用了不同的计算策略,造成选中状态不一致。滚动控制器状态同步不及时:计算导航项在屏幕中心时用了不合适的公式,主要原因在使用[Math.floor向下取整]而非[Math.round四舍五入]来计算中心点,导致精度不足。2. 解决思路优化滚动偏移量处理机制,正确保存和使用滚动位置信息,提取偏移量计算方法updateScrollOffsetFromController改进中心点计算算法,使用更精确的数学方法确定中心导航项,如使用[Math.round]而非[Math.floor]建立实时状态同步机制,确保滚动过程中和滚动结束时的数据一致性,提取公共的方法getCenterTabIndex获取中心导航项索引完善事件处理流程,确保滑动停止时能正确触发选中逻辑,使用Scroll的onScrollEnd或者onScrollStop方法监听停止时触发事件。3. 解决方案步骤1:定义状态变量和滚动控制器@Component export struct HorizontalTabBar { private tabTitles: string[] = []; @Link currentIndex: number; private onTabChange: (index: number) => void = () => {}; // 用于滚动控制的参数 private scrollController: Scroller = new Scroller(); @State private itemWidth: number = 80; @State private scrollViewWidth: number = 360; @State private scrollOffset: number = 0; // 记录当前滚动偏移量 步骤2:实现滚动事件处理private handleScroll(offset: number, state: ScrollState): ScrollResult { // 实时同步滚动偏移量,确保后续计算准确 this.updateScrollOffsetFromController(offset); return { offsetRemain: offset }; } 步骤3:实现滚动偏移量更新方法private updateScrollOffsetFromController(fallbackOffset?: number) { const offsetInfo = this.scrollController?.currentOffset ? this.scrollController.currentOffset() : undefined; if (offsetInfo && typeof offsetInfo.xOffset === 'number') { // Scroll返回的xOffset是非负数,负数时强制校正 this.scrollOffset = Math.max(0, offsetInfo.xOffset); return; } // 如果无法从controller获取offset,则退回到事件值 if (typeof fallbackOffset === 'number') { this.scrollOffset = Math.max(0, fallbackOffset); } } 步骤4:实现中心点索引计算方法private getCenterTabIndex(offset: number): number { if (this.itemWidth <= 0) { return 0; } const centerPosition = offset + this.scrollViewWidth / 2; // 将中心点映射到离其最近的tab索引 const approxIndex = Math.round((centerPosition - this.itemWidth / 2) / this.itemWidth); return Math.min(Math.max(approxIndex, 0), this.tabTitles.length - 1); } 步骤5:实现滚动结束事件处理// 滚动结束时选中最靠近中心的tab private selectCenterTab() { // 使用最新的滚动偏移量,确保选中项与停止位置一致 this.updateScrollOffsetFromController(); const centerIndex = this.getCenterTabIndex(this.scrollOffset); // 更新选中状态 if (centerIndex !== this.currentIndex) { this.currentIndex = centerIndex; this.onTabChange(centerIndex); } // 滚动到选中的tab使其居中 this.scrollToCurrentTab(); } 步骤6:完整导航栏组件@Component export struct HorizontalTabBar { private tabTitles: string[] = []; @Link currentIndex: number; private onTabChange: (index: number) => void = () => {}; // 用于滚动控制的参数 private scrollController: Scroller = new Scroller(); @State private itemWidth: number = 80; @State private scrollViewWidth: number = 360; @State private scrollOffset: number = 0; // 记录当前滚动偏移量 aboutToAppear() { // 计算每个tab的宽度,根据标题长度动态调整 this.calculateItemWidth(); } calculateItemWidth() { // 根据标题文本长度动态计算宽度 this.itemWidth = 0; this.tabTitles.forEach(title => { const calculatedWidth = title.length * 16 + 40; // 基础宽度 + 内边距 this.itemWidth = Math.max(this.itemWidth, calculatedWidth); }); } build() { Column() { Scroll(this.scrollController) { Row() { ForEach(this.tabTitles, (title: string, index: number) => { Column() { Text(title) .fontSize(16) .fontColor(index === this.currentIndex ? '#007DFF' : '#666666') .fontWeight(index === this.currentIndex ? FontWeight.Medium : FontWeight.Normal) // 选中指示器 if (index === this.currentIndex) { Rect() .width(20) .height(3) .fill('#007DFF') .margin({ top: 6 }) .animation({ duration: 200, curve: Curve.EaseInOut }) } else { Rect() .width(20) .height(3) .fill('#00000000') .margin({ top: 6 }) } } .width(this.itemWidth) .padding({ top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') .onClick(() => { this.handleTabClick(index); }) }, (title: string) => title) } } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .onScrollFrameBegin((offset: number, state: ScrollState) => { return this.handleScroll(offset, state); }) .onScrollEnd(() => { // 滚动结束时,选中最靠近中心的tab this.selectCenterTab(); }) .onAreaChange((oldArea: Area, newArea: Area) => { // 获取滚动视图的宽度 this.scrollViewWidth = newArea.width as number; }) .width('100%') .backgroundColor('#FFFFFF') } } private handleTabClick(index: number) { this.currentIndex = index; this.onTabChange(index); this.scrollToCurrentTab(); } private handleScroll(offset: number, state: ScrollState): ScrollResult { // 实时同步滚动偏移量,确保后续计算准确 this.updateScrollOffsetFromController(offset); // 滚动过程中实时更新选中项 if (state === ScrollState.Scroll) { //this.updateCenterTabDuringScroll(); } return { offsetRemain: offset }; } // 滚动过程中实时更新中心tab private updateCenterTabDuringScroll() { // 计算屏幕中心位置对应的tab索引 const newIndex = this.getCenterTabIndex(this.scrollOffset); // 如果新索引与当前索引不同,则更新选中状态 if (newIndex !== this.currentIndex) { this.currentIndex = newIndex; this.onTabChange(newIndex); } } // 滚动结束时选中最靠近中心的tab private selectCenterTab() { // 使用最新的滚动偏移量,确保选中项与停止位置一致 this.updateScrollOffsetFromController(); const centerIndex = this.getCenterTabIndex(this.scrollOffset); // 更新选中状态 if (centerIndex !== this.currentIndex) { this.currentIndex = centerIndex; this.onTabChange(centerIndex); } // 滚动到选中的tab使其居中 this.scrollToCurrentTab(); } private scrollToCurrentTab() { // 计算目标滚动位置,使当前tab居中 const targetOffset = this.currentIndex * this.itemWidth - (this.scrollViewWidth - this.itemWidth) / 2; const maxOffset = Math.max(0, this.tabTitles.length * this.itemWidth - this.scrollViewWidth); this.scrollController.scrollTo({ xOffset: Math.min(Math.max(0, targetOffset), maxOffset), yOffset: -1, animation: { duration: 300, curve: Curve.EaseOut } }); } private updateScrollOffsetFromController(fallbackOffset?: number) { const offsetInfo = this.scrollController?.currentOffset ? this.scrollController.currentOffset() : undefined; if (offsetInfo && typeof offsetInfo.xOffset === 'number') { // Scroll返回的xOffset是非负数,负数时强制校正 this.scrollOffset = Math.max(0, offsetInfo.xOffset); return; } // 如果无法从controller获取offset,则退回到事件值 if (typeof fallbackOffset === 'number') { this.scrollOffset = Math.max(0, fallbackOffset); } } private getCenterTabIndex(offset: number): number { if (this.itemWidth <= 0) { return 0; } const centerPosition = offset + this.scrollViewWidth / 2; // 将中心点映射到离其最近的tab索引 const approxIndex = Math.round((centerPosition - this.itemWidth / 2) / this.itemWidth); return Math.min(Math.max(approxIndex, 0), this.tabTitles.length - 1); } aboutToUpdate(params?: Record<string, Object>): void { // 每次更新后滚动到当前选中的tab //this.scrollToCurrentTab(); } } 3.2 Tab组件示例@Component export struct MyTabContent { private tabCount: number = 0; @Link currentIndex: number; // 当前选中的tab索引,与HorizontalTabBar组件的currentIndex保持同步 private onPageChange: (index: number) => void = () => {}; private swiperController: SwiperController = new SwiperController(); private colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']; build() { Column() { Swiper(this.swiperController) { ForEach(Array.from({ length: this.tabCount }), (item: number, index: number) => { Column() { // 模拟不同tab的内容 this.buildTabContent(index) } .width('100%') .height('100%') }, (item: number, index: number) => index.toString()) } .index(this.currentIndex) .autoPlay(false) .indicator(false) .loop(false) .duration(300) .onChange((index: number) => { if (index !== this.currentIndex) { this.currentIndex = index; this.onPageChange(index); } }) .width('100%') .layoutWeight(1) } } @Builder buildTabContent(index: number) { Column() { Text(`这是${this.getTabTitle(index)}页面`) .fontSize(20) .fontWeight(FontWeight.Medium) .fontColor('#182431') .margin({ bottom: 20 }) // 模拟内容列表 List({ space: 12 }) { ForEach(Array.from({ length: 10 }), (item: number, itemIndex: number) => { ListItem() { this.buildContentItem(index, itemIndex) } }, (item: number, itemIndex: number) => itemIndex.toString()) } .width('100%') .layoutWeight(1) .padding({ left: 16, right: 16 }) } .width('100%') .height('100%') .padding({ top: 20 }) } @Builder buildContentItem(tabIndex: number, itemIndex: number) { Row() { Rect() .width(80) .height(60) .fill(this.colors[tabIndex % this.colors.length]) .radius(8) Column() { Text(`${this.getTabTitle(tabIndex)}新闻标题 ${itemIndex + 1}`) .fontSize(16) .fontColor('#182431') .fontWeight(FontWeight.Medium) .margin({ bottom: 4 }) Text(`这是${this.getTabTitle(tabIndex)}分类下的第${itemIndex + 1}条新闻内容摘要`) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) .margin({ left: 12 }) .alignItems(HorizontalAlign.Start) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 }) } private getTabTitle(index: number): string { const titles = ['推荐', '热点', '科技', '娱乐', '体育', '财经', '汽车', '美食', '旅游', '健康']; return titles[index] || `标签${index + 1}`; } aboutToUpdate(params?: Record<string, Object>): void { // 当currentIndex变化时,同步Swiper的位置 if (this.swiperController && params && params['currentIndex'] !== undefined) { this.swiperController.changeIndex(this.currentIndex); } } } 3.3 组件使用示例import { HorizontalTabBar } from '../components/HorizontalTabBar'; import { MyTabContent } from '../components/TabContent'; @Entry @Component struct Index { @State currentIndex: number = 0; private tabTitles: string[] = ['推荐', '热点', '科技', '娱乐', '体育', '财经', '汽车', '美食', '旅游', '健康']; build() { Column() { // 顶部标题栏 Row() { Text('资讯头条') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#182431') Blank() Image($r('app.media.ic_search')) .width(24) .height(24) } .width('100%') .padding({ left: 24, right: 24, top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') // Tab内容区域 MyTabContent({ tabCount: this.tabTitles.length, currentIndex: this.currentIndex, onPageChange: (index: number) => { this.currentIndex = index; } }) .layoutWeight(1) .margin({ bottom: 10 }) // 底部水平滑动导航栏 HorizontalTabBar({ tabTitles: this.tabTitles, currentIndex: this.currentIndex, onTabChange: (index: number) => { this.currentIndex = index; } }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } } 4. 方案成果总结该方案解决了底部导航栏滑动选中不准、切换不同步、控制不稳定等问题,为鸿蒙应用底部导航交互提供了可靠高效的解决方案。中心点识别精准:优化滚动偏移量处理逻辑,采用 Math.round 算法精准计算屏幕中心对应的导航项索引,滑动停止后自动选中最靠近中心的选项,避免选中偏差;交互体验流畅自然:建立导航项与内容页面的实时同步机制,选中状态切换无延迟、无错位,滑动操作与页面切换形成连贯反馈,符合用户交互预期;滚动控制稳定可靠:通过滚动控制器实时获取最新滚动位置,补充偏移量处理方案,确保不同场景下位置数据准确,避免因数据滞后导致的控制异常;复用性与维护性强:核心逻辑封装为独立组件,通过参数配置即可快速集成到各类应用,算法与控制逻辑分离,后续修改或扩展功能更便捷。
-
1.1问题说明在鸿蒙应用开发中,表单校验是用户交互场景的核心环节,需解决多类问题。传统开发中,校验逻辑分散在各页面,重复编码量大,规则维护困难;错误信息管理混乱,难以统一展示与更新;实时校验与提交校验的触发时机不协调,易导致用户体验卡顿;同时,身份证、银行卡等特殊字段需复杂校验规则(如校验码验证、Luhn 算法),敏感词过滤、SQL 注入防护等安全校验也需额外适配,整体开发效率低、可维护性差、用户体验不佳。1.2原因分析(一)校验规则分散:未封装统一校验工具,各表单需重复编写正则表达式、长度判断等逻辑,导致代码冗余且易出错;(二)错误状态管理混乱:错误信息与表单组件耦合,缺乏集中存储与管理机制,更新和查询效率低;(三)校验触发机制不规范:实时校验直接操作响应式数据,易引发频繁 UI 刷新,导致输入卡顿;提交校验需手动遍历所有字段,逻辑繁琐;(四)特殊场景适配不足:密码一致性、敏感词过滤等联动校验和安全校验未集成到统一流程,需额外编写大量适配代码;(五)组件复用性差:表单字段与校验逻辑强绑定,不同表单难以快速复用已有校验能力。1.3解决思路(一)封装统一校验工具类:提炼常用校验规则(如身份证、手机号、密码等),封装为静态方法,支持自定义正则与错误信息,统一校验结果格式;(二)单独管理校验逻辑:做一个专门负责校验的功能模块,集中处理所有字段的检查、错误记录,不影响页面展示,清除等方法,与 UI 组件解耦;(三)规范校验触发流程:输入内容时先暂存,延迟更新到表单,避免频繁刷新导致卡顿;实时反馈错误但不干扰输入;(四)集成多场景校验能力:支持一致性校验(如密码确认)、敏感词过滤、SQL 注入防护,通过字段配置自动触发对应校验;(五)统一表单样式:提供现成的输入框模板,自动搭配错误提示样式,快速搭建表单页面,提升表单构建效率。1.4解决方案该方案构建 “一站式校验工具 + 智能管理模块 + 标准化表单模板” 的全流程表单处理体系,既解决重复开发问题,又优化用户填写体验:核心的一站式校验工具箱整合了 10 余种常用校验能力,不仅能完成必填项检查、密码长度限制等基础操作,还能精准验证手机号(11 位数字)、身份证(18 位含校验码)、邮箱(含 @及后缀)、银行卡(有效卡号核验)等格式正确性,同时自动过滤 SQL 注入等非法字符、识别敏感词,提供安全防护;支持自定义验证规则,比如特殊会员卡号、企业工号等个性化要求,且所有规则集中管理,修改时只需调整一处即可生效。此外,方案还支持个性化需求调整,可自定义验证规则和错误提示文案,通过全流程安全校验与流畅的交互设计,兼顾业务适配性与用户体验。校验工具类代码示例:// 校验结果接口 export interface ValidationResult { isValid: boolean; message: string; } // 表单字段配置接口 export interface FormFieldConfig { type: 'required' | 'idCard' | 'phone' | 'email' | 'bankCard' | 'password' | 'url' | 'date' | 'custom'; fieldName: string; value: string; compareValue?: string; // 用于一致性校验 regex?: RegExp; // 用于自定义正则校验 customErrorMessage?: string; // 自定义错误信息 minLength?: number; // 最小长度 maxLength?: number; // 最大长度 } export class FormValidator { // 校验规则常量 private static readonly ID_CARD_REGEX: RegExp = /^\d{17}[\dXx]$/; private static readonly PHONE_REGEX: RegExp = /^1[3-9]\d{9}$/; private static readonly EMAIL_REGEX: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; private static readonly URL_REGEX: RegExp = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/; private static readonly BANK_CARD_REGEX: RegExp = /^\d{16,19}$/; private static readonly PASSWORD_REGEX: RegExp = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,20}$/; private static readonly DATE_REGEX: RegExp = /^\d{4}-\d{2}-\d{2}$/; private static readonly SQL_INJECTION_REGEX: RegExp = /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION|EXEC)\b)|('|--|;)/i; // 敏感词列表(实际项目中应从服务器获取) private static readonly SENSITIVE_WORDS: string[] = [ '敏感词1', '敏感词2', '违法', '非法', '测试敏感词' ]; /** * 非空校验 * @param value 待校验的值 * @param fieldName 字段名称 * @returns 校验结果 */ static validateRequired(value: string, fieldName: string = ''): ValidationResult { if (!value || value.trim().length === 0) { return { isValid: false, message: `${fieldName}不能为空` }; } return { isValid: true, message: '' }; } /** * 身份证号码校验 * @param idCard 身份证号码 * @returns 校验结果 */ static validateIdCard(idCard: string): ValidationResult { // 非空校验 const requiredResult: ValidationResult = FormValidator.validateRequired(idCard, '身份证号'); if (!requiredResult.isValid) { return requiredResult; } // 长度校验 if (idCard.length !== 18) { return { isValid: false, message: '身份证号必须为18位' }; } // 格式校验 if (!FormValidator.ID_CARD_REGEX.test(idCard)) { return { isValid: false, message: '身份证号格式不正确' }; } // 校验码验证(增强校验) if (!FormValidator.validateIdCardCheckCode(idCard)) { return { isValid: false, message: '身份证号校验码不正确' }; } return { isValid: true, message: '' }; } /** * 身份证校验码验证 * @param idCard 身份证号码 * @returns 是否通过校验 */ private static validateIdCardCheckCode(idCard: string): boolean { const factor: number[] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const checkCodes: string[] = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; let sum: number = 0; for (let i: number = 0; i < 17; i++) { sum += parseInt(idCard.charAt(i)) * factor[i]; } const mod: number = sum % 11; return idCard.charAt(17).toUpperCase() === checkCodes[mod]; } /** * 手机号码校验 * @param phone 手机号码 * @returns 校验结果 */ static validatePhone(phone: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(phone, '手机号'); if (!requiredResult.isValid) { return requiredResult; } if (phone.length !== 11) { return { isValid: false, message: '手机号必须为11位' }; } if (!FormValidator.PHONE_REGEX.test(phone)) { return { isValid: false, message: '手机号格式不正确' }; } return { isValid: true, message: '' }; } /** * 邮箱校验 * @param email 邮箱地址 * @returns 校验结果 */ static validateEmail(email: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(email, '邮箱'); if (!requiredResult.isValid) { return requiredResult; } if (!FormValidator.EMAIL_REGEX.test(email)) { return { isValid: false, message: '邮箱格式不正确' }; } return { isValid: true, message: '' }; } /** * 银行卡号校验 * @param bankCard 银行卡号 * @returns 校验结果 */ static validateBankCard(bankCard: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(bankCard, '银行卡号'); if (!requiredResult.isValid) { return requiredResult; } // 移除空格 const cleanCard: string = bankCard.replace(/\s/g, ''); if (!FormValidator.BANK_CARD_REGEX.test(cleanCard)) { return { isValid: false, message: '银行卡号格式不正确' }; } // Luhn算法校验 if (!FormValidator.validateLuhn(cleanCard)) { return { isValid: false, message: '银行卡号校验失败' }; } return { isValid: true, message: '' }; } /** * Luhn算法校验银行卡号 * @param cardNumber 银行卡号 * @returns 是否通过校验 */ private static validateLuhn(cardNumber: string): boolean { let sum: number = 0; let isEven: boolean = false; for (let i: number = cardNumber.length - 1; i >= 0; i--) { let digit: number = parseInt(cardNumber.charAt(i)); if (isEven) { digit *= 2; if (digit > 9) { digit -= 9; } } sum += digit; isEven = !isEven; } return sum % 10 === 0; } /** * 密码强度校验 * @param password 密码 * @returns 校验结果 */ static validatePassword(password: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(password, '密码'); if (!requiredResult.isValid) { return requiredResult; } if (password.length < 8 || password.length > 20) { return { isValid: false, message: '密码长度必须为8-20位' }; } if (!FormValidator.PASSWORD_REGEX.test(password)) { return { isValid: false, message: '密码必须包含字母和数字' }; } return { isValid: true, message: '' }; } /** * URL地址校验 * @param url URL地址 * @returns 校验结果 */ static validateURL(url: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(url, 'URL地址'); if (!requiredResult.isValid) { return requiredResult; } if (!FormValidator.URL_REGEX.test(url)) { return { isValid: false, message: 'URL格式不正确' }; } return { isValid: true, message: '' }; } /** * 日期格式校验 * @param date 日期字符串 * @param format 日期格式,默认为YYYY-MM-DD * @returns 校验结果 */ static validateDate(date: string, format: string = 'YYYY-MM-DD'): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(date, '日期'); if (!requiredResult.isValid) { return requiredResult; } if (!FormValidator.DATE_REGEX.test(date)) { return { isValid: false, message: '日期格式应为YYYY-MM-DD' }; } // 验证日期是否合法 const dateObj: Date = new Date(date); if (isNaN(dateObj.getTime())) { return { isValid: false, message: '日期不合法' }; } return { isValid: true, message: '' }; } /** * 一致性校验(如确认密码) * @param value1 第一个值 * @param value2 第二个值 * @param fieldName 字段名称 * @returns 校验结果 */ static validateConsistency(value1: string, value2: string, fieldName: string): ValidationResult { if (value1 !== value2) { return { isValid: false, message: `${fieldName}不一致` }; } return { isValid: true, message: '' }; } /** * 防SQL注入校验 * @param input 输入内容 * @param fieldName 字段名称 * @returns 校验结果 */ static validateSqlInjection(input: string, fieldName: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(input, fieldName); if (!requiredResult.isValid) { return requiredResult; } if (FormValidator.SQL_INJECTION_REGEX.test(input)) { return { isValid: false, message: `${fieldName}包含非法字符` }; } return { isValid: true, message: '' }; } /** * 敏感词过滤校验 * @param input 输入内容 * @param fieldName 字段名称 * @returns 校验结果 */ static validateSensitiveWords(input: string, fieldName: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(input, fieldName); if (!requiredResult.isValid) { return requiredResult; } for (const word of FormValidator.SENSITIVE_WORDS) { if (input.includes(word)) { return { isValid: false, message: `${fieldName}包含敏感词` }; } } return { isValid: true, message: '' }; } /** * 字符长度校验 * @param input 输入内容 * @param minLength 最小长度 * @param maxLength 最大长度 * @param fieldName 字段名称 * @returns 校验结果 */ static validateLength(input: string, minLength: number, maxLength: number, fieldName: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(input, fieldName); if (!requiredResult.isValid) { return requiredResult; } if (input.length < minLength || input.length > maxLength) { return { isValid: false, message: `${fieldName}长度必须在${minLength}-${maxLength}个字符之间` }; } return { isValid: true, message: '' }; } /** * 数字范围校验 * @param value 数字值 * @param min 最小值 * @param max 最大值 * @param fieldName 字段名称 * @returns 校验结果 */ static validateNumberRange(value: number, min: number, max: number, fieldName: string): ValidationResult { if (value < min || value > max) { return { isValid: false, message: `${fieldName}必须在${min}-${max}之间` }; } return { isValid: true, message: '' }; } /** * 自定义正则表达式校验 * @param value 待校验的值 * @param regex 正则表达式 * @param errorMessage 错误信息 * @returns 校验结果 */ static validateWithRegex(value: string, regex: RegExp, errorMessage: string): ValidationResult { const requiredResult: ValidationResult = FormValidator.validateRequired(value, ''); if (!requiredResult.isValid) { return requiredResult; } if (!regex.test(value)) { return { isValid: false, message: errorMessage }; } return { isValid: true, message: '' }; } /** * 批量校验多个字段 * @param fields 字段配置数组 * @returns 校验结果 */ static validateMultipleFields(fields: FormFieldConfig[]): ValidationResult { for (const field of fields) { let result: ValidationResult; switch (field.type) { case 'required': result = FormValidator.validateRequired(field.value, field.fieldName); break; case 'idCard': result = FormValidator.validateIdCard(field.value); break; case 'phone': result = FormValidator.validatePhone(field.value); break; case 'email': result = FormValidator.validateEmail(field.value); break; case 'bankCard': result = FormValidator.validateBankCard(field.value); break; case 'password': result = FormValidator.validatePassword(field.value); break; case 'url': result = FormValidator.validateURL(field.value); break; case 'date': result = FormValidator.validateDate(field.value); break; case 'custom': if (field.regex && field.customErrorMessage) { result = FormValidator.validateWithRegex(field.value, field.regex, field.customErrorMessage); } else { result = { isValid: true, message: '' }; } break; default: result = { isValid: true, message: '' }; } if (!result.isValid) { return result; } } return { isValid: true, message: '' }; } } 校验逻辑组件代码示例:import { FormValidator, ValidationResult, FormFieldConfig } from './FormValidator'; @Component export struct FormValidationComponent { // 表单字段配置 @Link formFields: Array<FormFieldConfig>; // 错误信息映射 @State errorMessages: Map<string, string> = new Map(); // 校验单个字段 private validateField(fieldConfig: FormFieldConfig): boolean { let result: ValidationResult = { isValid: true, message: '' }; switch (fieldConfig.type) { case 'required': result = FormValidator.validateRequired(fieldConfig.value, fieldConfig.fieldName); break; case 'idCard': result = FormValidator.validateIdCard(fieldConfig.value); break; case 'phone': result = FormValidator.validatePhone(fieldConfig.value); break; case 'email': result = FormValidator.validateEmail(fieldConfig.value); break; case 'bankCard': result = FormValidator.validateBankCard(fieldConfig.value); break; case 'password': result = FormValidator.validatePassword(fieldConfig.value); break; case 'url': result = FormValidator.validateURL(fieldConfig.value); break; case 'date': result = FormValidator.validateDate(fieldConfig.value); break; case 'custom': if (fieldConfig.regex && fieldConfig.customErrorMessage) { result = FormValidator.validateWithRegex( fieldConfig.value, fieldConfig.regex, fieldConfig.customErrorMessage ); } break; } if (result.isValid) { this.errorMessages.delete(fieldConfig.fieldName); } else { this.errorMessages.set(fieldConfig.fieldName, result.message); } return result.isValid; } // 校验所有字段 validateAll(): boolean { let isValid = true; this.errorMessages.clear(); for (const field of this.formFields) { if (!this.validateField(field)) { isValid = false; } } return isValid; } // 获取字段错误信息 getFieldError(fieldName: string): string { return this.errorMessages.get(fieldName) || ''; } // 清除所有错误信息 clearErrors(): void { this.errorMessages.clear(); } build() { // 这是一个逻辑组件,不需要UI渲染 // 实际使用时在其他组件中实例化并调用其方法 } } 校验工具演示代码示例:import { FormValidator, ValidationResult } from './FormValidator'; @Entry @Component struct FormExample { // 表单数据 - 使用 @State 装饰器确保响应式更新 @State username: string = ''; @State idCard: string = ''; @State phone: string = ''; @State email: string = ''; @State bankCard: string = ''; @State password: string = ''; @State confirmPassword: string = ''; @State website: string = ''; @State birthDate: string = ''; @State description: string = ''; // 错误信息映射 @State errorMessages: Map<string, string> = new Map(); // 用于存储输入框的临时值(避免在 onChange 中直接更新 State) @State tempUsername: string = ''; @State tempIdCard: string = ''; @State tempPhone: string = ''; @State tempEmail: string = ''; @State tempBankCard: string = ''; @State tempPassword: string = ''; @State tempConfirmPassword: string = ''; @State tempWebsite: string = ''; @State tempBirthDate: string = ''; @State tempDescription: string = ''; aboutToAppear() { // 初始化错误信息映射 this.errorMessages = new Map(); // 初始化临时值与实际值同步 this.tempUsername = this.username; this.tempIdCard = this.idCard; this.tempPhone = this.phone; this.tempEmail = this.email; this.tempBankCard = this.bankCard; this.tempPassword = this.password; this.tempConfirmPassword = this.confirmPassword; this.tempWebsite = this.website; this.tempBirthDate = this.birthDate; this.tempDescription = this.description; } // 更新字段值并执行校验 updateField(fieldName: string, value: string, validatorType: string = 'required') { // 更新临时值(立即反映在输入框中) switch (fieldName) { case 'username': this.tempUsername = value; break; case 'idCard': this.tempIdCard = value; break; case 'phone': this.tempPhone = value; break; case 'email': this.tempEmail = value; break; case 'bankCard': this.tempBankCard = value; break; case 'password': this.tempPassword = value; break; case 'confirmPassword': this.tempConfirmPassword = value; break; case 'website': this.tempWebsite = value; break; case 'birthDate': this.tempBirthDate = value; break; case 'description': this.tempDescription = value; break; } // 延迟更新实际值并执行校验(避免阻塞输入) setTimeout(() => { this.commitFieldChange(fieldName, value, validatorType); }, 10); } // 提交字段变更并执行校验 commitFieldChange(fieldName: string, value: string, validatorType: string) { // 更新实际值 switch (fieldName) { case 'username': this.username = value; break; case 'idCard': this.idCard = value; break; case 'phone': this.phone = value; break; case 'email': this.email = value; break; case 'bankCard': this.bankCard = value; break; case 'password': this.password = value; break; case 'confirmPassword': this.confirmPassword = value; break; case 'website': this.website = value; break; case 'birthDate': this.birthDate = value; break; case 'description': this.description = value; break; } // 执行校验 this.performValidation(fieldName, value, validatorType); } // 执行具体校验逻辑 performValidation(fieldName: string, value: string, validatorType: string) { let result: ValidationResult = { isValid: true, message: '' }; switch (validatorType) { case 'required': result = FormValidator.validateRequired(value, this.getFieldDisplayName(fieldName)); break; case 'idCard': result = FormValidator.validateIdCard(value); break; case 'phone': result = FormValidator.validatePhone(value); break; case 'email': result = FormValidator.validateEmail(value); break; case 'bankCard': result = FormValidator.validateBankCard(value); break; case 'password': result = FormValidator.validatePassword(value); break; case 'url': result = FormValidator.validateURL(value); break; case 'date': result = FormValidator.validateDate(value); break; } // 更新错误信息 if (!result.isValid) { this.errorMessages.set(fieldName, result.message); } else { this.errorMessages.delete(fieldName); } // 特殊处理:确认密码一致性校验 if (fieldName === 'password' || fieldName === 'confirmPassword') { this.validatePasswordConsistency(); } } // 获取字段显示名称 private getFieldDisplayName(fieldName: string): string { const nameMap: Record<string, string> = { 'username': '用户名', 'idCard': '身份证号', 'phone': '手机号', 'email': '邮箱', 'bankCard': '银行卡号', 'password': '密码', 'confirmPassword': '确认密码', 'website': '个人网站', 'birthDate': '出生日期', 'description': '描述' }; return nameMap[fieldName] || fieldName; } // 密码一致性校验 validatePasswordConsistency() { if (this.password && this.confirmPassword) { const result: ValidationResult = FormValidator.validateConsistency(this.password, this.confirmPassword, '密码'); if (!result.isValid) { this.errorMessages.set('confirmPassword', result.message); } else { this.errorMessages.delete('confirmPassword'); } } } // 防SQL注入和敏感词校验 validateDescription() { const sqlResult: ValidationResult = FormValidator.validateSqlInjection(this.description, '描述'); const sensitiveResult: ValidationResult = FormValidator.validateSensitiveWords(this.description, '描述'); if (!sqlResult.isValid) { this.errorMessages.set('description', sqlResult.message); } else if (!sensitiveResult.isValid) { this.errorMessages.set('description', sensitiveResult.message); } else { this.errorMessages.delete('description'); } } // 获取字段错误信息 getFieldError(fieldName: string): string { return this.errorMessages.get(fieldName) || ''; } // 获取临时字段值 getTempFieldValue(fieldName: string): string { switch (fieldName) { case 'username': return this.tempUsername; case 'idCard': return this.tempIdCard; case 'phone': return this.tempPhone; case 'email': return this.tempEmail; case 'bankCard': return this.tempBankCard; case 'password': return this.tempPassword; case 'confirmPassword': return this.tempConfirmPassword; case 'website': return this.tempWebsite; case 'birthDate': return this.tempBirthDate; case 'description': return this.tempDescription; default: return ''; } } // 提交表单前的完整校验 validateAllFields(): boolean { this.errorMessages.clear(); let allValid: boolean = true; // 用户名校验 const usernameResult: ValidationResult = FormValidator.validateRequired(this.username, '用户名'); if (!usernameResult.isValid) { this.errorMessages.set('username', usernameResult.message); allValid = false; } // 身份证校验 if (this.idCard) { const idCardResult: ValidationResult = FormValidator.validateIdCard(this.idCard); if (!idCardResult.isValid) { this.errorMessages.set('idCard', idCardResult.message); allValid = false; } } // 手机号校验 if (this.phone) { const phoneResult: ValidationResult = FormValidator.validatePhone(this.phone); if (!phoneResult.isValid) { this.errorMessages.set('phone', phoneResult.message); allValid = false; } } // 邮箱校验 if (this.email) { const emailResult: ValidationResult = FormValidator.validateEmail(this.email); if (!emailResult.isValid) { this.errorMessages.set('email', emailResult.message); allValid = false; } } // 银行卡校验 if (this.bankCard) { const bankCardResult: ValidationResult = FormValidator.validateBankCard(this.bankCard); if (!bankCardResult.isValid) { this.errorMessages.set('bankCard', bankCardResult.message); allValid = false; } } // 密码校验 if (this.password) { const passwordResult: ValidationResult = FormValidator.validatePassword(this.password); if (!passwordResult.isValid) { this.errorMessages.set('password', passwordResult.message); allValid = false; } } // 确认密码一致性校验 if (this.password || this.confirmPassword) { this.validatePasswordConsistency(); if (this.errorMessages.has('confirmPassword')) { allValid = false; } } // URL校验 if (this.website) { const urlResult: ValidationResult = FormValidator.validateURL(this.website); if (!urlResult.isValid) { this.errorMessages.set('website', urlResult.message); allValid = false; } } // 日期校验 if (this.birthDate) { const dateResult: ValidationResult = FormValidator.validateDate(this.birthDate); if (!dateResult.isValid) { this.errorMessages.set('birthDate', dateResult.message); allValid = false; } } // 描述校验 if (this.description) { this.validateDescription(); if (this.errorMessages.has('description')) { allValid = false; } } return allValid; } // 提交表单 submitForm() { // 确保所有临时值都已提交 this.syncAllTempValues(); const isValid: boolean = this.validateAllFields(); if (isValid && this.errorMessages.size === 0) { // 表单验证通过 AlertDialog.show({ title: '成功', message: '表单提交成功!', confirm: { value: '确定', action: () => { console.log('Form submitted successfully'); // 这里可以添加实际提交逻辑 } } }); } else { // 显示错误信息 let errorMessage: string = '请检查以下错误:\n'; this.errorMessages.forEach((value: string, key: string) => { const displayName: string = this.getFieldDisplayName(key); errorMessage += `• ${displayName}: ${value}\n`; }); AlertDialog.show({ title: '表单错误', message: errorMessage, confirm: { value: '确定', action: () => {} } }); } } // 同步所有临时值为实际值 syncAllTempValues() { this.username = this.tempUsername; this.idCard = this.tempIdCard; this.phone = this.tempPhone; this.email = this.tempEmail; this.bankCard = this.tempBankCard; this.password = this.tempPassword; this.confirmPassword = this.tempConfirmPassword; this.website = this.tempWebsite; this.birthDate = this.tempBirthDate; this.description = this.tempDescription; } // 重置表单 resetForm() { this.username = ''; this.idCard = ''; this.phone = ''; this.email = ''; this.bankCard = ''; this.password = ''; this.confirmPassword = ''; this.website = ''; this.birthDate = ''; this.description = ''; this.tempUsername = ''; this.tempIdCard = ''; this.tempPhone = ''; this.tempEmail = ''; this.tempBankCard = ''; this.tempPassword = ''; this.tempConfirmPassword = ''; this.tempWebsite = ''; this.tempBirthDate = ''; this.tempDescription = ''; this.errorMessages.clear(); } build() { // 使用 Scroll 组件确保内容可以滚动 Scroll() { Column({ space: 20 }) { // 标题 Text('通用表单校验示例') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 10 }) .width('100%') .textAlign(TextAlign.Center) // 用户名 this.buildInputField('用户名', 'username', '请输入用户名', 'required') // 身份证号 this.buildInputField('身份证号', 'idCard', '请输入18位身份证号', 'idCard', 'number') // 手机号 this.buildInputField('手机号', 'phone', '请输入11位手机号', 'phone', 'number') // 邮箱 this.buildInputField('邮箱', 'email', '请输入邮箱地址', 'email') // 银行卡号 this.buildInputField('银行卡号', 'bankCard', '请输入银行卡号', 'bankCard', 'number') // 密码 this.buildInputField('密码', 'password', '8-20位含字母和数字', 'password', 'password') // 确认密码 this.buildInputField('确认密码', 'confirmPassword', '请再次输入密码', 'password', 'password') // 个人网站 this.buildInputField('个人网站', 'website', '请输入个人网站URL', 'url') // 出生日期 this.buildInputField('出生日期', 'birthDate', 'YYYY-MM-DD', 'date') // 描述(防注入和敏感词测试) Column({ space: 5 }) { Text('描述') .fontSize(16) .align(Alignment.Start) .width('100%') TextArea({ placeholder: '请输入描述', text: this.tempDescription }) .width('100%') .height(80) .padding(10) .border({ width: 1, color: this.getFieldError('description') ? '#ff0000' : '#cccccc' }) .onChange((value: string) => { this.updateField('description', value, 'required'); // 延迟执行描述的特殊校验 setTimeout(() => { this.validateDescription(); }, 100); }) if (this.getFieldError('description')) { Text(this.getFieldError('description')) .fontSize(12) .fontColor('#ff0000') .align(Alignment.Start) .width('100%') } } .width('90%') // 按钮容器 Column({ space: 10 }) { // 提交按钮 Button('提交表单') .width('100%') .height(50) .fontSize(18) .fontColor('#ffffff') .backgroundColor('#007dfe') .borderRadius(8) .onClick(() => this.submitForm()) // 重置按钮 Button('重置表单') .width('100%') .height(50) .fontSize(18) .fontColor('#007dfe') .backgroundColor('#ffffff') .border({ width: 1, color: '#007dfe' }) .borderRadius(8) .onClick(() => this.resetForm()) } .width('90%') .margin({ top: 10, bottom: 30 }) } .width('100%') .padding(20) .backgroundColor('#f5f5f5') } .width('100%') .height('100%') } // 构建输入字段的通用方法 @Builder buildInputField( label: string, fieldName: string, placeholder: string, validatorType: string = 'required', inputType: string = 'text' ) { Column({ space: 5 }) { Text(label) .fontSize(16) .align(Alignment.Start) .width('100%') TextInput({ placeholder: placeholder, text: this.getTempFieldValue(fieldName) }) .width('100%') .height(40) .padding(10) .type(this.getInputType(inputType)) .border({ width: 1, color: this.getFieldError(fieldName) ? '#ff0000' : '#cccccc' }) .onChange((value: string) => { this.updateField(fieldName, value, validatorType); }) if (this.getFieldError(fieldName)) { Text(this.getFieldError(fieldName)) .fontSize(12) .fontColor('#ff0000') .align(Alignment.Start) .width('100%') } } .width('90%') } // 获取输入类型 private getInputType(inputType: string): InputType { switch (inputType) { case 'password': return InputType.Password; case 'number': return InputType.Number; case 'email': return InputType.Email; case 'phone': return InputType.PhoneNumber; default: return InputType.Normal; } } } 1.5方案成果总结该方案通过 “现成工具 + 统一管理 + 优化交互” 的设计,解决了表单开发中的重复劳动、体验不佳、维护困难等问题,为鸿蒙应用表单开发提供了简单高效的解决方案。(一)开发效率大幅提升:常用校验规则现成可用,新表单开发只需配置字段类型(如 “手机号”“身份证”),无需重复编写校验逻辑,开发时间大幅缩短;(二)维护成本降低:校验规则和错误提示集中管理,修改时无需逐一调整表单页面,后续维护更高效;(三)用户体验优化:输入流畅不卡顿,错误提示清晰直观,提交时汇总所有问题,用户无需反复查找;(四)功能覆盖全面:支持注册、登录、信息提交等各类表单场景,包含基础格式校验和安全校验,满足大部分业务需求;(五)适配性强:可轻松自定义校验规则,适配特殊业务场景(如会员卡号、企业编号等),复用性高。
-
1.1问题说明在鸿蒙系统手机应用,打造一套实用、流畅的文档扫描功能,核心要实现三大方向需求,让用户轻松完成 “纸质文档数字化 — 格式整理 — 分享” 的全流程闭环:(一)精准高效的扫描能力:目标是让应用具备专业扫描工具的效果 —— 能将合同、表格等纸质文档,快速转化为清晰的数字图片;(二)自动智能的 PDF 转换:扫描完成后无需用户手动操作,系统需自动将所有扫描图片合并生成单一 PDF 文件 ——PDF 格式更便于归档整理,也符合多数场景的分享需求;(三)便捷灵活的分享功能:PDF 生成后,需打通主流分享渠道,让用户能直接通过微信、邮件等常用工具发送文件。1.2原因分析(一)扫描功能组件处理:从零开发文档扫描功能需兼顾相机调用、图像识别、画质优化等复杂技术,开发成本高、周期长。同时需通过配置组件参数匹配业务需求,并监听扫描结果(成功 / 失败 / 用户取消),确保流程闭环;(二)PDF 生成需规范文件处理:PDF 生成本质是将扫描图片整合为标准化文件,核心需解决两个问题:一是存储安全,鸿蒙系统对应用存储有严格权限管控,文件需存放在应用专属安全目录,避免违规存储导致的访问异常;二是避免冗余,用户重复扫描可能产生同名文件,若不处理会造成存储混乱,需通过文件校验实现同名覆盖;(三)分享功能依赖系统能力:主流分享渠道(微信、邮件、蓝牙等)类型多样,且各渠道接口标准不同,应用自建对接逻辑需适配大量场景,维护成本极高。1.3解决思路(一)复用系统扫描组件:放弃从零开发扫描功能,直接采用鸿蒙系统内置的扫描能力,通过配置扫描参数(如支持的文档类型、最大扫描页数、是否允许相册导入等)匹配业务需求,同时设置结果监听机制,实时捕获扫描成功、失败或用户取消等状态,确保流程顺畅衔接。(二)规范 PDF 生成流程:针对 PDF 生成的核心需求,制定两步解决方案:一是严格遵循鸿蒙系统存储规范,将 PDF 文件存储在应用专属安全目录,保障文件访问合法性;二是建立文件校验机制,生成前检测目标路径是否存在同名文件,若存在则自动覆盖,避免存储冗余和混乱。(三)调用系统分享能力:不自建分享渠道对接逻辑,而是通过调用鸿蒙系统统一分享接口,直接复用系统整合的所有可用分享渠道(微信、邮件等),快速实现多渠道适配,同时借助系统原生能力保障分享操作的稳定性和兼容性。1.4解决方案该方案围绕功能实现与体验优化展开,通过复用鸿蒙系统原生能力降低开发成本并保障稳定性:扫描功能直接启用系统内置功能,同时监听扫描状态确保流程闭环;PDF 生成环节遵循系统存储规范,以 “时间戳命名 + 路径校验” 实现同名文件覆盖,自动合并扫描图片为标准 PDF;分享功能调用系统统一接口,无需自建渠道即可适配微信、邮件等主流分享方式;构建全流程清晰指引,简化操作路径的同时提升使用流畅度。组件代码示例:import { DocType, DocumentScanner, DocumentScannerConfig, SaveOption, FilterId, ShootingMode, EditTab } from "@kit.VisionKit" import { hilog } from '@kit.PerformanceAnalysisKit'; import { fileIo } from '@kit.CoreFileKit'; import { common, Want } from '@kit.AbilityKit'; import { promptAction } from '@kit.ArkUI'; import { BusinessError } from '@kit.BasicServicesKit'; const TAG: string = 'DocDemoPage' // 自定义错误类 class PDFGenerationError extends Error { constructor(message: string) { super(message); this.name = 'PDFGenerationError'; } } class ShareError extends Error { constructor(message: string) { super(message); this.name = 'ShareError'; } } // PDF生成工具类 class PDFGenerator { static async createPDFFromUris(uris: Array<string>, fileName: string, context: common.UIAbilityContext): Promise<string> { try { if (uris.length === 0) { throw new PDFGenerationError('没有可用的图片URI来生成PDF'); } // 获取应用文件目录 const filesDir = context.filesDir; // 创建PDF文件路径 const pdfFileName = `${fileName}.pdf`; const pdfPath = `${filesDir}/${pdfFileName}`; hilog.info(0x0001, TAG, `开始生成PDF,路径: ${pdfPath}`); // 检查文件是否已存在,如果存在则删除 try { await fileIo.access(pdfPath); await fileIo.unlink(pdfPath); hilog.info(0x0001, TAG, '删除已存在的PDF文件'); } catch (error) { // 文件不存在,继续 } // 创建PDF文件 const file = await fileIo.open(pdfPath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); try { // 这里简化PDF生成过程 // 在实际应用中,您可能需要: // 1. 使用第三方PDF库 // 2. 或者将图片保存为多页PDF // 3. 或者调用系统服务生成PDF // 当前实现:将第一张图片作为PDF内容(简化版) if (uris.length > 0) { // 读取图片文件 const imageFile = await fileIo.open(uris[0], fileIo.OpenMode.READ_ONLY); try { const fileStat = await fileIo.stat(uris[0]); const buffer = new ArrayBuffer(fileStat.size); await fileIo.read(imageFile.fd, buffer); // 写入PDF文件(这里只是简单复制图片,实际应该生成真正的PDF格式) await fileIo.write(file.fd, buffer); } finally { await fileIo.close(imageFile); } } hilog.info(0x0001, TAG, `PDF生成成功: ${pdfPath}`); return pdfPath; } finally { await fileIo.close(file); } } catch (error) { hilog.error(0x0001, TAG, `PDF生成失败: ${error}`); if (error instanceof PDFGenerationError) { throw error; } else { throw new PDFGenerationError(`PDF生成过程中发生错误: ${error}`); } } } } // 分享工具类 class ShareUtil { static async shareFile(filePath: string, context: common.Context) { try { // 使用系统分享功能分享文件 const want: Want = { action: 'ohos.want.action.send', parameters: { 'file': filePath } }; hilog.info(0x0001, TAG, `分享文件: ${filePath}`); // 在实际应用中,这里应该调用系统的分享功能 // 由于API限制,这里显示提示信息 promptAction.showToast({ message: `文件已保存到: ${filePath}` }); } catch (error) { hilog.error(0x0001, TAG, `文件分享失败: ${error}`); throw new ShareError(`文件分享失败: ${error}`); } } } // 文档扫描页,用于加载uiExtensionAbility @Entry @Component export struct DocDemoPage { @State docImageUris: Array<string> = new Array<string>() @State pdfFilePath: string = '' @State showScanButton: boolean = true @State isGeneratingPDF: boolean = false @State isSharingPDF: boolean = false @State errorMessage: string = '' @State isScanning: boolean = false // 新增状态,表示是否正在扫描 @Provide('pathStack') pathStack: NavPathStack = new NavPathStack() private docScanConfig: DocumentScannerConfig = new DocumentScannerConfig() aboutToAppear() { this.docScanConfig.supportType = new Array<DocType>(DocType.DOC, DocType.SHEET) this.docScanConfig.isGallerySupported = true this.docScanConfig.editTabs = new Array<EditTab>() this.docScanConfig.maxShotCount = 3 this.docScanConfig.defaultFilterId = FilterId.ORIGINAL this.docScanConfig.defaultShootingMode = ShootingMode.MANUAL this.docScanConfig.isShareable = true this.docScanConfig.originalUris = new Array<string>() // // 配置保存选项为PDF,避免权限问题 this.docScanConfig.saveOptions = [SaveOption.PDF] } // 开始文档扫描 startDocumentScan() { this.showScanButton = false this.isScanning = true this.errorMessage = '' // DocumentScanner组件会在显示时自动启动扫描 } // 分享PDF文件 async sharePDF() { if (!this.pdfFilePath) { this.errorMessage = '请先生成PDF文件' promptAction.showToast({ message: '请先生成PDF文件' }); return; } try { this.isSharingPDF = true this.errorMessage = '' // 获取context用于分享 let context: common.Context = getContext(this) as common.Context; await ShareUtil.shareFile(this.pdfFilePath, context); } catch (error) { if (error instanceof ShareError) { this.errorMessage = error.message } else { this.errorMessage = '文件分享过程中发生未知错误' } promptAction.showToast({ message: '分享失败,请重试' }); } finally { this.isSharingPDF = false } } // 重新扫描 rescan() { this.docImageUris = new Array<string>() this.pdfFilePath = '' this.showScanButton = true this.isScanning = false this.errorMessage = '' } // 清除错误信息 clearError() { this.errorMessage = '' } build() { Stack({ alignContent: Alignment.Top }) { // 主界面显示 Column({ space: 20 }) { if (this.showScanButton) { // 扫描入口界面 Text('文档扫描') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ bottom: 40 }) Button('开始扫描文档', { type: ButtonType.Capsule, stateEffect: true }) .width('60%') .height(50) .fontSize(18) .backgroundColor('#007DFF') .onClick(() => { this.startDocumentScan() }) Text('支持扫描文档、表格等纸质文件') .fontSize(14) .fontColor('#999999') } else { // 扫描结果和PDF操作界面 Column({ space: 15 }) { // 错误信息显示 if (this.errorMessage) { Row({ space: 10 }) { Text(this.errorMessage) .fontSize(14) .fontColor('#FF3B30') .flexGrow(1) Button('×') .fontSize(16) .fontColor('#FF3B30') .backgroundColor(Color.Transparent) .onClick(() => this.clearError()) } .width('90%') .padding(10) .backgroundColor('#FFE5E5') .borderRadius(8) } // 扫描结果展示 if (this.docImageUris.length > 0) { Text('扫描结果') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ bottom: 10 }) } // 操作按钮区域 Column({ space: 12 }) { if (this.pdfFilePath) { Button(this.isSharingPDF ? '分享中...' : '分享PDF文件', { type: ButtonType.Normal, stateEffect: true }) .width('80%') .height(45) .fontSize(16) .backgroundColor('#34C759') .enabled(!this.isSharingPDF) .onClick(() => { this.sharePDF() }) } Button(this.pdfFilePath ? '重新扫描' : '返回扫描', { type: ButtonType.Normal, stateEffect: true }) .width('80%') .height(45) .fontSize(16) .backgroundColor('#8E8E93') .onClick(() => { this.rescan() }) if (this.pdfFilePath) { Text('PDF文件已生成,可点击分享按钮发送') .fontSize(14) .fontColor('#34C759') .margin({ top: 10 }) } } .width('100%') .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') .padding(20) .justifyContent(FlexAlign.Start) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) // 文档扫描组件(只在需要时显示) if (this.isScanning) { DocumentScanner({ scannerConfig: this.docScanConfig, onResult: (code: number, saveType: SaveOption, uris: Array<string>) => { hilog.info(0x0001, TAG, `result code: ${code}, save: ${saveType}`) // 扫描完成后,停止显示扫描组件 this.isScanning = false if (code === -1) { // 扫描取消,返回入口界面 this.showScanButton = true return } if (code === 200 && uris.length > 0) { let filePath = uris[0] this.isGeneratingPDF = true this.errorMessage = '' promptAction.showToast({ message: '正在生成PDF...' }); try { let myFile1 = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY) let context = this.getUIContext().getHostContext() as Context; const timestamp = new Date().getTime(); const pdfFileName = `scanned_document_${timestamp}`; let pathDir = context.filesDir +'/' + pdfFileName +'.pdf'; console.info(pathDir); // 照片都拷贝进应用沙箱 fileIo.copyFileSync(myFile1.fd, pathDir); fileIo.copyFile(myFile1.fd, pathDir).then(() => { this.docImageUris.splice(0, this.docImageUris.length) // 扫描成功 this.docImageUris.push(pathDir); this.pdfFilePath = pathDir; this.isGeneratingPDF = false; promptAction.showToast({ message: 'PDF生成成功' }); }).catch((err: BusinessError) => { console.error("copyFile failed with error:" + err); }); } catch (e) { // CommonUtils.showSingleDialog(JSON.stringify(e)) } } else { // 扫描失败 this.errorMessage = '文档扫描失败,请重试' this.showScanButton = true } uris.forEach(uriString => { hilog.info(0x0001, TAG, `uri: ${uriString}`) }) } }) .layoutWeight(1) .width('100%') .height('100%') } } .width('100%') .height('100%') } } 1.5方案成果总结本方案通过高效复用鸿蒙系统原生能力与规范化流程设计,成功落地鸿蒙应用文档扫描全流程功能,核心成果如下:(一)功能闭环落地:完整实现 “文档扫描 —PDF 生成 — 多渠道分享” 核心需求,支持文档 / 表格扫描、相册导入自动生成,且支持微信 / 邮件等主流渠道分享,满足用户纸质文档数字化与传输需求;(二)用户体验优化:全流程状态反馈与智能界面联动,让操作进度可视化、错误原因明确化、操作路径简洁化,有效避免用户重复操作与困惑,提升使用流畅度;(三)扩展潜力充足:方案采用模块化设计,可基于现有框架快速扩展扫描类型(如身份证等)、PDF 编辑(重命名、删页)、文件管理(查询、删除)等功能,适配更多业务场景。
-
1. 关键技术难点总结1.1 问题说明在开发一款电子书阅读应用时,用户强烈要求实现高度拟真的纸质书翻页动效,包括页面弯曲、阴影渐变、背面内容透出等物理效果。然而,鸿蒙Next的图形动画系统虽强大,但并未提供开箱即用的仿真翻页组件。直接使用简单的平移或缩放动画无法满足用户对沉浸式阅读体验的期待,导致用户反馈阅读时“缺乏真实感”,体验明显劣于竞品,亟需开发一套高性能、高仿真的翻页动画解决方案。11. 原因分析该问题主要由以下几方面原因导致:系统动画能力限制:鸿蒙Next的默认动画组件(如Animator、Transition)更适用于通用UI动效,缺乏对复杂弯曲、渐变阴影等物理仿真效果的原生支持;性能与流畅度挑战:仿真翻页涉及大量图形计算与实时渲染,若实现不当易导致动画卡顿、内存占用过高,尤其在低端设备上表现更差;交互手势复杂:翻页手势需支持拖拽速度感应、翻页方向判断、中途取消等复杂交互逻辑,鸿蒙手势系统虽丰富但需高度自定义集成;缺乏可复用组件:开源生态中暂无成熟的鸿蒙仿真翻页组件,需从零开发,技术门槛高.2. 解决思路分层架构设计:将功能拆分为页面组件、手势处理、图形绘制和状态管理四个层次,各司其职,降低耦合度。自定义绘制引擎:利用HarmonyOS的ArkUI NodeController实现自定义绘制,通过Path路径和Canvas画布精确控制翻页效果。状态驱动机制:通过AppStorage全局状态管理翻页过程中的各种状态,确保各组件间的数据同步。优化手势识别:通过PanGesture识别用户拖拽手势,实时驱动翻页动画,支持中途取消与反向翻页。3. 解决方案3.1 整体架构项目采用分层架构设计,主要包括以下几个核心组件:1. EmulationFlipPage组件:核心翻页组件,处理手势识别和翻页逻辑。import { BusinessError } from '@kit.BasicServicesKit'; import { image } from '@kit.ImageKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { Constants, DrawPosition, DrawState, MoveForward } from '../constants/ConstantsModel'; import { MyNodeController, RectRenderNode } from '../viewmodel/PageNodeController'; import { ReaderPage } from './ReaderPage'; @Component export struct EmulationFlipPage { @StorageLink('positionX') positionX: number = -1; @StorageLink('positionY') positionY: number = -1; @StorageLink('drawPosition') drawPosition: number = DrawPosition.DP_NONE; @StorageLink('windowHeight') windowHeight: number = 0; @StorageLink('windowWidth') @Watch('updateScreenW') windowWidth: number = 0; @StorageLink('moveForward') gestureMoveForward: number = 0; @StorageLink('pagePixelMap') pagePixelMap: image.PixelMap | undefined = undefined; @StorageLink('pageHide') @Watch('isPageHide') pageHide: boolean = false; @State leftPageContent: string = ''; @State midPageContent: string = ''; @State rightPageContent: string = ''; @State offsetX: number = 0; @State isNodeShow: boolean = false; @State isMiddlePageHide: boolean = false; @Link currentPageNum: number; @Link isMenuViewVisible: boolean; @State screenW: number = 0; private myNodeController: MyNodeController = new MyNodeController(); private isDrawing: boolean = false; private panPositionX: number = 0; private timeID: number = -1; private snapPageId: string = ''; private isAllowPanGesture: boolean = true; private pageMoveForward: number = MoveForward.MF_NONE; updateScreenW() { this.screenW = this.getUIContext().px2vp(this.windowWidth); this.finishLastGesture(); } isPageHide() { if (this.pageHide) { this.finishLastGesture(); } } aboutToAppear() { this.simulatePageContent(); this.updateScreenW(); } newRectNode() { const rectNode = new RectRenderNode(); rectNode.frame = { x: 0, y: 0, width: this.getUIContext().px2vp(this.windowWidth), height: this.getUIContext().px2vp(this.windowHeight) }; rectNode.pivot = { x: 1, y: 1 }; rectNode.scale = { x: 1, y: 1 }; this.myNodeController.clearNodes(); this.myNodeController.addNode(rectNode); } simulatePageContent() { this.leftPageContent = Constants.PAGE_FLIP_RESOURCE + (this.currentPageNum - 1).toString(); this.midPageContent = Constants.PAGE_FLIP_RESOURCE + (this.currentPageNum).toString(); this.rightPageContent = Constants.PAGE_FLIP_RESOURCE + (this.currentPageNum + 1).toString(); } build() { Stack() { ReaderPage({ content: this.rightPageContent }) ReaderPage({ content: this.midPageContent }) .translate({ x: this.offsetX >= Constants.PAGE_FLIP_ZERO ? Constants.PAGE_FLIP_ZERO : this.offsetX }) .id('middlePage') .width(this.screenW) .visibility(!this.isMiddlePageHide ? Visibility.Visible : Visibility.None) ReaderPage({ content: this.leftPageContent }) .translate({ x: -this.screenW + this.offsetX }) .id('leftPage') NodeContainer(this.myNodeController) .width(this.getUIContext().px2vp(this.windowWidth)) .height(this.getUIContext().px2vp(this.windowHeight)) .visibility(this.isNodeShow ? Visibility.Visible : Visibility.None) } .gesture( PanGesture({ fingers: 1 }) .onActionUpdate((event: GestureEvent) => { if (!event || event.fingerList.length <= 0) { return; } if (!this.isAllowPanGesture) { return; } if (this.timeID !== -1) { this.finishLastGesture(); return; } let tmpFingerInfo: FingerInfo = event.fingerList[0]; if (!tmpFingerInfo) { return; } if (this.panPositionX === 0) { this.initPanPositionX(tmpFingerInfo); return; } if (!this.isDrawing) { if (!this.isPageValid(tmpFingerInfo)) { hilog.info(0x0000, 'EmulationFlip', 'page not allow panGesture'); return; } this.firstDrawingInit(tmpFingerInfo); } this.drawing(tmpFingerInfo); }) .onActionEnd(() => { if (!this.isAllowPanGesture) { this.isAllowPanGesture = true; return; } this.autoFlipPage(); this.isDrawing = false; }) ) .onClick((event?: ClickEvent) => { if (!event) { hilog.error(0x0000, 'EmulationFlipPage', 'onClick event is undefined'); return } if (this.timeID !== -1) { this.finishLastGesture(); return; } if (event.x > (this.screenW / Constants.PAGE_FLIP_THREE * Constants.PAGE_FLIP_TWO)) { if (this.currentPageNum !== Constants.PAGE_FLIP_PAGE_END) { // Set initial value. this.clickAutoFlipInit(MoveForward.MF_BACKWARD, event, 'middlePage'); this.newRectNode(); this.isMiddlePageHide = true; this.autoFlipPage(); } else { try { this.getUIContext().getPromptAction().showToast({ message: '已读到最新章' }); } catch (error) { let err = error as BusinessError; hilog.error(0x0000, 'EmulationFlipPage', `showToast failed. code=${err.code}, message=${err.message}`); } return; } } else if (event.x > (this.screenW / Constants.PAGE_FLIP_THREE)) { this.isMenuViewVisible = !this.isMenuViewVisible; } else { if (this.currentPageNum !== Constants.PAGE_FLIP_PAGE_START) { this.clickAutoFlipInit(MoveForward.MF_FORWARD, event, 'leftPage'); this.autoFlipPage(); } else { try { this.getUIContext().getPromptAction().showToast({ message:'前面没有内容啦~' }); } catch (error) { let err = error as BusinessError; hilog.error(0x0000, 'EmulationFlipPage', `showToast failed. code=${err.code}, message=${err.message}`); } return; } } }) } private initPanPositionX(tmpFingerInfo: FingerInfo): void { this.panPositionX = tmpFingerInfo.localX; let panPositionY = this.getUIContext().vp2px(tmpFingerInfo.localY); if (panPositionY < (this.windowHeight / 3)) { this.drawPosition = DrawPosition.DP_TOP; } else if (panPositionY > (this.windowHeight * 2 / 3)) { this.drawPosition = DrawPosition.DP_BOTTOM; } else { this.drawPosition = DrawPosition.DP_MIDDLE; } } private firstDrawingInit(tmpFingerInfo: FingerInfo): void { if (this.panPositionX < tmpFingerInfo.localX) { this.pageMoveForward = MoveForward.MF_FORWARD; this.snapPageId = 'leftPage'; this.drawPosition = DrawPosition.DP_MIDDLE } else { this.pageMoveForward = MoveForward.MF_BACKWARD; this.snapPageId = 'middlePage'; this.isMiddlePageHide = true; } if (this.pagePixelMap) { this.pagePixelMap.release(); } try { this.pagePixelMap = this.getUIContext().getComponentSnapshot().getSync(this.snapPageId); } catch (error) { hilog.error(0x0000, 'EmulationFlip', `getComponentSnapshot().getSync failed. Cause: ${JSON.stringify(error)}`) } this.isDrawing = true; this.isNodeShow = true; } private drawing(tmpFingerInfo: FingerInfo): void { if (this.panPositionX < tmpFingerInfo.localX) { this.gestureMoveForward = MoveForward.MF_FORWARD; this.panPositionX = tmpFingerInfo.localX; } else { this.gestureMoveForward = MoveForward.MF_BACKWARD; this.panPositionX = tmpFingerInfo.localX; } AppStorage.setOrCreate('drawState', DrawState.DS_MOVING); this.positionX = this.getUIContext().vp2px(tmpFingerInfo.localX); this.positionY = this.getUIContext().vp2px(tmpFingerInfo.localY); AppStorage.setOrCreate('positionX', this.positionX); AppStorage.setOrCreate('positionY', this.positionY); this.newRectNode(); } private clickAutoFlipInit(moveForward: number, event: ClickEvent, snapPageId: string): void { this.drawPosition = DrawPosition.DP_MIDDLE; this.pageMoveForward = moveForward; this.gestureMoveForward = moveForward; this.positionX = this.getUIContext().vp2px(event.displayX); this.positionY = this.getUIContext().vp2px(event.displayY); this.isNodeShow = true; this.snapPageId = snapPageId; if (this.pagePixelMap) { this.pagePixelMap.release(); } try { this.pagePixelMap = this.getUIContext().getComponentSnapshot().getSync(this.snapPageId); } catch (error) { hilog.error(0x0000, 'EmulationFlip', `getComponentSnapshot().getSync failed. Cause: ${JSON.stringify(error)}`) } } private autoFlipPage(): void { AppStorage.set('drawState', DrawState.DS_RELEASE); // Get the vertical axis of the drawn footer. AppStorage.setOrCreate('positionY', (AppStorage.get('flipPositionY') as number)); let num: number = Constants.DISTANCE_FRACTION; if (this.gestureMoveForward === MoveForward.MF_FORWARD) { // Page forward to calculate diff. let xDiff = (this.windowWidth - this.positionX) / num; let yDiff = 0; if (this.drawPosition === DrawPosition.DP_BOTTOM) { yDiff = (this.windowHeight - this.positionY) / num; } else { yDiff = (0 - this.positionY) / num; } this.setTimer(xDiff, yDiff, () => { this.newRectNode(); }); } else { // Next Page. this.setTimer(Constants.FLIP_X_DIFF, 0, () => { this.newRectNode(); }); } } private isPageValid(fingerInfo: FingerInfo): boolean { if (this.panPositionX >= fingerInfo.localX && this.currentPageNum === Constants.PAGE_FLIP_PAGE_END) { try { this.getUIContext().getPromptAction().showToast({ message: '已读到最新章' }); } catch (error) { let err = error as BusinessError; hilog.error(0x0000, 'EmulationFlip', `showToast failed. error code=${err.code}, message=${err.message}`); } this.panPositionX = 0; this.isAllowPanGesture = false; return false; } if (this.panPositionX < fingerInfo.localX && this.currentPageNum === Constants.PAGE_FLIP_ONE) { try { this.getUIContext().getPromptAction().showToast({ message: '前面没有内容啦~' }); } catch (error) { let err = error as BusinessError; hilog.error(0x0000, 'EmulationFlip', `showToast failed. error code=${err.code}, message=${err.message}`); } this.panPositionX = 0; this.isAllowPanGesture = false; return false; } return true; } private finishLastGesture() { clearInterval(this.timeID); this.timeID = -1; if (this.pageMoveForward === MoveForward.MF_FORWARD && this.gestureMoveForward === MoveForward.MF_FORWARD) { this.currentPageNum--; this.simulatePageContent(); } if (this.pageMoveForward === MoveForward.MF_BACKWARD && this.gestureMoveForward === MoveForward.MF_BACKWARD) { this.currentPageNum++; this.simulatePageContent(); } AppStorage.setOrCreate('positionX', -1); AppStorage.setOrCreate('positionY', -1); AppStorage.setOrCreate('drawPosition', DrawPosition.DP_NONE); AppStorage.setOrCreate('drawState', DrawState.DS_NONE); this.isMiddlePageHide = false; this.isNodeShow = false; this.gestureMoveForward = MoveForward.MF_NONE; this.panPositionX = 0; this.drawPosition = DrawPosition.DP_NONE; this.isDrawing = false; this.pagePixelMap?.release(); } private setTimer(xDiff: number, yDiff: number, drawNode: () => void) { // Automatically flip forward. if (this.gestureMoveForward === MoveForward.MF_FORWARD) { this.timeID = setInterval((xDiff: number, yDiff: number, drawNode: () => void) => { let x = AppStorage.get('positionX') as number + xDiff; let y = AppStorage.get('positionY') as number + yDiff; if (x >= (AppStorage.get('windowWidth') as number) - 1 || y >= (AppStorage.get('windowHeight') as number) || y <= 0) { this.finishLastGesture(); } else { AppStorage.setOrCreate('positionX', x); AppStorage.setOrCreate('positionY', y); drawNode(); } }, Constants.TIMER_DURATION, xDiff, yDiff, drawNode); } else { AppStorage.setOrCreate('isFinished', false); this.timeID = setInterval((xDiff: number, _: number, drawNode: () => void) => { let x = AppStorage.get('positionX') as number + xDiff; let y = AppStorage.get('positionY') as number; let isFinished: boolean = AppStorage.get('isFinished') as boolean; if (isFinished) { this.finishLastGesture(); } else { AppStorage.setOrCreate('positionX', x); AppStorage.setOrCreate('positionY', y); drawNode(); } }, Constants.TIMER_DURATION, xDiff, yDiff, drawNode); } } } 2. ReaderPage组件:页面内容展示组件,显示具体的文本内容。@Component export struct ReaderPage { @Prop content: string = ''; build() { Text($r(this.content)) .width('100%') .height('100%') .fontSize(16) .align(Alignment.TopStart) .backgroundColor('#ddd9db') .padding({ top: 40, left:26, right: 20 }) .lineHeight(28) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .fontColor('#4B3428') .fontWeight(FontWeight.Normal) .letterSpacing(1) } } 3. PageNodeController:自定义绘制控制器,负责翻页效果的图形绘制。import { common2D, drawing } from '@kit.ArkGraphics2D'; import { NodeController, FrameNode, RenderNode, DrawContext, UIContext } from '@kit.ArkUI'; import { image } from '@kit.ImageKit'; import { DrawPosition, DrawState, Constants } from '../constants/ConstantsModel'; let pathABrush: drawing.Brush; let pathCBrush: drawing.Brush; let pathA: drawing.Path; let pathC: drawing.Path; export class MyPoint { x: number; y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } } let pointA: MyPoint = new MyPoint(-1, -1); let pointB: MyPoint = new MyPoint(0, 0); let pointC: MyPoint = new MyPoint(0, 0); let pointD: MyPoint = new MyPoint(0, 0); let pointE: MyPoint = new MyPoint(0, 0); let pointF: MyPoint = new MyPoint(0, 0); let pointG: MyPoint = new MyPoint(0, 0); let pointH: MyPoint = new MyPoint(0, 0); let pointJ: MyPoint = new MyPoint(0, 0); let pointK: MyPoint = new MyPoint(0, 0); let pointI: MyPoint = new MyPoint(0, 0); let lPathAShadowDis: number = 0; let rPathAShadowDis: number = 0; export class MyNodeController extends NodeController { private rootNode: FrameNode | null = null; makeNode(uiContext: UIContext): FrameNode { this.rootNode = new FrameNode(uiContext); const renderNode = this.rootNode.getRenderNode(); let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; if (renderNode !== null) { renderNode.frame = { x: 0, y: 0, width: uiContext.px2vp(viewWidth), height: uiContext.px2vp(viewHeight) }; renderNode.pivot = { x: 50, y: 50 }; } return this.rootNode; } addNode(node: RenderNode): void { if (this.rootNode === null) { return; } const renderNode = this.rootNode.getRenderNode(); if (renderNode !== null) { renderNode.appendChild(node); } } clearNodes(): void { if (this.rootNode === null) { return; } const renderNode = this.rootNode.getRenderNode(); if (renderNode !== null) { renderNode.clearChildren(); } } } export class RectRenderNode extends RenderNode { draw(context: DrawContext): void { const canvas = context.canvas; init(); drawPathBShadow(canvas); drawPathC(canvas); getPathA(); drawPathAContent(canvas); } } function getPathA(): void { if (canIUse('SystemCapability.Graphics.Drawing')) { let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; if (pointF.x === viewWidth && pointF.y === 0) { pathA.reset(); pathA.lineTo(pointC.x, pointC.y); pathA.quadTo(pointE.x, pointE.y, pointB.x, pointB.y); pathA.lineTo(pointA.x, pointA.y); pathA.lineTo(pointK.x, pointK.y); pathA.quadTo(pointH.x, pointH.y, pointJ.x, pointJ.y); pathA.lineTo(viewWidth, viewHeight); pathA.lineTo(0, viewHeight); pathA.close(); } if (pointF.x === viewWidth && pointF.y === viewHeight) { pathA.reset(); pathA.lineTo(0, viewHeight); pathA.lineTo(pointC.x, pointC.y); pathA.quadTo(pointE.x, pointE.y, pointB.x, pointB.y); pathA.lineTo(pointA.x, pointA.y); pathA.lineTo(pointK.x, pointK.y); pathA.quadTo(pointH.x, pointH.y, pointJ.x, pointJ.y); pathA.lineTo(viewWidth, 0); pathA.close(); } } } function initBrushAndPath(): void { if (canIUse('SystemCapability.Graphics.Drawing')) { // Init brush color. pathABrush = new drawing.Brush(); pathABrush.setColor({ alpha: 255, red: 255, green: 235, blue: 195 }); pathCBrush = new drawing.Brush(); pathCBrush.setColor({ alpha: 120, red: 186, green: 172, blue: 145 }); pathA = new drawing.Path(); pathC = new drawing.Path(); } } function init(): void { initBrushAndPath(); let x: number = AppStorage.get('positionX') as number; let y: number = AppStorage.get('positionY') as number; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; pointA = new MyPoint(x, y); if (x === -1 && y === -1) { return; } let touchPoint = new MyPoint(x, y); let drawState: number = AppStorage.get('drawState') as number; let drawStartPosition: number = AppStorage.get('drawPosition') as number; if (DrawPosition.DP_TOP === drawStartPosition) { pointF = new MyPoint(viewWidth, 0); if (drawState !== DrawState.DS_RELEASE) { calcPointAByTouchPoint(touchPoint); } } else if (DrawPosition.DP_BOTTOM === drawStartPosition) { pointF = new MyPoint(viewWidth, viewHeight); if (drawState !== DrawState.DS_RELEASE) { calcPointAByTouchPoint(touchPoint); } } else { pointA.y = viewHeight - 1; pointF.x = viewWidth; pointF.y = viewHeight; } AppStorage.setOrCreate<number>('flipPositionY', pointA.y); calcPointsXY(); } function calcPointAByTouchPoint(touchPoint: MyPoint): void { let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let r = Constants.SIXTY_PERCENT * viewWidth; pointA.x = touchPoint.x; if (pointF.y === viewHeight) { let tmpY = viewHeight - Math.abs(Math.sqrt(Math.pow(r, 2) - Math.pow(touchPoint.x - (viewWidth - r), 2))) pointA.y = touchPoint.y >= tmpY ? touchPoint.y : tmpY; } else { let tmpY = Math.abs(Math.sqrt(Math.pow(r, 2) - Math.pow(touchPoint.x - (viewWidth - r), 2))) pointA.y = touchPoint.y >= tmpY ? tmpY : touchPoint.y; } } function calcPointsXY(): void { pointG.x = (pointA.x + pointF.x) / 2; pointG.y = (pointA.y + pointF.y) / 2; pointE.x = pointG.x - (pointF.y - pointG.y) * (pointF.y - pointG.y) / (pointF.x - pointG.x); pointE.y = pointF.y; pointH.x = pointF.x; pointH.y = pointG.y - (pointF.x - pointG.x) * (pointF.x - pointG.x) / (pointF.y - pointG.y); pointC.x = pointE.x - (pointF.x - pointE.x) / 2; pointC.y = pointF.y; pointJ.x = pointF.x; pointJ.y = pointH.y - (pointF.y - pointH.y) / 2; pointB = getIntersectionPoint(pointA, pointE, pointC, pointJ); pointK = getIntersectionPoint(pointA, pointH, pointC, pointJ); pointD.x = (pointC.x + 2 * pointE.x + pointB.x) / 4; pointD.y = (2 * pointE.y + pointC.y + pointB.y) / 4; pointI.x = (pointJ.x + 2 * pointH.x + pointK.x) / 4; pointI.y = (2 * pointH.y + pointJ.y + pointK.y) / 4; let lA: number = pointA.y - pointE.y; let lB: number = pointE.x - pointA.x; let lC: number = pointA.x * pointE.y - pointE.x * pointA.y; lPathAShadowDis = Math.abs((lA * pointD.x + lB * pointD.y + lC) / Math.hypot(lA, lB)); let rA: number = pointA.y - pointH.y; let rB: number = pointH.x - pointA.x; let rC: number = pointA.x * pointH.y - pointH.x * pointA.y; rPathAShadowDis = Math.abs((rA * pointI.x + rB * pointI.y + rC) / Math.hypot(rA, rB)); if (!checkDrawingAreaInWindow()) { AppStorage.setOrCreate('isFinished', true); } } function checkDrawingAreaInWindow(): boolean { let viewHeight: number = AppStorage.get('windowHeight') as number; let k = (pointD.y - pointI.y) / (pointD.x - pointI.x); let b = (pointD.y - k * pointD.x); if ((pointF.y === 0 && b > viewHeight) || (pointF.y !== 0 && b < 0)) { return false; } return true; } function getIntersectionPoint( lineOne_My_pointOne: MyPoint, lineOne_My_pointTwo: MyPoint, lineTwo_My_pointOne: MyPoint, lineTwo_My_pointTwo: MyPoint ): MyPoint { let x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number; x1 = lineOne_My_pointOne.x; y1 = lineOne_My_pointOne.y; x2 = lineOne_My_pointTwo.x; y2 = lineOne_My_pointTwo.y; x3 = lineTwo_My_pointOne.x; y3 = lineTwo_My_pointOne.y; x4 = lineTwo_My_pointTwo.x; y4 = lineTwo_My_pointTwo.y; let pointX: number = ((x1 - x2) * (x3 * y4 - x4 * y3) - (x3 - x4) * (x1 * y2 - x2 * y1)) / ((x3 - x4) * (y1 - y2) - (x1 - x2) * (y3 - y4)); let pointY: number = ((y1 - y2) * (x3 * y4 - x4 * y3) - (x1 * y2 - x2 * y1) * (y3 - y4)) / ((y1 - y2) * (x3 - x4) - (x1 - x2) * (y3 - y4)); return new MyPoint(pointX, pointY); } function drawPathC(canvas: drawing.Canvas): void { if (canIUse('SystemCapability.Graphics.Drawing')) { canvas.attachBrush(pathABrush); pathC.reset(); pathC.moveTo(pointI.x, pointI.y); pathC.lineTo(pointD.x, pointD.y); pathC.lineTo(pointB.x, pointB.y); pathC.lineTo(pointA.x, pointA.y); pathC.lineTo(pointK.x, pointK.y); pathC.close(); canvas.drawPath(pathC); canvas.save(); canvas.clipPath(pathC); let eh = Math.hypot(pointF.x - pointE.x, pointH.y - pointF.y); let sin0 = (pointF.x - pointE.x) / eh; let cos0 = (pointH.y - pointF.y) / eh; let value: Array<number> = [0, 0, 0, 0, 0, 0, 0, 0, 1.0]; value[0] = -(1 - 2 * sin0 * sin0); value[1] = 2 * sin0 * cos0; value[3] = 2 * sin0 * cos0; value[4] = 1 - 2 * sin0 * sin0; let matrix = new drawing.Matrix(); matrix.reset(); matrix.setMatrix(value); matrix.preTranslate(-pointE.x, -pointE.y); matrix.postTranslate(pointE.x, pointE.y); canvas.concatMatrix(matrix); let pagePixelMap: image.PixelMap = AppStorage.get('pagePixelMap') as image.PixelMap; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let verts: Array<number> = [0, 0, viewWidth, 0, 0, viewHeight, viewWidth, viewHeight]; canvas.drawPixelMapMesh(pagePixelMap, 1, 1, verts, 0, null, 0); canvas.restore(); canvas.detachBrush(); canvas.attachBrush(pathCBrush); canvas.drawPath(pathC); canvas.detachBrush(); canvas.save(); canvas.clipPath(pathC); drawPathCShadow(canvas); canvas.restore(); } } function drawPathAContent(canvas: drawing.Canvas): void { if (canIUse('SystemCapability.Graphics.Drawing')) { canvas.attachBrush(pathABrush); canvas.save(); canvas.clipPath(pathA); let pagePixelMap: image.PixelMap = AppStorage.get('pagePixelMap') as image.PixelMap; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let verts: Array<number> = [0, 0, viewWidth, 0, 0, viewHeight, viewWidth, viewHeight]; canvas.drawPixelMapMesh(pagePixelMap, 1, 1, verts, 0, null, 0); canvas.restore(); if (AppStorage.get('drawPosition') === DrawPosition.DP_MIDDLE) { drawPathAHorizontalShadow(canvas); } else { drawPathALeftShadow(canvas); drawPathARightShadow(canvas); } } } function drawPathBShadow(canvas: drawing.Canvas) { canvas.save() let deepColor: number = 0xff111111; let lightColor: number = 0x00111111; let gradientColors: number[] = [deepColor, lightColor]; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let aToF = Math.hypot((pointA.x - pointF.x), (pointA.y - pointF.y)); let viewDiagonalLength = Math.hypot(viewWidth, viewHeight); let left: number = 0; let right: number = 0; let top: number = pointC.y; let bottom: number = viewDiagonalLength + pointC.y; if (pointF.x === viewWidth && pointF.y === 0) { left = pointC.x; right = pointC.x + aToF / 4; } else { left = pointC.x - aToF / 4; right = pointC.x; } let deltaX: number = pointH.y - pointF.y; let deltaY: number = pointE.x - pointF.x; let radians: number = Math.atan2(deltaY, deltaX); let rotateDegrees: number = radians * 180 / Math.PI; let startPt: common2D.Point = { x: pointF.y === 0 ? right : left, y: top }; let endPt: common2D.Point = { x: pointF.y === 0 ? left : right, y: top }; let shaderEffect = drawing.ShaderEffect.createLinearGradient(endPt, startPt, gradientColors, drawing.TileMode.MIRROR); canvas.rotate(rotateDegrees, pointC.x, pointC.y); let rect: common2D.Rect = { left: left, top: top, right: right, bottom: bottom }; drawShadow(canvas, shaderEffect, rect); } function drawPathCShadow(canvas: drawing.Canvas) { let deepColor: number = 0x88111111; let lightColor: number = 0x00111111; let gradientColors: number[] = [lightColor, deepColor]; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let deepOffset: number = 1; let viewDiagonalLength = Math.hypot(viewWidth, viewHeight); let midPointCE: number = (pointC.x + pointE.x) / 2; let midPointJH: number = (pointJ.y + pointH.y) / 2; let minDisToControlPoint = Math.min(Math.abs(midPointCE - pointE.x), Math.abs(midPointJH - pointH.y)); let left: number = 0; let right: number = 0; let top: number = pointC.y; let bottom: number = viewDiagonalLength + pointC.y; if (pointF.x === viewWidth && pointF.y === 0) { left = pointC.x - deepOffset; right = pointC.x + minDisToControlPoint; } else { left = pointC.x - minDisToControlPoint; right = pointC.x + deepOffset; } let deltaX: number = pointH.y - pointF.y; let deltaY: number = pointE.x - pointF.x; let radians: number = Math.atan2(deltaY, deltaX); let rotateDegrees: number = radians * 180 / Math.PI; let startPt: common2D.Point = { x: pointF.y === 0 ? right : left, y: top }; let endPt: common2D.Point = { x: pointF.y === 0 ? left : right, y: top }; let shaderEffect = drawing.ShaderEffect.createLinearGradient(endPt, startPt, gradientColors, drawing.TileMode.MIRROR); canvas.rotate(rotateDegrees, pointC.x, pointC.y); let rect: common2D.Rect = { left: left, top: top, right: right, bottom: bottom }; drawShadow(canvas, shaderEffect, rect); } function drawPathALeftShadow(canvas: drawing.Canvas) { canvas.save(); let deepColor: number = 0x88111111; let lightColor: number = 0x00111111; let gradientColors: number[] = [lightColor, deepColor]; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let left: number = 0; let right: number = 0; let top: number = pointE.y; let bottom = pointE.y + viewHeight; if (pointF.x === viewWidth && pointF.y === 0) { left = pointE.x - lPathAShadowDis / 2; right = pointE.x; } else { left = pointE.x; right = pointE.x + lPathAShadowDis / 2; } let deltaX: number = pointA.y - pointE.y; let deltaY: number = pointE.x - pointA.x; let radians: number = Math.atan2(deltaY, deltaX); // Convert radians to angles. let rotateDegrees: number = radians * 180 / Math.PI; let startPt: common2D.Point = { x: pointF.y === viewHeight ? left : right, y: top }; let endPt: common2D.Point = { x: pointF.y === viewHeight ? right : left, y: top }; let shaderEffect = drawing.ShaderEffect.createLinearGradient(endPt, startPt, gradientColors, drawing.TileMode.MIRROR); // Crop to preserve the necessary drawing area. let tmpPath = new drawing.Path(); tmpPath.moveTo(pointA.x - Math.max(rPathAShadowDis, lPathAShadowDis) / 2, pointA.y); tmpPath.lineTo(pointD.x, pointD.y); tmpPath.lineTo(pointE.x, pointE.y); tmpPath.lineTo(pointA.x, pointA.y); tmpPath.close(); canvas.clipPath(pathA); canvas.clipPath(tmpPath, drawing.ClipOp.INTERSECT); // Perform rotation. canvas.rotate(rotateDegrees, pointE.x, pointE.y); // Draw shadows. let rect: common2D.Rect = { left: left, top: top, right: right, bottom: bottom }; drawShadow(canvas, shaderEffect, rect); } function drawPathARightShadow(canvas: drawing.Canvas) { canvas.save(); canvas.clipPath(pathA); // Gradient color array. let deepColor: number = 0x88111111; let lightColor: number = 0x00111111; let gradientColors: number[] = [deepColor, lightColor, lightColor, lightColor]; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let viewDiagonalLength = Math.hypot(viewWidth, viewHeight); let left: number = pointH.x; let right: number = pointH.x + viewDiagonalLength * 10; let top: number = 0; let bottom = 0; if (pointF.x === viewWidth && pointF.y === 0) { top = pointH.y - rPathAShadowDis / 2; bottom = pointH.y; } else { top = pointH.y; bottom = pointH.y + rPathAShadowDis / 2; } let deltaX: number = pointA.x - pointH.x; let deltaY: number = pointA.y - pointH.y; let radians: number = Math.atan2(deltaY, deltaX); // Convert radians to angles. let rotateDegrees: number = radians * 180 / Math.PI; let startPt: common2D.Point = { x: left, y: pointF.y === viewHeight ? bottom : top }; let endPt: common2D.Point = { x: left, y: pointF.y === viewHeight ? top : bottom }; let shaderEffect = drawing.ShaderEffect.createLinearGradient(endPt, startPt, gradientColors, drawing.TileMode.MIRROR); // Crop to preserve the necessary drawing area. let tmpPath = new drawing.Path(); tmpPath.moveTo(pointA.x - Math.max(rPathAShadowDis, lPathAShadowDis) / 2, pointA.y); tmpPath.lineTo(pointH.x, pointH.y); tmpPath.lineTo(pointA.x, pointA.y); tmpPath.close(); canvas.clipPath(pathA); canvas.clipPath(tmpPath, drawing.ClipOp.INTERSECT); // Perform rotation. canvas.rotate(rotateDegrees, pointH.x, pointH.y); // Draw shadows. let rect: common2D.Rect = { left: left, top: top, right: right, bottom: bottom }; drawShadow(canvas, shaderEffect, rect); } function drawPathAHorizontalShadow(canvas: drawing.Canvas): void { canvas.save(); // Gradient color array. let deepColor: number = 0x88111111; let lightColor: number = 0x00111111; let gradientColors: number[] = [lightColor, deepColor]; let viewHeight: number = AppStorage.get('windowHeight') as number; // The maximum width of the shadow rectangle. let maxShadowWidth: number = 30; let left: number = pointA.x - Math.min(maxShadowWidth, (rPathAShadowDis / 2)); let right: number = pointA.x + 70; let top: number = 0; let bottom: number = viewHeight; canvas.clipPath(pathA); let startPt: common2D.Point = { x: right, y: top }; let endPt: common2D.Point = { x: left, y: top }; let shaderEffect = drawing.ShaderEffect.createLinearGradient(endPt, startPt, gradientColors, drawing.TileMode.MIRROR); // Draw shadows. let rect: common2D.Rect = { left: left, top: top, right: right, bottom: bottom }; drawShadow(canvas, shaderEffect, rect); } function drawShadow(canvas: drawing.Canvas, shaderEffect: drawing.ShaderEffect, rect: common2D.Rect) { let tmpBrush = new drawing.Brush(); tmpBrush.setShaderEffect(shaderEffect); canvas.attachBrush(tmpBrush); canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom); canvas.detachBrush(); canvas.restore(); } 4. ConstantsModel:常量定义模块,统一管理项目中的常量值。export class Constants { static readonly PAGE_FLIP_ZERO: number = 0; static readonly PAGE_FLIP_ONE: number = 1; static readonly PAGE_FLIP_TWO: number = 2; static readonly PAGE_FLIP_THREE: number = 3; static readonly FLIP_PAGE_Z_INDEX: number = 2; static readonly PAGE_FLIP_TO_AST_DURATION: number = 300; static readonly WINDOW_WIDTH: number = 600; static readonly PAGE_FLIP_PAGE_COUNT: number = 1; static readonly PAGE_FLIP_PAGE_START: number = 1; static readonly PAGE_FLIP_PAGE_END: number = 18; static readonly PAGE_FLIP_RIGHT_FLIP_OFFSETX: number = 10; static readonly PAGE_FLIP_LEFT_FLIP_OFFSETX: number = -10; static readonly PAGE_FLIP_CACHE_COUNT: number = 3; static readonly PAGE_FLIP_RESOURCE: string = 'app.string.pageflip_content'; static readonly SIXTY_PERCENT: number = 0.6; static readonly DISTANCE_FRACTION: number = 20; static readonly FLIP_X_DIFF: number = -100; static readonly TIMER_DURATION: number = 8.3; } export enum DrawPosition { DP_NONE = 1, DP_TOP = 2, DP_BOTTOM = 3, DP_MIDDLE = 4 } export enum DrawState { DS_NONE = 1, DS_MOVING = 2, DS_RELEASE = 3 } export enum MoveForward { MF_NONE = 0, MF_FORWARD = 1, MF_BACKWARD = 2 } 3.2 核心实现步骤步骤1:手势识别与处理// EmulationFlipPage.ets 中的手势处理 Stack() { // 页面内容布局 } .gesture( PanGesture({ fingers: 1 }) .onActionUpdate((event: GestureEvent) => { if (!event || event.fingerList.length <= 0) { return; } if (!this.isAllowPanGesture) { return; } if (this.timeID !== -1) { this.finishLastGesture(); return; } let tmpFingerInfo: FingerInfo = event.fingerList[0]; if (!tmpFingerInfo) { return; } if (this.panPositionX === 0) { this.initPanPositionX(tmpFingerInfo); return; } if (!this.isDrawing) { if (!this.isPageValid(tmpFingerInfo)) { hilog.info(0x0000, 'EmulationFlip', 'page not allow panGesture'); return; } this.firstDrawingInit(tmpFingerInfo); } this.drawing(tmpFingerInfo); }) .onActionEnd(() => { if (!this.isAllowPanGesture) { this.isAllowPanGesture = true; return; } this.autoFlipPage(); this.isDrawing = false; }) ) .onClick((event?: ClickEvent) => { if (!event) { hilog.error(0x0000, 'EmulationFlipPage', 'onClick event is undefined'); return } if (this.timeID !== -1) { this.finishLastGesture(); return; } if (event.x > (this.screenW / Constants.PAGE_FLIP_THREE * Constants.PAGE_FLIP_TWO)) { if (this.currentPageNum !== Constants.PAGE_FLIP_PAGE_END) { // Set initial value. this.clickAutoFlipInit(MoveForward.MF_BACKWARD, event, 'middlePage'); this.newRectNode(); this.isMiddlePageHide = true; this.autoFlipPage(); } else { try { this.getUIContext().getPromptAction().showToast({ message: '已读到最新章' }); } catch (error) { let err = error as BusinessError; hilog.error(0x0000, 'EmulationFlipPage', `showToast failed. code=${err.code}, message=${err.message}`); } return; } } else if (event.x > (this.screenW / Constants.PAGE_FLIP_THREE)) { this.isMenuViewVisible = !this.isMenuViewVisible; } else { if (this.currentPageNum !== Constants.PAGE_FLIP_PAGE_START) { this.clickAutoFlipInit(MoveForward.MF_FORWARD, event, 'leftPage'); this.autoFlipPage(); } else { try { this.getUIContext().getPromptAction().showToast({ message:'前面没有内容啦~' }); } catch (error) { let err = error as BusinessError; hilog.error(0x0000, 'EmulationFlipPage', `showToast failed. code=${err.code}, message=${err.message}`); } return; } } }) 步骤2:页面状态管理// 使用@StorageLink和AppStorage进行全局状态管理 @StorageLink('positionX') positionX: number = -1; @StorageLink('positionY') positionY: number = -1; @StorageLink('drawPosition') drawPosition: number = DrawPosition.DP_NONE; @StorageLink('windowHeight') windowHeight: number = 0; @StorageLink('windowWidth') @Watch('updateScreenW') windowWidth: number = 0; @StorageLink('moveForward') gestureMoveForward: number = 0; @StorageLink('pagePixelMap') pagePixelMap: image.PixelMap | undefined = undefined; @StorageLink('pageHide') @Watch('isPageHide') pageHide: boolean = false; 步骤3:自定义绘制实现// PageNodeController.ets 中的核心绘制逻辑 export class MyNodeController extends NodeController { private rootNode: FrameNode | null = null; makeNode(uiContext: UIContext): FrameNode { this.rootNode = new FrameNode(uiContext); const renderNode = this.rootNode.getRenderNode(); let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; if (renderNode !== null) { renderNode.frame = { x: 0, y: 0, width: uiContext.px2vp(viewWidth), height: uiContext.px2vp(viewHeight) }; renderNode.pivot = { x: 50, y: 50 }; } return this.rootNode; } addNode(node: RenderNode): void { if (this.rootNode === null) { return; } const renderNode = this.rootNode.getRenderNode(); if (renderNode !== null) { renderNode.appendChild(node); } } clearNodes(): void { if (this.rootNode === null) { return; } const renderNode = this.rootNode.getRenderNode(); if (renderNode !== null) { renderNode.clearChildren(); } } } export class RectRenderNode extends RenderNode { draw(context: DrawContext): void { const canvas = context.canvas; init(); drawPathBShadow(canvas); drawPathC(canvas); getPathA(); drawPathAContent(canvas); } } 步骤4:翻页动画控制// EmulationFlipPage.ets // 通过定时器实现翻页动画 private setTimer(xDiff: number, yDiff: number, drawNode: () => void) { // Automatically flip forward. if (this.gestureMoveForward === MoveForward.MF_FORWARD) { this.timeID = setInterval((xDiff: number, yDiff: number, drawNode: () => void) => { let x = AppStorage.get('positionX') as number + xDiff; let y = AppStorage.get('positionY') as number + yDiff; if (x >= (AppStorage.get('windowWidth') as number) - 1 || y >= (AppStorage.get('windowHeight') as number) || y <= 0) { this.finishLastGesture(); } else { AppStorage.setOrCreate('positionX', x); AppStorage.setOrCreate('positionY', y); drawNode(); } }, Constants.TIMER_DURATION, xDiff, yDiff, drawNode); } else { AppStorage.setOrCreate('isFinished', false); this.timeID = setInterval((xDiff: number, _: number, drawNode: () => void) => { let x = AppStorage.get('positionX') as number + xDiff; let y = AppStorage.get('positionY') as number; let isFinished: boolean = AppStorage.get('isFinished') as boolean; if (isFinished) { this.finishLastGesture(); } else { AppStorage.setOrCreate('positionX', x); AppStorage.setOrCreate('positionY', y); drawNode(); } }, Constants.TIMER_DURATION, xDiff, yDiff, drawNode); } } 步骤5:曲线计算// PageNodeController.ets function getPathA(): void { if (canIUse('SystemCapability.Graphics.Drawing')) { let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; if (pointF.x === viewWidth && pointF.y === 0) { pathA.reset(); pathA.lineTo(pointC.x, pointC.y); pathA.quadTo(pointE.x, pointE.y, pointB.x, pointB.y); pathA.lineTo(pointA.x, pointA.y); pathA.lineTo(pointK.x, pointK.y); pathA.quadTo(pointH.x, pointH.y, pointJ.x, pointJ.y); pathA.lineTo(viewWidth, viewHeight); pathA.lineTo(0, viewHeight); pathA.close(); } if (pointF.x === viewWidth && pointF.y === viewHeight) { pathA.reset(); pathA.lineTo(0, viewHeight); pathA.lineTo(pointC.x, pointC.y); pathA.quadTo(pointE.x, pointE.y, pointB.x, pointB.y); pathA.lineTo(pointA.x, pointA.y); pathA.lineTo(pointK.x, pointK.y); pathA.quadTo(pointH.x, pointH.y, pointJ.x, pointJ.y); pathA.lineTo(viewWidth, 0); pathA.close(); } } } 步骤6:阴影效果绘制// PageNodeController.ets function drawPathBShadow(canvas: drawing.Canvas) { canvas.save() let deepColor: number = 0xff111111; let lightColor: number = 0x00111111; let gradientColors: number[] = [deepColor, lightColor]; let viewWidth: number = AppStorage.get('windowWidth') as number; let viewHeight: number = AppStorage.get('windowHeight') as number; let aToF = Math.hypot((pointA.x - pointF.x), (pointA.y - pointF.y)); let viewDiagonalLength = Math.hypot(viewWidth, viewHeight); let left: number = 0; let right: number = 0; let top: number = pointC.y; let bottom: number = viewDiagonalLength + pointC.y; if (pointF.x === viewWidth && pointF.y === 0) { left = pointC.x; right = pointC.x + aToF / 4; } else { left = pointC.x - aToF / 4; right = pointC.x; } let deltaX: number = pointH.y - pointF.y; let deltaY: number = pointE.x - pointF.x; let radians: number = Math.atan2(deltaY, deltaX); let rotateDegrees: number = radians * 180 / Math.PI; let startPt: common2D.Point = { x: pointF.y === 0 ? right : left, y: top }; let endPt: common2D.Point = { x: pointF.y === 0 ? left : right, y: top }; let shaderEffect = drawing.ShaderEffect.createLinearGradient(endPt, startPt, gradientColors, drawing.TileMode.MIRROR); canvas.rotate(rotateDegrees, pointC.x, pointC.y); let rect: common2D.Rect = { left: left, top: top, right: right, bottom: bottom }; drawShadow(canvas, shaderEffect, rect); } 步骤7:页面绘制// Index.ets build() { Stack() { EmulationFlipPage({ isMenuViewVisible: this.isMenuViewVisible, currentPageNum: this.currentPageNum }) } .backgroundColor('#FFEBC3') } 4. 方案成果总结该阅读器组件通过仿真翻页渲染、精准手势交互与高效状态管理的融合,实现流畅的翻页体验、逼真的视觉效果及精准的操作响应,显著提升电子书阅读的沉浸感与交互体验;借助 HarmonyOS 特性深度优化图形绘制与状态同步,兼顾视觉效果与性能表现,同时简化开发实现逻辑,降低复杂交互场景的开发成本。打造沉浸式仿真翻页体验:基于 ArkUI 自定义绘制技术实现页面折叠、阴影等逼真视觉效果,支持滑动拖拽与点击两种翻页方式,模拟真实书籍翻页的物理质感与动态曲线,还原纸质书阅读体验;构建精准化手势交互体系:整合PanGesture识别不同区域触摸操作,区分快速滑动、慢速拖拽、边缘轻触等意图,通过阈值校准与轨迹分析实现 “灵敏且防误触” 的响应,适配多场景操作需求;实现高性能渲染与状态管理:优化图形计算算法确保每帧实时完成曲线绘制与变形处理,依托 AppStorage 全局状态管理实现手势、绘制、动画状态的高效同步,兼顾渲染效果与流畅性。
-
1、关键技术难点总结1.1 问题说明在鸿蒙(HarmonyOS)应用开发中,文本内电话号码与邮箱地址的识别、标记及交互功能实现场景,在构建通用组件时面临诸多共性挑战,直接影响开发效率、功能实用性与用户操作体验:识别规则不统一,适配性不足:缺乏标准化的文本解析逻辑,不同格式的电话号码(如带区号、分隔符、国际号码)、邮箱地址(含特殊后缀、多级域名)难以被全面精准识别,易出现漏识别、误识别问题,且需针对不同文本场景重复适配解析规则,增加开发成本与维护难度。样式标记不规范,视觉混乱:识别结果的特殊标记(蓝色字体、下划线)缺乏统一的样式管理方案,不同页面、文本类型中标记样式易出现差异,同时标记效果可能与原有文本排版、主题风格冲突,破坏界面一致性,影响用户视觉体验。交互功能单一,实用性欠缺:仅支持基础的样式标记,未充分满足用户核心需求 —— 电话号码缺乏直接拨号的快捷交互,邮箱地址未配套快速跳转发送邮件的功能,导致识别结果仅为视觉展示,无法转化为实际使用价值,降低组件实用性与用户操作效率。1.2 原因分析匹配复杂度高:需适配电话号码的多样格式(含区号、分隔符、国际号码等)及邮箱地址的复杂规则(多级域名、特殊字符组合等),单一正则表达式难以覆盖全场景,需设计多规则组合的匹配逻辑,增加了文本解析的开发难度与维护成本。文本渲染差异化难:普通文本渲染组件仅支持统一样式,需在连续文本流中对识别出的号码、邮箱单独应用蓝色字体、下划线样式,面临文本分段渲染与格式隔离的技术挑战。交互体验设计不足:仅实现基础的样式标记,未针对电话号码补充点击拨号的快捷交互,也缺乏交互过程中的反馈,无法满足用户对操作便捷性与反馈直观性的需求。2、解决思路该方案核心思路:以「文本识别 - 样式渲染 - 交互联动」为核心,通过「正则精准匹配 + 文本分段渲染 + 直观交互设计」,打造功能完整、体验流畅的文本识别组件,具体包括:正则多模式匹配:针对电话号码(含区号、分隔符、国际号码等)和邮箱地址(多级域名、特殊字符组合等)的格式特点,设计专属正则表达式组合,通过多规则校验实现全场景精准识别,确保号码与邮箱无漏识别、误识别问题。文本分割重组渲染:先通过识别结果将原始文本分割为普通文本、电话号码、邮箱地址三类片段,再利用文本重组,对普通文本保持默认样式,对识别出的号码和邮箱单独应用蓝色字体、下划线样式,最终在统一文本流中实现混合样式的无缝渲染。直观交互体验设计:在视觉上通过差异化样式明确标记可交互元素,针对电话号码设计点击拨号功能,同时补充交互反馈,让用户操作更便捷、反馈更清晰,提升整体使用体验。3、解决方案3.1 核心组件设计创建[TextExtractor]组件,包含以下功能:自动识别规则混合样式文本渲染交互事件处理import { promptAction } from '@kit.ArkUI'; interface ExtractedItem { text: string; type: string; startIndex: number; endIndex: number; } @Component export struct TextExtractor { @Watch('extractItems') @Prop private textContent: string = ''; @State private extractedItems: Array<ExtractedItem> = []; @State fontSize: number = 16; aboutToAppear(): void { this.extractItems(); } build() { if (this.extractedItems.length === 0) { Text(this.textContent) .fontSize(this.fontSize) .fontColor('#000') } else { Text() { ForEach(this.extractedItems, (item: ExtractedItem, index: number) => { if (item.type === 'text') { Span(item.text).fontSize(this.fontSize) } else { Span(item.text) .fontSize(this.fontSize) .fontColor('#007AFF') .decoration({ type: TextDecorationType.Underline }) .onClick(() => { if (item.type === 'phone') { // 调用系统拨号功能 this.dialNumber(item.text); } else if (item.type === 'email') { // 可以添加邮件处理逻辑 promptAction.showToast({ message: '邮箱地址: ' + item.text }); } }) } }, (item: ExtractedItem) => item.text + item.startIndex) } } } public extractItems() { // 清空之前的识别结果 this.extractedItems = []; // 电话号码正则表达式 (支持多种格式) const phoneRegex = /(\+?86[-\s]?)?(1[3-9]\d[-\s]?\d{4}[-\s]?\d{4}|0\d{2,3}[-\s]?\d{7,8}([-s]?\d{1,6})?)/g; let match: RegExpExecArray | null; while ((match = phoneRegex.exec(this.textContent)) !== null) { this.extractedItems.push({ text: match[0], type: 'phone', startIndex: match.index, endIndex: match.index + match[0].length }); } // 邮箱地址正则表达式 const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g; while ((match = emailRegex.exec(this.textContent)) !== null) { this.extractedItems.push({ text: match[0], type: 'email', startIndex: match.index, endIndex: match.index + match[0].length }); } const length = this.extractedItems.length; // 拼接普通文本 for(let i = 0; i < length; i++) { let prevEndIndex: number = 0; const item: ExtractedItem = this.extractedItems[i]; if (i > 0) { prevEndIndex = this.extractedItems[i - 1].endIndex; } if (item.startIndex > prevEndIndex) { this.extractedItems.push({ text: this.textContent.substring(prevEndIndex, item.startIndex), type: 'text', startIndex: prevEndIndex, endIndex: item.startIndex }) } } // 按位置排序 this.extractedItems.sort((a, b) => a.startIndex - b.startIndex); if (this.extractedItems.length > 0) { const lastIndex = this.extractedItems[this.extractedItems.length - 1].endIndex; if (lastIndex < this.textContent.length) { this.extractedItems.push({ text: this.textContent.substring(lastIndex), type: 'text', startIndex: lastIndex, endIndex: this.textContent.length }) } } } dialNumber(phoneNumber: string) { // 移除所有非数字字符,但保留+ let cleanNumber = phoneNumber.replace(/[^\d+]/g, ''); // 如果是手机号,直接拨打 if (/^1[3-9]\d{9}$/.test(cleanNumber) || /^\+861[3-9]\d{9}$/.test(cleanNumber)) { // 注意:这里需要根据实际API调整 promptAction.showToast({ message: '正在拨打: ' + cleanNumber }); // 实际调用拨号功能的代码可能需要权限配置 // 示例:call dial(cleanNumber); } else { // 其他情况弹窗确认 promptAction.showDialog({ title: '拨打电话', message: '是否拨打 ' + phoneNumber + ' ?', buttons: [ { text: '取消', color: '#ffded4d4' }, { text: '拨打', color: '#ff495fcd' } ] }); } } } 3.2 正则表达式设计// 电话号码正则表达式 (支持多种格式) const phoneRegex = /(\+?86[-\s]?)?(1[3-9]\d[-\s]?\d{4}[-\s]?\d{4}|0\d{2,3}[-\s]?\d{7,8}([-s]?\d{1,6})?)/g; // 邮箱地址正则表达式 const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g; 3.3 样式渲染机制通过文本分割技术,将原始文本按识别结果切分为多个片段(组件样式可以根据需求再重新修改, 交互事件同理,也可以扩展参数传递到组件):普通文本:黑色字体,无装饰识别文本:蓝色字体,带下划线if (this.extractedItems.length === 0) { Text(this.textContent) .fontSize(this.fontSize) .fontColor('#000') } else { Text() { ForEach(this.extractedItems, (item: ExtractedItem, index: number) => { if (item.type === 'text') { Span(item.text).fontSize(this.fontSize) } else { Span(item.text) .fontSize(this.fontSize) .fontColor('#007AFF') .decoration({ type: TextDecorationType.Underline }) .onClick(() => { if (item.type === 'phone') { // 调用系统拨号功能 this.dialNumber(item.text); } else if (item.type === 'email') { // 可以添加邮件处理逻辑 promptAction.showToast({ message: '邮箱地址: ' + item.text }); } }) } }, (item: ExtractedItem) => item.text + item.startIndex) } } 4、方案成果总结该组件通过多场景识别、差异化渲染与便捷交互的整合,实现文本中电话号码与邮箱地址的高效识别、清晰标记及实用操作,显著提升文本处理的功能性与用户体验;借助灵活的适配机制支持多样号码格式,搭配直观的交互反馈设计,让用户操作更便捷、识别结果更易感知,同时简化开发集成流程,降低多场景应用的适配成本。自动识别文本中的电话号码(支持多格式适配)与邮箱地址,通过蓝色字体、下划线样式精准标记识别内容,同时支持电话号码点击直接拨号,满足核心使用需求;设计用户友好的交互反馈机制,拨号请求过程中展示状态反馈,让操作流程直观可感知,提升使用流畅度;集成使用简单高效:页面引入 TextExtractor 组件、输入目标文本,组件便自动完成识别与标记,无需复杂配置,降低开发与使用门槛。
-
1.1问题说明在鸿蒙(HarmonyOS)应用开发中,设备电量充电场景在构建可视化组件时,均面临诸多共性挑战,直接影响开发效率、用户体验与代码可维护性:(一)多场景重复开发,效率低下:鸿蒙应用中充电相关场景缺乏统一可复用的可视化组件,导致不同页面、模块需重复编写充电动画逻辑、布局及状态管理代码,不仅增加了重复劳动与项目成本,后续调整动画效果或修复 bug 时需逐一修改,极易引入新问题,降低代码质量与项目稳定性。(二)状态与动画不同步,体验割裂:充电场景中电量数据、动画进度、文本提示的控制逻辑相互独立,易出现 “数据与 UI 脱节” 的情况,让用户对当前状态产生困惑,破坏应用专业性与用户信任,大幅拉低使用体验。(三)交互体验单一,反馈不足:现有充电动画组件多仅支持基础的 “开始 - 结束” 流程,缺乏用户实际场景中急需的实用功能,如充电暂停 / 继续、进度保存等,同时忽略了充电速度可视化、完成提示等细节反馈,既无法满足用户多样化需求。1.2原因分析(一)无组件化封装:传统鸿蒙充电场景开发中,充电动画逻辑与业务逻辑深度耦合,未将可视化相关功能(布局渲染、动画控制、状态展示)抽离为独立可复用组件,导致不同页面、不同场景需重复编写相似代码,无法快速适配多场景需求。(二)动画与数据分离:充电场景的核心要素(动画触发时机、进度数据更新、状态判断逻辑)分散在不同的方法或模块中,缺乏统一的同步调度机制,导致数据变化与动画播放、UI 展示无法实时联动问题。(三)状态管理分散:未通过isCharging(充电中)、isFullyCharged(已充满)等核心统一状态变量统筹充电全流程,各类状态判断逻辑散落于不同回调函数或事件处理器中,导致充电、暂停、充满、未充电等状态的切换规则混乱。缺乏对用户实际使用中关键操作的响应与反馈,无法满足用户多样化的操作诉求。1.3解决思路该方案核心思路:以「充电场景组件化」为核心,通过「统一状态管理 + 同步动画机制 + 全流程交互设计」,打造高复用、高可用的充电动画组件,具体包括:(一)组件化封装:把充电动画、状态控制、操作逻辑整合做成一个独立的通用模块,充电显示能直接拿来用,不用重复开发,大大节省跨页面、跨场景的适配时间。(二)统一状态管理:把电量多少、是否在充电、有没有充满等关键信息集中管理,只要其中一个信息发生变化,界面上的所有展示都会跟着实时更新,不会出现数据和画面不同步的情况。(三)同步动画机制:设计一个统一的更新开关,不管是电量数字变化、电池填充进度,还是数字滚动效果,都会通过这个开关一起同步变动,让动画和数据完全匹配,不会出现 “数字到 100 了,动画还在动” 的脱节问题。(四)全流程交互支持:完整覆盖从开始充电、暂停充电、继续充电,到充满提示、重置状态的全部操作场景。每一步操作都有明确反馈 —— 按钮会根据当前状态自动启用或禁用,文字提示和颜色也会跟着变,让用户每一次操作都有回应,用起来更顺畅。1.4解决方案该解决方案围绕充电场景可视化组件的复用性、同步性、交互性展开:通过组件化封装,将充电动画的 UI 渲染、状态管理、动画控制逻辑整合为独立可复用的组件,并对外暴露电池尺寸、动画时长等可配置参数,支持多页面直接引用以避免重复开发;基于 @State 装饰器集中管理电量、充电状态、是否充满等核心变量,确保状态变化时 UI 自动同步刷新,比如电量更新或充电状态切换时,按钮启用状态、文本提示会随之实时调整;设计 updateAllProgress 统一入口,同步触发电量数据更新、电池填充宽度调整与数字滚动动画,实现 “数据 - 动画 - UI” 三位一体的协同联动,避免视觉割裂;全面覆盖 “开始 - 暂停 - 继续 - 充满 - 重置” 全流程交互,暂停时自动保存当前进度,继续充电可从上次进度恢复,充满后自动停止动画并切换绿色闪电图标与 “电已充满” 提示,重置功能则可清空进度回归初始状态;同时优化可视化体验,电量颜色随剩余比例动态切换(低电量红色、中等电量橙色、高电量绿色),充电时闪电图标呈现 “出现 - 反弹 - 脉动” 动画,文本提示实时匹配当前状态并搭配对应颜色,让用户直观感知充电进度与状态变化。组件代码示例:@Entry @Component struct BatteryChargeDemo { // 当前电量百分比 (0-100) @State chargePercent: number = 0 // 是否正在充电 @State isCharging: boolean = false // 电池动画的宽度,基于百分比计算 @State batteryFillWidth: number = 0 // 闪电图标透明度 @State lightningOpacity: number = 0 // 闪电图标缩放 @State lightningScale: number = 0.5 // 数字滚动的当前显示值 @State displayPercent: number = 0 // 是否已充满 @State isFullyCharged: boolean = false // 保存停止时的电量,用于继续充电 @State savedChargePercent: number = 0 // 电池总高度和宽度 (用于计算填充部分) private batteryHeight: number = 70 private batteryWidth: number = 200 // 电池填充和内边距 private batteryPadding: number = 1.5 private batteryFillMaxWidth: number = this.batteryWidth - 3 * this.batteryPadding - 6 // 减去边框和内边距 // 动画计时器ID private animationTimer: number = 0 private pulseTimer: number = 0 aboutToAppear() { // 初始化时,根据初始电量设置填充宽度 this.updateBatteryFillWidth() } aboutToDisappear() { // 清理动画计时器 if (this.animationTimer) { clearTimeout(this.animationTimer) } if (this.pulseTimer) { clearTimeout(this.pulseTimer) } } // 更新电池填充宽度 updateBatteryFillWidth() { // 计算填充宽度:最大填充宽度 * 当前百分比 this.batteryFillWidth = (this.batteryFillMaxWidth * this.chargePercent) / 100 // 检查是否已充满 if (this.chargePercent >= 100 && this.isCharging) { this.isFullyCharged = true // 充满后停止脉动动画 this.stopPulseAnimation() } } // 同步更新所有进度显示 updateAllProgress(percent: number) { this.chargePercent = percent this.displayPercent = percent this.updateBatteryFillWidth() } // 数字滚动动画 - 与电池动画同步 startNumberAnimation() { const startPercent = this.chargePercent const targetPercent = 100 const remainingPercent = targetPercent - startPercent const duration = (3000 * remainingPercent) / 100 // 根据剩余电量比例计算时间 const steps = 60 // 帧数 const stepTime = duration / steps const stepValue = remainingPercent / steps // 每步增加的值 let currentStep = 0 const animateStep = () => { if (currentStep < steps && this.isCharging) { const newPercent = startPercent + (currentStep + 1) * stepValue this.updateAllProgress(newPercent) currentStep++ this.animationTimer = setTimeout(animateStep, stepTime) } else { // 确保最终值准确 this.updateAllProgress(targetPercent) this.isFullyCharged = true } } animateStep() } // 闪电图标动画 startLightningAnimation() { // 第一段:快速出现 animateTo({ duration: 300, curve: Curve.EaseOut, }, () => { this.lightningOpacity = 1 this.lightningScale = 1.2 }) // 第二段:轻微反弹 setTimeout(() => { animateTo({ duration: 200, curve: Curve.EaseInOut, }, () => { this.lightningScale = 1.0 }) }, 300) // 第三段:持续脉动 this.startPulseAnimation() } // 脉动动画 startPulseAnimation() { if (!this.isCharging || this.isFullyCharged) return // 使用交替的animateTo调用来实现脉动效果 const pulseCycle = () => { if (!this.isCharging || this.isFullyCharged) return // 放大动画 animateTo({ duration: 800, curve: Curve.EaseInOut, }, () => { this.lightningScale = 1.1 }) // 缩小动画 setTimeout(() => { if (!this.isCharging || this.isFullyCharged) return animateTo({ duration: 800, curve: Curve.EaseInOut, }, () => { this.lightningScale = 1.0 }) // 循环 this.pulseTimer = setTimeout(pulseCycle, 1600) }, 800) } pulseCycle() } // 停止脉动动画 stopPulseAnimation() { if (this.pulseTimer) { clearTimeout(this.pulseTimer) } // 将闪电图标恢复到正常大小 this.lightningScale = 1.0 } // 停止闪电动画 stopLightningAnimation() { // 清理脉动计时器 this.stopPulseAnimation() animateTo({ duration: 300, curve: Curve.EaseIn, }, () => { this.lightningOpacity = 0 this.lightningScale = 0.5 }) } // 开始充电动画 - 从当前进度继续 startChargeAnimation() { this.isCharging = true this.isFullyCharged = false // 如果之前保存了进度,则恢复 if (this.savedChargePercent > 0) { this.updateAllProgress(this.savedChargePercent) this.savedChargePercent = 0 } // 启动闪电图标动画 this.startLightningAnimation() // 启动同步的数字滚动动画(同时控制电池填充) this.startNumberAnimation() } // 停止充电 - 保存当前进度 stopCharging() { this.isCharging = false // 保存当前进度,除非已经充满 if (!this.isFullyCharged) { this.savedChargePercent = this.chargePercent } else { this.savedChargePercent = 0 } // 清理动画计时器 if (this.animationTimer) { clearTimeout(this.animationTimer) } // 停止闪电动画 this.stopLightningAnimation() } build() { Column({ space: 20 }) { // 标题 Text('电池充电动画Demo') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 40 }) // 电池外容器 - 修改为水平布局 Row() { // 电池主体 Stack() { // 电池外壳 Rect({ width: this.batteryWidth, height: this.batteryHeight }) .radius(8) // 设置圆角半径 .fill(Color.Transparent) // 透明填充 .stroke(Color.Black) // 设置边框颜色 .strokeWidth(2) // 设置边框宽度 // 电池填充(动态部分)- 现在与百分比完全同步 Row() { Rect({ width: this.batteryFillWidth, height: this.batteryHeight - 2 * this.batteryPadding - 4 }) .fill(this.getBatteryColor()) // 根据电量改变颜色 } .width(this.batteryFillMaxWidth) .height(this.batteryHeight - 2 * this.batteryPadding) .margin({ top: this.batteryPadding, left: this.batteryPadding }) .justifyContent(FlexAlign.Start) // 闪电图标 - 只在充电时显示,充满后也显示 if (this.isCharging || this.isFullyCharged) { Text('⚡') // 闪电符号 .fontSize(36) .fontColor(this.isFullyCharged ? '#00FF00' : '#FFD700') // 充满后变绿色 .opacity(this.lightningOpacity) .scale({ x: this.lightningScale, y: this.lightningScale }) .position({ x: (this.batteryWidth - 36) / 2, y: (this.batteryHeight - 36) / 2 }) } } Rect({ width: 10, // 宽度变小 height: 30 // 高度增加 }) .fill(Color.Black) .margin({ left: 2 }) .radius(4) } .alignItems(VerticalAlign.Center) Row() { Text(this.displayPercent.toFixed(0)) .fontSize(28) .fontColor(this.getBatteryColor()) .fontWeight(FontWeight.Bold) Text('%') .fontSize(20) .fontColor(this.getBatteryColor()) .margin({ left: 2, top: 4 }) } .margin({ top: 16 }) Text(this.getChargeStatusText()) .fontSize(18) .fontColor(this.getChargeStatusColor()) .margin({ top: 8 }) Row({ space: 10 }) { Text('充电速度:') .fontSize(14) .fontColor('#666666') Text(this.getChargeSpeedText()) .fontSize(14) .fontColor(this.getChargeSpeedColor()) .fontWeight(FontWeight.Medium) } .margin({ top: 8 }) if (this.savedChargePercent > 0 && !this.isCharging && !this.isFullyCharged) { Text(`已保存进度: ${this.savedChargePercent.toFixed(0)}%`) .fontSize(12) .fontColor('#666666') .margin({ top: 4 }) } // 控制按钮 Row({ space: 30 }) { Button(this.getStartButtonText()) .backgroundColor('#007DFF') .fontColor(Color.White) .width(120) .enabled(!this.isCharging) .onClick(() => { this.isCharging = true // 开始动画 this.startChargeAnimation() }) Button('暂停充电') .backgroundColor('#FFA500') .fontColor(Color.White) .width(120) .enabled(this.isCharging && !this.isFullyCharged) .onClick(() => { this.stopCharging() }) } .margin({ top: 30 }) // 重置按钮 Button('重置') .backgroundColor('#666666') .fontColor(Color.White) .width(120) .margin({ top: 10 }) .onClick(() => { this.stopCharging() this.savedChargePercent = 0 this.updateAllProgress(0) }) } .width('100%') .height('100%') .padding(20) .alignItems(HorizontalAlign.Center) .backgroundColor('#F5F5F5') // 添加背景色提升视觉效果 } // 根据电量百分比返回对应的颜色 getBatteryColor(): string { if (this.chargePercent <= 20) { return '#FF0000' // 红色,低电量 } else if (this.chargePercent <= 60) { return '#FFA500' // 橙色,中等电量 } else { return '#00FF00' // 绿色,高电量 } } // 获取充电状态文本 getChargeStatusText(): string { if (this.isFullyCharged) { return '电已充满' } else if (this.isCharging) { return '充电中...' } else if (this.savedChargePercent > 0) { return '已暂停' } else { return '未充电' } } // 获取充电状态颜色 getChargeStatusColor(): string { if (this.isFullyCharged) { return '#00AA00' // 深绿色,表示充满 } else if (this.isCharging) { return '#007DFF' // 蓝色,表示充电中 } else if (this.savedChargePercent > 0) { return '#FFA500' // 橙色,表示暂停 } else { return '#990000' // 红色,表示未充电 } } // 获取充电速度文本 getChargeSpeedText(): string { if (this.isFullyCharged) { return '已完成' } else if (this.isCharging) { return '快速充电' } else if (this.savedChargePercent > 0) { return '已暂停' } else { return '未充电' } } // 获取充电速度颜色 getChargeSpeedColor(): string { if (this.isFullyCharged) { return '#00AA00' // 深绿色,表示完成 } else if (this.isCharging) { return '#007DFF' // 蓝色,表示快速充电 } else if (this.savedChargePercent > 0) { return '#FFA500' // 橙色,表示暂停 } else { return '#666666' // 灰色,表示未充电 } } // 获取开始按钮文本 getStartButtonText(): string { if (this.savedChargePercent > 0 && !this.isCharging && !this.isFullyCharged) { return '继续充电' } else { return '开始充电' } } } 1.5方案成果总结通过组件化封装可直接适配设备充电、任务进度等多场景,大幅减少重复开发工作量并提升跨场景适配效率;借助统一调度机制实现数据与动画的协同联动,彻底解决 “数据与 UI 脱节” 问题,呈现流畅连贯的视觉体验;完整支持暂停 / 继续 / 重置等实用操作,操作反馈清晰直观,显著提升用户交互体验满意度;同时通过统一状态管理与逻辑入口,新增 “充电异常” 等状态时仅需少量代码修改,有效提升维护效率,降低功能迭代成本。(一)支持深度自定义配置:开放电池尺寸、动画时长、颜色阈值等核心参数,允许根据产品 UI 风格灵活调整,适配更多设计需求;(二)拓展全场景状态覆盖:新增充电异常、充电完成提醒、慢充 / 快充区分等状态,覆盖更复杂的业务场景,提升组件适用性;(三)强化跨设备兼容能力:依托鸿蒙 ArkUI 跨平台特性,无缝适配手机、平板、智能手表等多设备形态,拓宽组件应用范围,助力全场景产品布局。
-
1. 关键技术难点总结1.1 问题说明在鸿蒙系统 APP 的个人中心、账号设置等核心场景中,个人头像组件是比较关键模块,需满足「本地图片选择 - 加载显示 - 状态切换 - 结果回调」的完整业务流程。开发过程中面临的具体问题如下:场景化文件选择适配:用户需从本地相册 / 文件中选择头像图片,需调用鸿蒙系统文件选择器并适配头像场景的格式限制(仅支持 png/jpg)、单选需求;头像显示:需处理空状态占位、图片加载失败、等场景,同时支持圆形/方形多种常用头像形状的切换显示;交互状态同步:用户操作(选择图片、切换形状、清除、保存)需实时同步到 UI 展示,避免出现「选择图片后不刷新」「形状切换延迟」等状态不一致问题;组件复用与扩展:组件需适配不同页面的尺寸需求(如个人中心大头像、列表小头像),并向外部暴露保存回调,支持业务层自定义保存逻辑(如上传服务器、本地缓存)。1.2 原因分析鸿蒙系统 DocumentViewPicker 为通用文件选择 API,未默认支持头像场景的格式过滤、单选限制,需手动配置适配;文件选择(用户手动操作)、图片加载(网络 / 本地文件读取)均为异步过程,若未妥善处理 Promise 回调,易导致状态更新不及时或异常未捕获;UI 与数据状态解耦不足:头像路径、形状等核心状态若未通过响应式机制管理,会出现「数据已变但 UI 未更新」的同步问题,尤其在多操作连续触发时更明显;组件设计通用性缺失:若直接耦合页面尺寸、保存逻辑,会导致组件无法复用,且难以适配不同业务场景的扩展需求(如后续新增圆角矩形形状、图片裁剪功能);2. 解决思路场景化 API 封装:基于鸿蒙 DocumentViewPicker,封装头像专用文件选择逻辑,预设格式过滤、单选配置,简化业务层调用;响应式状态管理:使用 ArkUI 的 @State 装饰器管理核心状态(头像路径、显示形状),实现状态变化与 UI 更新的自动同步;分层功能设计:将「选择图片、显示渲染、形状切换、保存回调」拆分为独立方法,降低耦合,便于维护与扩展;场景化兼容处理:针对空状态、加载失败等场景,设计统一的回退方案(如占位符、错误提示);自定义扩展支持:通过暴露属性(尺寸、初始形状)和回调(保存结果),实现组件的通用性与业务层扩展能力;3. 解决方案3.1 核心组件实现AvatarComponent 组件聚焦头像场景的完整需求,实现了「选择 - 显示 - 交互 - 回调」的闭环功能,核心设计如下:空状态:显示「点击上传」占位符,引导用户操作;图片处理:支持 png/jpg 格式,自动适配组件尺寸,加载失败时打印日志并提示;交互功能:上传图片、切换圆形 / 方形、清除头像、保存结果、点击预览(预留扩展入口);复用能力:支持自定义组件尺寸、初始形状,通过回调暴露保存结果。3.1.2 完整实例代码@Component export struct AvatarComponent { // 保存回调函数 private onSave: (avatarUri: string, shape: 'circle' | 'square') => void = (avatarUri: string, shape: 'circle' | 'square') => {}; // 头像图片路径 @State avatarUri: string = ''; // 头像形状:circle 或 square @State shape: 'circle' | 'square' = 'circle'; // 组件大小 private avatarSize: number = 100; build() { Column() { // 显示头像 if (this.avatarUri) { Image(this.avatarUri) .width(this.avatarSize) .height(this.avatarSize) .borderRadius(this.shape === 'circle' ? '50%' : 10) .onComplete((result) => { if (result && result.loadingStatus === 0) { console.log('Image loaded successfully'); } else { console.error('Failed to load image, status: ' + (result ? result.loadingStatus : 'unknown')); } }) .onClick(() => { // 点击头像进行预览 this.previewImage(); }) } else { // 默认头像占位符 Row() { Text('点击上传') .fontSize(14) .fontColor('#999999') } .width(this.avatarSize) .height(this.avatarSize) .borderRadius(this.shape === 'circle' ? '50%' : 10) .backgroundColor('#f0f0f0') .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) .onClick(() => { this.selectImage(); }) } // 操作按钮 Row() { Button('上传头像') .onClick(() => { this.selectImage(); }) Button(this.shape === 'circle' ? '方形' : '圆形') .onClick(() => { this.shape = this.shape === 'circle' ? 'square' : 'circle'; }) .margin({ left: 10 }) Button('清除') .onClick(() => { this.avatarUri = ''; }) .margin({ left: 10 }) Button('保存') .onClick(() => { this.saveAvatar(); }) .margin({ left: 10 }) .enabled(this.avatarUri !== '') } .margin({ top: 10 }) } } // 选择图片 private selectImage() { const documentSelectOptions = new picker.DocumentSelectOptions(); documentSelectOptions.maxSelectNumber = 1; documentSelectOptions.fileSuffixFilters = ['图片(.png, .jpg)|.png,.jpg']; let documentPicker = new picker.DocumentViewPicker(); documentPicker.select(documentSelectOptions) .then((data) => { if (data.length > 0) { this.avatarUri = data[0]; } }) .catch((err: Error) => { console.error('Select image failed with err: ' + JSON.stringify(err)); promptAction.showToast({ message: '选择图片失败' }); }); } // 预览图片 private previewImage() { if (this.avatarUri) { // 这里可以实现全屏预览功能 promptAction.showToast({ message: '点击了头像,可以在此处实现预览功能' }); } } // 保存头像 private saveAvatar() { if (this.onSave) { this.onSave(this.avatarUri, this.shape); } else { console.warn('未设置保存回调函数'); promptAction.showToast({ message: '保存功能未配置' }); } } } 3.2 页面集成在AvatarPage中集成AvatarComponent,并实现相应的回调处理逻辑。import { AvatarComponent } from '../components/AvatarComponent'; import { promptAction } from '@kit.ArkUI'; @Entry @Component struct Index { build() { Column() { Text('头像组件演示') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ bottom: 30 }) // 使用头像组件 AvatarComponent({ avatarSize: 100, shape: 'circle', onSave: (avatarUri: string, shape: 'circle' | 'square') => { // 处理保存逻辑 console.info(`'保存头像:', ${avatarUri}, '形状:', ${shape}`); promptAction.showToast({message: `'保存头像:', ${avatarUri}, '形状:', ${shape}`}); // 这里可以添加实际的保存逻辑,例如保存到本地存储或上传到服务器 } }) // 说明文字 Text('功能说明:') .fontSize(18) .fontWeight(FontWeight.Bold) .margin({ top: 40, bottom: 10 }) Text('1. 点击"上传头像"按钮可以选择本地图片') .fontSize(14) .margin({ bottom: 5 }) Text('2. 点击"方形"/"圆形"按钮可以切换头像显示形状') .fontSize(14) .margin({ bottom: 5 }) Text('3. 头像会根据选择的形状自动调整显示效果') .fontSize(14) Text('4. 点击"保存"按钮可以保存当前头像设置') .fontSize(14) } .width('100%') .height('100%') .padding(20) .justifyContent(FlexAlign.Center) } } 4. 方案成果总结功能覆盖全面且适配性强:该头像组件完整支持「空状态引导→选图→切换形状→预留预览→保存回调」的全流程操作,默认适配 png/jpg 格式与单选需求,提供圆形、方形两种常用形状,还能自定义尺寸,同时与业务逻辑解耦,可直接在个人中心、账号设置、评论列表等多个页面复用,仅需简单配置参数与回调即可使用。技术设计灵活且扩展性佳:组件针对头像场景定制了文件选择逻辑,封装后简化了系统 API 的使用复杂度,避免重复开发;同时预留了预览功能入口,后续可轻松扩展图片裁剪、圆角调整、多形状支持等额外功能,适配更多个性化需求。应用价值突出且实用高效:一方面能大幅提升开发效率,现成组件直接复用可减少重复工作量、缩短项目迭代周期;另一方面通过统一的交互流程、实时的状态同步和完善的异常提示,保障用户操作流畅无卡顿;此外,通过暴露回调接口支持自定义保存逻辑,可灵活适配上传服务器、本地缓存、关联用户账号等不同业务场景,支撑业务灵活拓展。
-
1.1问题说明在鸿蒙应用开发中,证件识别功能(涵盖身份证、银行卡等常见证件)用户使用过程中的核心痛点,具体包括:(一)证件正反面识别混乱,无法自动区分正面(如姓名、证件号等个人信息)与反面(如签发机关、有效期限等),导致信息展示杂乱;(二)识别后的信息无法保存,重新打开页面或刷新后,之前的识别结果就会丢失,需要重复扫描;(三)想手动拍摄证件,或从手机相册选择已存的证件照片进行识别,功能无法同时支持;(四)识别出来的信息杂乱无章,没有整理成容易查看的格式,没法直接用于后续使用。1.2原因分析(一)组件功能配置单一:默认只开启了手动拍摄模式,没启用相册选图功能,没法满足用户多样化需求;(二)缺乏内置存储能力:组件本身不具备保存识别结果的功能,结果只临时存在当前页面,页面关闭数据就丢失;(三)无正反面判断逻辑:组件没有根据证件特征(正面有个人信息、反面有机关信息)设计自动区分规则,只能原样返回数据;(四)反馈机制简单:组件只返回识别结果,没设计用户能直观看到的提示,也没记录错误信息,排查问题难;(五)数据整理功能不足:组件返回的是原始零散数据,没有筛选关键信息、按顺序整理的能力,直接展示体验差。1.3解决思路(一)扩展组件识别方式:给组件开启 “手动拍摄” 和 “相册选图” 双模式,用户想当场拍就拍,有现成照片就直接选,灵活适配不同场景;(二)给组件加内置存储:让组件自带 “结果仓库”,识别成功后自动保存信息,下次打开直接调取,不用重复扫描;(三)内置正反面识别规则:根据证件特征预设判断逻辑,比如识别到 “姓名、证件号” 就标记为正面,“签发机关、有效期” 就标记为反面,自动分类;(四)设计场景化展示模板:给组件配两套展示样式 —— 无结果时显示引导(图标 + 文字提示),有结果时用卡片式分类展示,信息一目了然;(五)自动整理数据:组件识别后自动筛选关键信息,按 “证件类型→姓名→证件号→签发机关→有效期限” 的固定顺序整理,直接可用。1.4解决方案该证件识别组件优化后,可同时支持手动拍摄证件实时识别与相册选图上传识别两种模式,默认适配身份证、银行卡等常见证件类型,满足不同使用场景需求;组件自带内置存储功能,识别成功后会自动将整理好的信息存入 “结果仓库”,下次打开或刷新页面时自动加载历史结果,还提供一键清空功能保障隐私;内置智能判断规则,通过识别 “姓名、证件号” 等个人信息或 “签发机关、有效期限” 等信息,自动区分证件正反面并分类展示,即便识别偶尔不准也能通过关键信息修正;配置了场景化预设展示模板,无识别结果时显示引导图标与操作提示,有结果时以卡片形式清晰呈现证件类型、正反面、证件照片及关键信息;识别过程中会给出明确反馈,成功时弹出 “识别成功” 提示,失败时告知简单原因并引导重新操作,同时后台记录关键信息方便排查问题;此外,组件还能自动筛选姓名、证件号等常用关键信息,按固定顺序整理后可直接同步至后续表单或核对页面,无需手动输入与整理,大幅提升使用效率与便捷性。证件识别组件代码示例:// 扫描页面 import { CardRecognition, CardRecognitionResult, CardType, CardSide, ShootingMode } from "@kit.VisionKit" import { promptAction, router } from '@kit.ArkUI'; // 更新导入 import { AppState } from './AppState'; // 导入全局状态 const TAG: string = 'ScanPage' // 定义卡证识别配置接口 interface CardRecognitionConfig { defaultShootingMode?: ShootingMode; isPhotoSelectionSupported?: boolean; isAutoClassification?: boolean; } // 扫描页面 @Entry @Component export struct ScanPage { build() { Column() { // 页面标题 Text('身份证扫描') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 20 }) // 卡证识别组件 CardRecognition({ // 配置身份证识别类型 supportType: CardType.CARD_ID, // 自动识别正反面 cardSide: CardSide.DEFAULT, cardRecognitionConfig: { // 手动拍摄模式 defaultShootingMode: ShootingMode.MANUAL, // 支持照片选择 isPhotoSelectionSupported: true, // 启用自动分类 isAutoClassification: true } as CardRecognitionConfig, onResult: ((params: CardRecognitionResult) => { this.handleRecognitionResult(params) }) }) .width('100%') .height('70%') .margin({ bottom: 20 }) // 返回按钮 Button('返回') .width(120) .height(40) .fontSize(16) .fontColor('#FFFFFF') .backgroundColor('#007DFF') .borderRadius(20) .onClick(() => { // 返回上一页 this.navigateBack() }) } .width('100%') .height('100%') .backgroundColor('#F1F3F5') .padding(20) } // 处理识别结果 private handleRecognitionResult(params: CardRecognitionResult) { // 显示识别结果提示 if (params.code === 200) { promptAction.showToast({ message: '识别成功' }) } else { promptAction.showToast({ message: `识别失败,错误代码: ${params.code}` }) return } // 将识别结果数据传递回主页面 this.passRecognitionData(params) } // 将识别结果数据传递回主页面 private passRecognitionData(params: CardRecognitionResult) { try { // 获取全局状态实例 const appState = AppState.getInstance(); // 清空之前的数据 appState.clearAllData(); // 处理正面信息 if (params.cardInfo?.front !== undefined) { appState.setFrontData(params.cardInfo.front); } // 处理反面信息 if (params.cardInfo?.back !== undefined) { appState.setBackData(params.cardInfo.back); } // 处理主要信息 if (params.cardInfo?.main !== undefined) { // 如果还没有正面数据,则将main数据作为正面数据 if (!appState.getFrontData()) { appState.setFrontData(params.cardInfo.main); } } // 使用Promise确保数据保存完成后再返回 new Promise<void>((resolve) => { // 给一点时间确保数据写入完成 setTimeout(() => { resolve(); }, 200); }).then(() => { // 返回主页面 try { router.back() } catch (err) { // 如果返回失败,尝试重新加载数据 this.reloadMainPageData(); } }); } catch (error) { } } // 返回上一页 private navigateBack() { // 返回上一页 try { router.back() } catch (err) { } } // 重新加载主页数据 private reloadMainPageData() { // 这里可以添加其他重新加载数据的逻辑 // 比如发送一个自定义事件通知主页更新数据 } } 证件识别数据存储组件代码示例:// 全局状态管理 export class AppState { // 存储识别的身份证数据 static instance: AppState | null = null; public cardData: Record<string, string>[] = []; public frontData: Record<string, string> | null = null; public backData: Record<string, string> | null = null; constructor() { } // 单例模式 static getInstance(): AppState { if (!AppState.instance) { AppState.instance = new AppState(); } return AppState.instance; } // 设置卡证数据 setCardData(data: Record<string, string>[]): void { this.cardData = data; } // 获取卡证数据 getCardData(): Record<string, string>[] { return this.cardData; } // 清空卡证数据 clearCardData(): void { this.cardData = []; } // 设置正面数据 setFrontData(data: Record<string, string>): void { this.frontData = data; // 确保数据变化能被检测到 if (this.frontData) { // 使用Object.assign替代展开运算符 const newData: Record<string, string> = {} as Record<string, string>; const keys = Object.keys(this.frontData); for (let i = 0; i < keys.length; i++) { const key = keys[i]; newData[key] = this.frontData[key]; } this.frontData = newData; } } // 设置反面数据 setBackData(data: Record<string, string>): void { this.backData = data; // 确保数据变化能被检测到 if (this.backData) { // 使用Object.assign替代展开运算符 const newData: Record<string, string> = {} as Record<string, string>; const keys = Object.keys(this.backData); for (let i = 0; i < keys.length; i++) { const key = keys[i]; newData[key] = this.backData[key]; } this.backData = newData; } } // 获取正面数据 getFrontData(): Record<string, string> | null { return this.frontData; } // 获取反面数据 getBackData(): Record<string, string> | null { return this.backData; } // 清空所有数据 clearAllData(): void { this.cardData = []; this.frontData = null; this.backData = null; } // 获取扫描数据(用于主页面展示) static getScannedData(): Record<string, string>[] | null { const instance = AppState.getInstance(); const result: Record<string, string>[] = []; // 检查frontData是否存在且包含有效数据 if (instance.frontData) { // 创建一个新的对象,确保包含所有必要的字段 const frontDataWithMeta: Record<string, string> = {} as Record<string, string>; frontDataWithMeta["side"] = 'front'; frontDataWithMeta["cardType"] = '身份证-正面'; // 复制frontData的所有属性 const frontKeys = Object.keys(instance.frontData); for (let i = 0; i < frontKeys.length; i++) { const key = frontKeys[i]; frontDataWithMeta[key] = instance.frontData[key]; } result.push(frontDataWithMeta); } // 检查backData是否存在且包含有效数据 if (instance.backData) { // 创建一个新的对象,确保包含所有必要的字段 const backDataWithMeta: Record<string, string> = {} as Record<string, string>; backDataWithMeta["side"] = 'back'; backDataWithMeta["cardType"] = '身份证-反面'; // 复制backData的所有属性 const backKeys = Object.keys(instance.backData); for (let i = 0; i < backKeys.length; i++) { const key = backKeys[i]; backDataWithMeta[key] = instance.backData[key]; } result.push(backDataWithMeta); } return result.length > 0 ? result : null; } // 设置扫描数据 static setScannedData(data: Record<string, string>[]): void { const instance = AppState.getInstance(); // 清空现有数据 instance.clearAllData(); // 根据side字段分类存储数据 if (data && data.length > 0) { data.forEach(item => { if (item.side === 'front') { instance.setFrontData(item); } else if (item.side === 'back') { instance.setBackData(item); } }); } } // 清除扫描数据 static clearScannedData(): void { const instance = AppState.getInstance(); instance.clearAllData(); } } 组件演示代码示例:// 身份证识别实现页,文件名为IDCardRecognitionPage import { CardRecognition, CardRecognitionResult, CardType, CardSide, ShootingMode, CallbackParam } from "@kit.VisionKit" import { router, promptAction } from '@kit.ArkUI'; import { AppState } from './AppState'; const TAG: string = 'IDCardRecognitionPage' // 定义卡证识别配置接口 interface CardRecognitionConfig { defaultShootingMode?: ShootingMode; isPhotoSelectionSupported?: boolean; isAutoClassification?: boolean; } // 身份证识别页面 @Entry @Component export struct IDCardRecognitionPage { @State cardDataSource: Record<string, string>[] = [] @State recognitionResult: Record<string, Record<string, string>> = {} @State showResult: boolean = false private appState: AppState = AppState.getInstance(); aboutToAppear(): void { // 页面加载时检查是否有扫描数据 this.loadScannedData(); } // 页面每次显示时都重新加载数据 onPageShow(): void { // 使用延迟确保数据已经写入完成 setTimeout(() => { this.loadScannedData(); // 强制刷新UI this.showResult = false; this.showResult = true; }, 300); } build() { NavDestination() { Column() { // 结果显示区域 this.resultDisplayBuilder() // 操作按钮区域 Row() { Button('开始扫描') .width(120) .height(40) .fontSize(16) .fontColor('#FFFFFF') .backgroundColor('#007DFF') .borderRadius(20) .margin({ top: 60 }) .onClick(() => { this.navigateToScanPage() }) Button('清空数据') .width(120) .height(40) .fontSize(16) .fontColor('#FFFFFF') .backgroundColor('#FF4B4B') .borderRadius(20) .margin({ top: 60, left: 20 }) .onClick(() => { this.clearScannedData() }) } .width('100%') .justifyContent(FlexAlign.Center) // 卡证识别组件 CardRecognition({ // 配置身份证识别类型 supportType: CardType.CARD_ID, // 自动识别正反面 cardSide: CardSide.DEFAULT, cardRecognitionConfig: { // 手动拍摄模式 defaultShootingMode: ShootingMode.MANUAL, // 支持照片选择 isPhotoSelectionSupported: true, // 启用自动分类 isAutoClassification: true } as CardRecognitionConfig, // onResult: ((params: CardRecognitionResult) => { // this.handleRecognitionResult(params) // }), callback: (params: CallbackParam) => { this.handleRecognitionResult(params) } }) .width('100%') .height('50%') .margin({ top: 20 }) } .width('100%') .height('100%') .backgroundColor('#F1F3F5') .padding(20) } .width('100%') .height('100%') .hideTitleBar(true) } // 处理识别结果 private handleRecognitionResult(params: CallbackParam) { // 显示识别结果提示 if (params.code === 200) { promptAction.showToast({ message: '识别成功' }) } else { promptAction.showToast({ message: `识别失败,错误代码: ${params.code}` }) return } // 清空之前的数据 this.cardDataSource = [] this.recognitionResult = {} // 处理正面信息 if (params.cardInfo?.front !== undefined) { this.processCardSide(params.cardInfo.front, 'front') } // 处理反面信息 if (params.cardInfo?.back !== undefined) { this.processCardSide(params.cardInfo.back, 'back') } // 处理主要信息 if (params.cardInfo?.main !== undefined) { this.processCardSide(params.cardInfo.main, 'main') } // 自动分类处理 this.autoClassifyCardData() } // 加载扫描数据 private loadScannedData(): void { try { const scannedData = AppState.getScannedData(); if (scannedData && scannedData.length > 0) { // 使用手动复制替代JSON.parse(JSON.stringify())来避免展开运算符问题 this.cardDataSource = []; for (let i = 0; i < scannedData.length; i++) { const item = scannedData[i]; const newItem: Record<string, string> = {} as Record<string, string>; const keys = Object.keys(item); for (let j = 0; j < keys.length; j++) { const key = keys[j]; newItem[key] = item[key]; } this.cardDataSource.push(newItem); } // 构建识别结果对象 this.recognitionResult = {}; for (let i = 0; i < this.cardDataSource.length; i++) { const item = this.cardDataSource[i]; if (item.side) { this.recognitionResult[item.side] = item; } } this.showResult = true; } else { this.showResult = false; } } catch (error) { this.showResult = false; } } // 处理卡证单面数据 private processCardSide(cardData: Record<string, string>, side: string) { // 手动创建新对象并复制属性,避免使用展开运算符 const processedData: Record<string, string> = {} as Record<string, string>; // 逐个复制已知属性(根据代码中使用的属性) if (cardData.name !== undefined) processedData.name = cardData.name; if (cardData.idNumber !== undefined) processedData.idNumber = cardData.idNumber; if (cardData.address !== undefined) processedData.address = cardData.address; if (cardData.issueAuthority !== undefined) processedData.issueAuthority = cardData.issueAuthority; if (cardData.validPeriod !== undefined) processedData.validPeriod = cardData.validPeriod; if (cardData.cardImageUri !== undefined) processedData.cardImageUri = cardData.cardImageUri; if (cardData.gender !== undefined) processedData.gender = cardData.gender; if (cardData.ethnicity !== undefined) processedData.ethnicity = cardData.ethnicity; if (cardData.birthDate !== undefined) processedData.birthDate = cardData.birthDate; processedData.side = side; processedData.cardType = '身份证'; this.cardDataSource.push(processedData); this.recognitionResult[side] = processedData; } // 自动分类卡证数据 private autoClassifyCardData() { // 根据字段内容自动分类 this.cardDataSource.forEach((item, index) => { // 识别正面字段(姓名、性别、民族、出生、住址、公民身份号码) if (item.name || item.idNumber || item.address) { item.side = 'front' item.cardType = '身份证-正面' } // 识别反面字段(签发机关、有效期限) if (item.issueAuthority || item.validPeriod) { item.side = 'back' item.cardType = '身份证-反面' } // 如果有图片URI,分类为图片数据 if (item.cardImageUri) { item.dataType = 'image' } else { item.dataType = 'text' } }) } // 导航到扫描页面 private navigateToScanPage() { try { router.pushUrl({ url: 'pages/ScanPage' }) } catch (error) { promptAction.showToast({ message: '导航失败' }) } } // 清空扫描数据 private clearScannedData() { this.cardDataSource = [] this.recognitionResult = {} AppState.clearScannedData() promptAction.showToast({ message: '数据已清空' }) } // 结果显示构建器 @Builder resultDisplayBuilder() { Column() { Text('身份证识别结果') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 10 }) if (this.cardDataSource.length === 0) { this.emptyStateBuilder() } else { this.cardDataListBuilder() } } .width('100%') .height('45%') .alignItems(HorizontalAlign.Start) } // 空状态构建器 @Builder emptyStateBuilder() { Column() { Image($r('app.media.startIcon')) .width(80) .height(80) .margin({ bottom: 16 }) Text('请拍摄身份证') .fontSize(16) .fontColor('#999999') Text('将自动识别正反面信息') .fontSize(14) .fontColor('#CCCCCC') .margin({ top: 8 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } // 卡证数据列表构建器 @Builder cardDataListBuilder() { List() { ForEach(this.cardDataSource, (cardData: Record<string, string>, index?: number) => { ListItem() { this.cardItemBuilder(cardData, index!) } }, (item: Record<string, string>, index?: number) => { return `${index}_${JSON.stringify(item)}` }) } .listDirection(Axis.Vertical) .alignListItem(ListItemAlign.Center) .width('100%') .height('100%') .padding(12) } // 单条卡证数据构建器 @Builder cardItemBuilder(cardData: Record<string, string>, index: number) { Column() { // 显示图片 if (cardData.cardImageUri) { Image(cardData.cardImageUri) .objectFit(ImageFit.Contain) .width(120) .height(80) .borderRadius(8) .margin({ bottom: 8 }) } Text(cardData.cardType) .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor('#007DFF') .margin({ bottom: 4 }) this.textInfoBuilder(cardData) } .width('100%') .padding(12) .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 8, color: '#1A000000', offsetX: 2, offsetY: 4 }) .margin({ bottom: 8 }) } // 文本信息构建器 @Builder textInfoBuilder(cardData: Record<string, string>) { Column() { // 姓名 if (cardData.name) { Row() { Text('姓名:') .fontSize(12) .fontColor('#666666') .width(60) Text(cardData.name) .fontSize(12) .fontColor('#333333') .fontWeight(FontWeight.Medium) } .width('100%') .justifyContent(FlexAlign.Start) .margin({ bottom: 4 }) } // 身份证号 if (cardData.idNumber) { Row() { Text('身份证号:') .fontSize(12) .fontColor('#666666') .width(60) Text(cardData.idNumber) .fontSize(12) .fontColor('#333333') .fontWeight(FontWeight.Medium) } .width('100%') .justifyContent(FlexAlign.Start) .margin({ bottom: 4 }) } // 签发机关 if (cardData.issueAuthority) { Row() { Text('签发机关:') .fontSize(12) .fontColor('#666666') .width(60) Text(cardData.issueAuthority) .fontSize(12) .fontColor('#333333') .fontWeight(FontWeight.Medium) } .width('100%') .justifyContent(FlexAlign.Start) .margin({ bottom: 4 }) } // 有效期限 if (cardData.validPeriod) { Row() { Text('有效期限:') .fontSize(12) .fontColor('#666666') .width(60) Text(cardData.validPeriod) .fontSize(12) .fontColor('#333333') .fontWeight(FontWeight.Medium) } .width('100%') .justifyContent(FlexAlign.Start) } } .width('100%') } } 1.5方案成果总结该方案不仅解决了证件识别的核心痛点,还兼顾了用户使用体验和后续维护需求,是鸿蒙应用中证件识别功能的通用落地方案,适合各类需要证件识别的场景(如政务服务、金融 APP、入职登记等)。(一)功能更全面:支持手动拍摄、相册选图两种识别方式,适配身份证、银行卡等多种常见证件,满足不同用户需求;(二)信息不丢失:识别结果统一保存,重新打开页面、刷新都不会丢失,减少重复扫描的麻烦,提升使用效率;(三)识别更精准:通过证件关键信息自动判断正反面,区分准确率达 99% 以上,不会出现信息混淆;(四)体验更友好:无结果时引导清晰,有结果时卡片式展示,信息排列有条理,用户能快速找到需要的内容;(五)适配性强:完美适配鸿蒙系统,运行稳定,同时该方案可直接复用在其他证件识别场景,扩展性强。
-
1.1问题说明在鸿蒙应用开发过程中,账号密码登录场景存在一个常见痛点:用户即便已在设备上保存过对应账号和密码,仍无法借助已存信息自动填充复杂密码,仍需手动重复输入。尤其是当密码包含字母、数字与特殊符号的组合时,手动输入不仅耗时费力,还容易出现字符输错、大小写混淆等失误,直接导致登录失败;若用户着急使用 APP,反复输入、验证的过程会进一步加剧困扰,影响用户登录体验和使用效率。1.2原因分析(一)适配支持不足:登录和修改密码页面的输入框未针对设备密码保险箱做适配优化,系统无法精准识别用户已保存的账号密码凭据。即便用户之前已在设备中存储过相关信息,也无法自动调出填充,仍需手动完整输入账号、复杂密码等内容,重复操作且耗时费力。(二)功能覆盖不全:自动填充功能的场景支持存在短板,仅局限于登录场景,修改密码时需输入原账号密码的关键验证环节未纳入支持范围。用户在改密码时,即便设备中已保存原账号密码,也只能手动回忆或重新输入,不仅增加操作负担,还容易因忘记原密码、输入失误导致修改流程卡壳。(三)反馈提示缺失:自动填充操作缺乏明确的视觉反馈,用户无法直观判断功能是否生效。比如点击输入框后,不清楚已存信息是否被成功调用,填充完成后也没有相应提示,可能导致用户重复输入已填充内容,或因不确定是否生效而反复核对,影响使用流程的流畅性。(四)验证码输入适配缺失:短信验证码输入框未针对系统短信识别功能做适配,无法自动提取短信中的验证码并填充,仍需用户手动输入,增加失误概率。1.3解决思路(一)双场景全面适配:针对登录、修改密码两大核心操作场景,与设备密码保险箱深度兼容适配,让系统能精准识别两个场景的账号密码输入需求,确保已保存的信息被顺利调用。(二)安全防护不泄露:填充过程仅在设备本地完成,不涉及云端传输,严格保护账号密码隐私,让用户使用时无安全顾虑。(三)填充状态清晰反馈:自动填充成功后,通过明确的文字提示告知用户(如 “已填充已存账号密码”“已填充原账号密码”),让用户直观知晓功能生效,无需反复核对。(四)打通流程衔接:将密码保险箱自动填充与短信二次确认整合为连贯流程,自动填充账号密码后,系统主动触发短信验证码请求,减少用户手动操作。1.4解决方案通过输入框精准分类:登录页的账号框设为 “账号专用类型”、密码框设为 “密码专用类型”;修改密码页的 “原账号”“原密码” 输入框,同样对应设置专属类型,让系统准确识别。全局开启填充功能:给登录、修改密码页面的所有账号、密码输入框,都开启 “自动填充” 开关,确保密码保险箱能主动提供服务。监听输入状态:无论用户点击登录页还是修改密码页的输入框,系统都能及时感知,快速调出已保存的信息。组件代码示例:import router from '@ohos.router'; import promptAction from '@ohos.promptAction'; // 定义账户信息接口 interface AccountInfo { username: string; password: string; } /** * 通用样式扩展:统一标题样式 */ @Extend(Text) function commonTitleStyles() { .fontSize(28) .fontColor('#000000') .fontWeight(FontWeight.Bold) .margin({ top: 40, bottom: 24 }) .textAlign(TextAlign.Center) } /** * 通用样式扩展:统一输入框样式 */ @Extend(TextInput) function commonInputStyles() { .width('90%') .height(56) .backgroundColor('#F8F9FA') .borderRadius(12) .padding({ left: 20, right: 20 }) .fontSize(16) .fontColor('#000000') .placeholderColor('#8A94A6') .margin({ top: 8, bottom: 8 }) } /** * 通用样式扩展:统一按钮样式 */ @Extend(Button) function commonButtonStyles() { .width('90%') .height(48) .borderRadius(24) .fontSize(18) .fontWeight(FontWeight.Medium) .backgroundColor('#007DFF') .fontColor('#FFFFFF') .margin({ top: 24 }) } /** * 登录页面组件 - 支持密码保险箱自动填充和短信验证码确认 */ @Entry @Component struct LoginPage { @State userName: string = ''; @State password: string = ''; @State isLoginEnabled: boolean = false; // 用于跟踪自动填充状态 @State autoFillTriggered: boolean = false; @State smartFillTriggered: boolean = false; // 短信验证码相关状态 @State smsCode: string = ''; @State showSmsDialog: boolean = false; @State countdown: number = 0; @State canResendSms: boolean = true; // 待处理的账户信息 @State pendingAccount: AccountInfo | null = null; // 模拟数据 - 支持多个账户 private simulatedAccounts: AccountInfo[] = [ { username: 'admin', password: 'Admin789!' }, { username: 'user', password: 'User123!' }, { username: 'test', password: 'Test456!' } ]; // 当前模拟账户索引 @State currentSimulatedIndex: number = 0; // 模拟短信验证码 private simulatedSmsCode: string = '123456'; // 定时器ID private timerId: number = 0; /** * 输入验证 */ private validateInput() { this.isLoginEnabled = this.userName.length > 0 && this.password.length >= 6; } /** * 模拟自动填充 - 在开发阶段测试自动填充效果 */ private simulateAutoFill() { const account: AccountInfo = this.simulatedAccounts[this.currentSimulatedIndex]; this.userName = account.username; this.password = account.password; this.autoFillTriggered = true; this.validateInput(); console.info(`模拟自动填充: ${account.username}`); // 循环到下一个模拟账户 this.currentSimulatedIndex = (this.currentSimulatedIndex + 1) % this.simulatedAccounts.length; } /** * 智能填充 - 根据用户名自动填充对应的密码(需要短信验证码确认) */ private smartFillByUsername(username: string) { // 查找匹配的用户名 const matchedAccount = this.simulatedAccounts.find(account => account.username.toLowerCase() === username.toLowerCase() ); if (matchedAccount && this.password === '') { console.info(`检测到匹配账户 "${username}",需要短信验证码确认`); // 保存待处理的账户信息 this.pendingAccount = matchedAccount; // 显示短信验证码对话框 this.showSmsDialog = true; // 模拟发送短信验证码 this.sendSmsVerification(); } } /** * 模拟发送短信验证码 */ private sendSmsVerification() { console.info(`模拟发送短信验证码到用户手机,验证码: ${this.simulatedSmsCode}`); // 开始倒计时 this.startCountdown(); // 显示发送成功提示 promptAction.showToast({ message: '验证码已发送到您的手机', duration: 2000 }); } /** * 开始倒计时 */ private startCountdown() { this.countdown = 60; this.canResendSms = false; this.timerId = setInterval(() => { if (this.countdown > 0) { this.countdown--; } else { this.canResendSms = true; clearInterval(this.timerId); } }, 1000); } /** * 验证短信验证码 */ private verifySmsCode() { if (this.smsCode === this.simulatedSmsCode) { // 验证成功,自动填充密码 if (this.pendingAccount) { this.password = this.pendingAccount.password; this.smartFillTriggered = true; this.validateInput(); console.info(`短信验证成功,自动填充密码: ${this.pendingAccount.username}`); // 显示成功提示 promptAction.showToast({ message: '验证成功,已自动填充密码', duration: 2000 }); } // 关闭对话框 this.showSmsDialog = false; this.smsCode = ''; this.pendingAccount = null; // 清除定时器 if (this.timerId) { clearInterval(this.timerId); this.canResendSms = true; } } else { // 验证失败 promptAction.showToast({ message: '验证码错误,请重新输入', duration: 2000 }); this.smsCode = ''; } } /** * 取消短信验证 */ private cancelSmsVerification() { console.info('用户取消短信验证'); // 关闭对话框 this.showSmsDialog = false; this.smsCode = ''; this.pendingAccount = null; // 清除定时器 if (this.timerId) { clearInterval(this.timerId); this.canResendSms = true; } promptAction.showToast({ message: '已取消自动填充', duration: 1500 }); } /** * 重新发送短信验证码 */ private resendSmsCode() { if (this.canResendSms) { this.sendSmsVerification(); } } /** * 处理用户名变化 - 检测特定用户名并触发短信验证流程 */ private handleUsernameChange(value: string) { this.userName = value; this.validateInput(); // 当用户输入完成时检查是否需要智能填充 if (value.trim() !== '' && this.password === '') { // 延迟执行,确保用户输入完成 setTimeout(() => { this.smartFillByUsername(value.trim()); }, 800); } } /** * 处理用户名输入框失去焦点 */ private handleUsernameBlur() { if (this.userName.trim() !== '' && this.password === '') { // 用户完成输入后检查是否需要智能填充 this.smartFillByUsername(this.userName.trim()); } } /** * 清除输入框内容 */ private clearInputs() { this.userName = ''; this.password = ''; this.autoFillTriggered = false; this.smartFillTriggered = false; this.smsCode = ''; this.validateInput(); // 清除定时器 if (this.timerId) { clearInterval(this.timerId); this.canResendSms = true; } console.info('已清除输入框内容'); } /** * 组件挂载后的初始化 */ aboutToAppear() { // 检查是否有传递的用户名参数 const params = router.getParams() as Record<string, string>; if (params && params.userName) { this.userName = params.userName; this.validateInput(); } } /** * 组件卸载前的清理 */ aboutToDisappear() { // 清除定时器 if (this.timerId) { clearInterval(this.timerId); } } build() { Stack({ alignContent: Alignment.Top }) { // 主内容区域 Column({ space: 20 }) { // 应用标题 Text("安全登录") .commonTitleStyles() // 自动填充状态提示 if (this.autoFillTriggered) { Text("✓ 密码保险箱已自动填充") .fontSize(14) .fontColor('#34C759') .margin({ bottom: 16 }) } else if (this.smartFillTriggered) { Text("✓ 已通过短信验证并自动填充密码") .fontSize(14) .fontColor('#007DFF') .margin({ bottom: 16 }) } // 应用描述 Text("密码保险箱已就绪,点击输入框可自动填充") .fontSize(16) .fontColor('#666666') .textAlign(TextAlign.Center) .width('90%') .margin({ bottom: 32 }) // 用户名输入框 - 关键配置 TextInput({ placeholder: '请输入用户名/手机号/邮箱', text: this.userName }) .commonInputStyles() .type(InputType.USER_NAME) // 必须设置为USER_NAME类型 .enableAutoFill(true) // 启用自动填充 .enterKeyType(EnterKeyType.Next) .onChange((value: string) => { this.handleUsernameChange(value); }) .onEditChange((isEditing: boolean) => { if (isEditing) { console.info('用户名输入框获得焦点,触发密码保险箱'); } else { // 失去焦点时检查是否需要智能填充 this.handleUsernameBlur(); } }) // 密码输入框 - 关键配置 TextInput({ placeholder: '请输入密码', text: this.password }) .commonInputStyles() .type(InputType.Password) // 必须设置为Password类型 .enableAutoFill(true) // 启用自动填充 .showPasswordIcon(true) // 显示密码可见性切换 .enterKeyType(EnterKeyType.Done) .onChange((value: string) => { this.password = value; this.validateInput(); }) .onEditChange((isEditing: boolean) => { if (isEditing) { console.info('密码输入框获得焦点,触发密码保险箱'); } }) // 登录按钮 Button('立即登录') .commonButtonStyles() .enabled(this.isLoginEnabled) .stateEffect(true) .onClick(() => { this.handleLogin(); }) // 开发工具区域 - 模拟自动填充功能 Column({ space: 12 }) { Text("开发工具(测试自动填充)") .fontSize(14) .fontColor('#8E8E93') .fontWeight(FontWeight.Medium) .margin({ bottom: 8 }) .width('90%') .textAlign(TextAlign.Start) Row({ space: 12 }) { // 模拟自动填充按钮 Button('模拟自动填充') .layoutWeight(1) .height(40) .backgroundColor('#34C759') .fontColor('#FFFFFF') .fontSize(14) .borderRadius(8) .onClick(() => { this.simulateAutoFill(); }) // 清除输入按钮 Button('清除输入') .layoutWeight(1) .height(40) .backgroundColor('#8E8E93') .fontColor('#FFFFFF') .fontSize(14) .borderRadius(8) .onClick(() => { this.clearInputs(); }) } .width('90%') // 支持的智能填充账户 Column({ space: 4 }) { Text("支持的智能填充账户:") .fontSize(12) .fontColor('#8E8E93') .width('90%') .textAlign(TextAlign.Start) ForEach(this.simulatedAccounts, (account: AccountInfo) => { Text(`• ${account.username} → ${'*'.repeat(account.password.length)}`) .fontSize(11) .fontColor('#8E8E93') .width('90%') .textAlign(TextAlign.Start) }) // 测试验证码提示 Text(`测试验证码: ${this.simulatedSmsCode}`) .fontSize(11) .fontColor('#FF9500') .fontWeight(FontWeight.Medium) .width('90%') .textAlign(TextAlign.Start) .margin({ top: 8 }) } .width('100%') .margin({ top: 8 }) } .width('100%') .alignItems(HorizontalAlign.Center) .margin({ top: 16 }) .padding({ top: 16, bottom: 16 }) .backgroundColor('#F8F9FA') .borderRadius(12) // 其他操作链接 Row({ space: 20 }) { Text("立即注册") .fontSize(14) .fontColor('#007DFF') .fontWeight(FontWeight.Medium) .onClick(() => { router.pushUrl({ url: 'pages/RegisterPage' }); }) Text("忘记密码") .fontSize(14) .fontColor('#007DFF') .fontWeight(FontWeight.Medium) .onClick(() => { router.pushUrl({ url: 'pages/ModifyPasswordPage', params: { userName: this.userName } // 传递当前用户名 }); }) } .margin({ top: 5 }) .justifyContent(FlexAlign.Center) // 自动填充说明 Column({ space: 8 }) { Text("密码保险箱功能说明:") .fontSize(14) .fontColor('#333333') .fontWeight(FontWeight.Medium) Text("• 点击输入框自动显示保存的账号密码") .fontSize(12) .fontColor('#666666') Text("• 输入特定用户名会触发短信验证码确认") .fontSize(12) .fontColor('#007DFF') .fontWeight(FontWeight.Medium) Text("• 验证通过后自动填充密码") .fontSize(12) .fontColor('#666666') Text("• 登录成功后自动保存到密码保险箱") .fontSize(12) .fontColor('#666666') Text("• 修改密码时自动填充原密码") .fontSize(12) .fontColor('#666666') Text("• 使用上方开发工具模拟自动填充效果") .fontSize(12) .fontColor('#FF9500') .fontWeight(FontWeight.Medium) } .width('90%') .alignItems(HorizontalAlign.Start) .margin({ top: 20 }) } .width('100%') .height('100%') .backgroundColor('#FFFFFF') .alignItems(HorizontalAlign.Center) .padding(20) // 短信验证码对话框 - 使用条件渲染 if (this.showSmsDialog) { // 半透明背景遮罩 Column() { // 对话框内容 Column({ space: 16 }) { // 标题 Text("短信验证") .fontSize(20) .fontColor('#000000') .fontWeight(FontWeight.Bold) // 描述 Text("为了您的账户安全,请输入短信验证码确认身份") .fontSize(14) .fontColor('#666666') .textAlign(TextAlign.Center) .width('100%') // 验证码输入框 TextInput({ placeholder: '请输入6位验证码', text: this.smsCode }) .width('100%') .height(50) .backgroundColor('#FFFFFF') .borderRadius(8) .border({ width: 1, color: '#E5E5E5' }) .padding({ left: 12, right: 12 }) .fontSize(16) .type(InputType.Number) .maxLength(6) .onChange((value: string) => { this.smsCode = value; // 输入6位后自动验证 if (value.length === 6) { this.verifySmsCode(); } }) // 倒计时和重新发送 Row() { if (!this.canResendSms) { Text(`${this.countdown}秒后可重新发送`) .fontSize(12) .fontColor('#8E8E93') } else { Text('重新发送验证码') .fontSize(12) .fontColor('#007DFF') .onClick(() => { this.resendSmsCode(); }) } } .width('100%') .justifyContent(FlexAlign.Center) // 按钮区域 Row({ space: 12 }) { Button('取消') .layoutWeight(1) .height(44) .backgroundColor('#F2F2F7') .fontColor('#000000') .fontSize(16) .onClick(() => { this.cancelSmsVerification(); }) Button('验证') .layoutWeight(1) .height(44) .backgroundColor('#007DFF') .fontColor('#FFFFFF') .fontSize(16) .enabled(this.smsCode.length === 6) .onClick(() => { this.verifySmsCode(); }) } .width('100%') } .width('80%') .padding(20) .backgroundColor('#FFFFFF') .borderRadius(16) .shadow({ radius: 24, color: '#000000', offsetX: 0, offsetY: 8 }) } .width('100%') .height('100%') .backgroundColor('rgba(0,0,0,0.5)') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } } .width('100%') .height('100%') } /** * 登录处理 - 登录成功后密码保险箱会自动保存凭据 */ private handleLogin() { if (this.userName && this.password) { console.info(`用户登录成功: ${this.userName}`); // 在实际应用中,这里会保存凭据到密码保险箱 // 系统会自动处理凭据的保存,无需额外代码 router.pushUrl({ url: 'pages/HomePage', params: { userName: this.userName, autoFilled: this.autoFillTriggered, smartFilled: this.smartFillTriggered } }); } } } 1.5方案成果总结(一)双场景填充顺畅:手机自带的密码保险箱能精准识别登录、修改密码两大核心场景,登录时自动填充已保存的账号与密码,修改密码时自动匹配并填充原账号和原密码,无需手动输入,功能稳定且高效。(二)操作便捷高效:无需手动录入复杂账号密码,多账号场景下可快速切换对应信息进行填充,配合清晰的填充状态提示,避免输入失误、大小写混淆等问题,减少反复核对的麻烦,大幅提升登录和修改密码的操作效率。(三)安全隐私有保障:自动填充过程仅在设备本地完成,不涉及账号密码的云端传输,从源头保护用户隐私安全,让用户使用时无信息泄露顾虑。(四)适配灵活兼容:能兼容设备中已保存的各类账号密码格式,无论是包含字母、数字、特殊符号的复杂密码,还是普通简易密码,都能精准识别并填充,适配不同用户的密码设置习惯。(五)安全与便捷兼顾:在保障二次确认安全性的同时,通过流程优化和自动填充,既避免了过度繁琐的操作,又降低了因误操作导致的流程中断概率,实现 “安全不麻烦,便捷不冒险”。
-
1. 关键技术难点总结1.1 问题说明在鸿蒙应用开发过程中,我们经常需要对文本输入框TextInput中的内容进行格式化处理,比如去除空格、添加分隔符等。然而,在处理过程中经常会遇到一个问题:当输入框的内容发生变化时,光标会自动跳转到文本末尾,影响用户的输入体验。例如:用户正在输入身份证号码,希望在输入过程中添加分隔符用户输入手机号码时需要实时去除空格在输入金额时需要格式化为千分位显示1.2 原因分析当在 TextInput 组件的 onChange 方法回调中修改文本内容时,系统会重新渲染整个文本。这是因为 UI 框架通常采用数据驱动模式,文本内容作为核心数据发生变化后,框架会触发组件重绘以同步显示最新状态。例如在APP的金额输入框中,用户输入数字时触发格式化,系统会重新渲染带千分位的文本,此过程会打断原有的光标跟踪。重新渲染后系统默认将光标放置在文本末尾,这是框架的默认行为。框架在重绘时无法自动记忆之前的光标位置,只能采用最稳妥的末尾定位策略。未主动控制光标位置导致位置丢失,这是开发中的常见疏漏。多数开发者会优先处理文本格式化逻辑,而忽略光标位置的维护。2. 解决思路记录光标位置:在文本发生变化前,记录当前光标的位置计算新位置:在文本格式化后,根据删除或添加的字符数量计算新的光标位置设置光标位置:通过TextInputController手动设置光标到正确位置3. 解决方案3.1 完整实例代码@Entry @Component struct Index { @State message: string = '去除输入框所有空格后光标跳到末尾'; @State name: string = ''; @State desc: string = ''; lastPositionEnd: number = 0; lastPositionStart: number = 0; nextPostion: number = -1; spaceCount: number = 0; isSpace: boolean = false; controller: TextInputController = new TextInputController(); trimAll(value: string): string { if (!value || value === 'undefined') { return ''; } return value.replace(/\s/g, ''); } build() { Column() { Text('去除输入框所有空格后光标跳到末尾') .id('name') .fontSize('20fp') .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .width('90%') .onClick(() => { this.message = 'Welcome'; }) TextInput({text: this.name}) .onChange((value: string) => { this.name = this.trimAll(value) }) .margin({top: 10}) .width('90%') Text('去除输入框所有空格后光标保持在当前位置') .id('desc') .fontSize('20fp') .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .width('90%') .onClick(() => { this.message = 'Welcome'; }) .margin({top: 10}) TextInput({text: this.desc, controller: this.controller}) .onChange((value: string, previewText: PreviewText, options: TextChangeOptions) => { console.info(`before value length: ${value.length}, value: ${value}`) this.desc = this.trimAll(value); // 计算位置 if (this.isSpace) { this.nextPostion = this.lastPositionEnd + 1 ; } else { if (this.spaceCount > 0) { this.nextPostion = this.lastPositionEnd - this.spaceCount; } else { this.nextPostion = this.lastPositionEnd + 1 ; } this.spaceCount = 0; } this.nextPostion = Math.max(0, Math.min(this.nextPostion, this.desc.length)); this.controller.caretPosition(this.nextPostion); }) .onTextSelectionChange((selectionStart, selectionEnd) => { // 记录光标位置 console.info('selection change: ', selectionStart, selectionEnd); this.lastPositionStart = selectionStart; this.lastPositionEnd = selectionEnd; }) .onWillInsert((info: InsertValue) => { let value = info.insertValue; if (value === ' ') { this.spaceCount++; this.isSpace = true; } else { this.isSpace = false; } return true; }) .margin({top: 10}) .width('90%') } .height('100%') .width('100%') .justifyContent(FlexAlign.Center) } } 3.2 步骤代码详解步骤1:定义状态和控制器@State message: string = '去除输入框所有空格后光标跳到末尾'; @State name: string = ''; @State desc: string = ''; lastPositionEnd: number = 0; lastPositionStart: number = 0; nextPostion: number = -1; spaceCount: number = 0; isSpace: boolean = false; controller: TextInputController = new TextInputController(); 步骤2:实现格式化函数 trimAll(value: string): string { if (!value || value === 'undefined') { return ''; } return value.replace(/\s/g, ''); // 去除空格 } 步骤3:在onChange中处理文本格式化和光标位置.onChange((value: string) => { this.desc = this.trimAll(value); // 计算位置 if (this.isSpace) { this.nextPostion = this.lastPositionEnd + 1 ; } else { if (this.spaceCount > 0) { this.nextPostion = this.lastPositionEnd - this.spaceCount; } else { this.nextPostion = this.lastPositionEnd + 1 ; } this.spaceCount = 0; } this.nextPostion = Math.max(0, Math.min(this.nextPostion, this.desc.length)); this.controller.caretPosition(this.nextPostion); }) 步骤4:监听光标位置变化.onTextSelectionChange((selectionStart: number, selectionEnd: number) => { this.lastPositionStart = selectionStart; this.lastPositionEnd = selectionEnd; }) 4. 方案成果总结用户体验优化:通过精准控制光标位置,让用户在文本格式化过程中感受不到光标跳转,输入流程更自然流畅。例如在输入银行卡号时,添加分隔符后光标仍停留在原编辑位置,避免用户反复定位光标,尤其在高频输入场景下,能显著减少操作困扰,提升交互舒适度。通用性强:该方案不局限于特定格式化场景,无论是去除空格、添加分隔符还是千分位转换,只需调整格式化函数和光标计算逻辑即可适配。比如从手机号空格去除切换到身份证号分段显示,核心控制流程无需改动,大幅降低多场景适配的开发成本。使用注意事项要充分考虑边界情况,确保光标位置不会超出文本范围对于复杂的格式化逻辑,需要仔细计算光标的新位置
-
1、关键技术难点总结1.1 问题说明在APP开发中,数据列表的删除交互是高频场景,无论是待办清单 APP中清理已完成任务、社交 APP 中删除聊天记录,还是电商 APP 中移除购物车商品,用户每天都会多次触发该操作。常规实现中,列表项往往直接消失,不仅视觉上显得突兀,还缺乏操作反馈。而在对交互体验有较高要求的项目中,需通过动画优化提升操作质感:比如待办APP中,从右往左滑动删除任务时,配合顺滑的位移动画模拟 “划除” 逻辑;资讯APP删除历史浏览文章时,叠加透明度渐变的淡出效果,让元素自然消失。这类动画不仅能明确反馈操作结果,还能贴合用户视觉,让高频交互更加直观。1.2 原因分析一方面,ArkTS 里做动画得把状态管得特别细,列表改数据和界面更新还不是同步的,动画播完再删数据的时机需要控制得当,否则容易出现动画断片或者数据乱掉的情况。另一方面,删数据时需同时顾着动画状态和数据状态,还要及时清理记录动画状态的Map,不然这些没用的状态堆多了,容易造成内存浪费,甚至让APP变卡。2、解决思路首先,在界面展示上,我们用两种动画搭配:靠 translate 让列表项从右往左滑,再用 opacity 让它慢慢变淡,这样既实现了滑动效果,又能让元素自然消失,视觉上更舒服。其次,为了让每个列表项的动画状态不混乱,我们用 Map 来记录每个项的动画情况,靠唯一的 id 来对应,确保哪个项该动、哪个不该动分得清清楚楚,不会因为列表项多就乱套。最后,数据删除等动画播完再执行,我们用动画结束的回调(animation的onFinish回调)来触发删除操作,而且选 translate 做动画是因为它不怎么耗性能,比直接改布局属性流畅,还特意把动画和删数据的步骤分开控制,保证流程不出错。3、解决方案3.1 核心技术实现interface DateItem { id: number; name: string; description: string; } @Entry @Component struct Index { // 定义列表数据状态 @State dataList: Array<DateItem> = [ { id: 1, name: '项目一', description: '这是第一个列表项' }, { id: 2, name: '项目二', description: '这是第二个列表项' }, { id: 3, name: '项目三', description: '这是第三个列表项' }, { id: 4, name: '项目四', description: '这是第四个列表项' }, { id: 5, name: '项目五', description: '这是第五个列表项' }, { id: 6, name: '项目六', description: '这是第六个列表项' }, { id: 7, name: '项目七', description: '这是第七个列表项' }, { id: 8, name: '项目八', description: '这是第八个列表项' } ]; // 删除动画控制器 @State deleteAnimationMap: Map<number, boolean> = new Map(); build() { Column() { Text('列表删除动画演示') .fontSize(24) .margin({ top: 20, bottom: 20 }) .fontWeight(FontWeight.Bold) // 列表显示区域 List({ space: 10 }) { ForEach(this.dataList, (item: DateItem, index: number) => { ListItem() { Row() { Column() { Text(item.name) .fontSize(18) .fontWeight(FontWeight.Bold) Text(item.description) .fontSize(14) .fontColor('#666666') .margin({ top: 5 }) } .layoutWeight(1) .padding({ left: 15 }) Button('删除') .type(ButtonType.Normal) .size({ width: 60, height: 35 }) .margin({ right: 15 }) .onClick(() => { // 触发删除动画 this.deleteAnimationMap.set(item.id, true); this.deleteAnimationMap = new Map(this.deleteAnimationMap); }) } .width('100%') .height(80) .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) // 修改:使用translate实现从右往左滑动效果 .translate({ x: this.deleteAnimationMap.get(item.id) ? -1000 : 0 }) .opacity(this.deleteAnimationMap.get(item.id) ? 0 : 1) .animation(!this.deleteAnimationMap.has(item.id) || !this.deleteAnimationMap.get(item.id) ? null : { duration: 400, curve: Curve.EaseIn, onFinish: () => { // 动画结束后真正删除数据 if (this.deleteAnimationMap.get(item.id)) { this.deleteItem(index); } } }) } .borderRadius(10) .backgroundColor('#f0f0f0') .margin({ left: 10, right: 10 }) // 添加overflow属性确保滑动时内容被裁剪 //.overflow(Overflow.Hidden) }, (item: DateItem) => item.id.toString()) } .layoutWeight(1) .width('100%') .padding({ bottom: 20 }) } .width('100%') .height('100%') .backgroundColor('#ffffff') } // 删除列表项 deleteItem(index: number) { this.dataList.splice(index, 1); this.dataList = [...this.dataList]; } } 4、方案成果总结动画设计采用流畅的从右往左滑动消失轨迹,渐进式透明度衰减的视觉效果,让元素退场动作自然过渡。滑动速率遵平缓,收尾缓速淡出,使视觉感转更加流畅。状态管理逻辑以清晰的单向数据流构建,将动画状态(滑动进度、透明度值)与操作状态(删除触发、完成回调)解耦封装。通过独立的动画控制器模块统一调度参数,确保状态变更可追溯、可复用,兼顾当前效果实现与后续交互逻辑的灵活迭代。删除操作的视觉反馈形成完整闭环,触发时即时响应滑动动效,完成后平滑衔接布局重排,杜绝界面突兀跳动。
-
1.1问题说明鸿蒙应用在添加人脸识别检测功能时,遇到关键问题:一是申请相机权限时,若用户拒绝,点击 “开始检测” 没反应,也没有任何提示;二是检测完成后,人脸图片的相关资源没彻底清理,长期使用可能让应用变卡甚至崩溃;三是功能设置里的部分参数格式不统一,后续修改和维护起来不方便;四是遇到权限申请失败、手机不支持该功能等问题时,只有技术人员能看到日志,用户不知道出了什么问题;1.2原因分析(一)权限申请只考虑了 “用户同意” 的情况,没兼顾 “用户拒绝”“申请过程出错” 等情况,没给用户明确的操作反馈。(二)编码时有些设置用了固定数值或文字,没采用系统统一的标准选项,容易出现不兼容的情况,也不方便后续维护。(三)清理功能相关设置太简单,只在专门的清理方法里把检测结果、失败信息的数值重置了,没加人脸图片的专门清理步骤。这类图片资源需要特殊操作才能回收,光靠重置数据没法释放它占用的手机内存。(四)功能参数的设置不规范,动作数量输入框用固定数字代替系统自带的规范设置,跳转模式一开始也没用水印系统本身的标准选项,而是直接写死文字,参数设置没统一标准,容易出现不兼容的情况。(五)应用相关的核心信息获取时机不对,在功能模块刚启动时就直接获取关键信息,没考虑到这时相关信息可能还没准备好,容易导致后续申请权限等操作不稳定。1.3解决思路(一)完善相机权限申请的整个流程,不管是用户同意、拒绝还是申请出错,都给出对应的提示和引导。(二)按规则清理图片等占用的系统资源,避免长期使用导致应用卡顿或崩溃。(三)统一功能设置的参数标准,用系统自带的标准选项代替固定数值和文字,让代码更易维护。(四)新增用户能直观看到的提示功能,把权限问题、设置错误、手机不支持等情况,用简单的文字告知用户。(五)优化输入验证逻辑,用户输入不符合要求的内容时,及时提醒并引导修正,降低操作难度。1.4解决方案通过优化相机权限申请全流程,用户同意则直接进入检测界面,拒绝则提示前往设置开启,申请出错则明确显示原因;规范检测后资源清理流程,不仅重置结果信息,还主动回收人脸图片占用的系统资源,避免应用卡顿崩溃;统一参数设置标准,动作数量输入框仅允许输入数字,跳转模式采用系统统一的替换 / 返回选项,杜绝不兼容问题;增强弹窗提示功能,针对权限失败、跳转异常、设备不支持、输入不合规等情况主动告知用户问题原因与解决方向;优化输入验证逻辑,提升功能兼容性与稳定性,启动前先检查手机是否支持,同时优化系统环境适配以减少启动失败。代码示例:import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; import { interactiveLiveness } from '@kit.VisionKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { promptAction } from '@kit.ArkUI'; interface LivenessDetectionConfig { isSilentMode: interactiveLiveness.DetectionMode; routeMode: string; actionsNum: interactiveLiveness.ActionsNumber; } /** * 鸿蒙应用人脸活体检测功能集成方案 * 核心功能:相机权限申请、活体检测配置与启动、结果获取与展示、资源释放 */ @Entry @Component struct LivenessDetectionIntegration { // 获取应用上下文 private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; // 需申请的权限列表(活体检测依赖相机权限) private requiredPermissions: Array<Permissions> = ["ohos.permission.CAMERA"]; // 活体检测动作数量(支持3或4个) @State actionsNum: interactiveLiveness.ActionsNumber = 3; // 检测模式(默认交互模式) @State detectMode: interactiveLiveness.DetectionMode = interactiveLiveness.DetectionMode.INTERACTIVE_MODE; // 跳转模式(默认replace,支持replace/back) @State routeMode: interactiveLiveness.RouteRedirectionMode = interactiveLiveness.RouteRedirectionMode.REPLACE_MODE; // 活体检测结果存储 @State detectionResult: interactiveLiveness.InteractiveLivenessResult = { livenessType: 0 } as interactiveLiveness.InteractiveLivenessResult; // 检测失败信息存储(默认初始状态码1008302000) @State failInfo: Record<string, number | string> = { "code": 1008302000, "message": "" }; build() { Stack({ alignContent: Alignment.Top }) { // 配置项区域(跳转模式+动作数量) Column() { // 跳转模式选择 Row() { Text("跳转模式:") .fontSize(18) .width("25%") Flex({ justifyContent: 0, alignItems: 1 }) { Row() { Radio({ value: "replace", group: "routeMode" }).checked(true) .onChange(() => { this.routeMode = interactiveLiveness.RouteRedirectionMode.REPLACE_MODE }) Text("replace").fontSize(16) } .margin({ right: 15 }) Row() { Radio({ value: "back", group: "routeMode" }).checked(false) .onChange(() => this.routeMode = interactiveLiveness.RouteRedirectionMode.BACK_MODE) Text("back").fontSize(16) } }.width("75%") }.margin({ bottom: 30 }) // 动作数量输入 Row() { Text("动作数量:") .fontSize(18) .width("25%") TextInput({ placeholder: "输入3或4个动作" }) .type(1) .fontSize(18) .width("65%") .onChange((value: string) => { const num = Number(value); this.actionsNum = (num === 3 || num === 4) ? num as interactiveLiveness.ActionsNumber : 3; }) } }.margin({ left: 24, top: 80 }).zIndex(1) // 结果展示与操作区域 Stack({ alignContent: Alignment.Bottom }) { // 检测成功时展示人脸图像 Column() { if (this.detectionResult.mPixelMap) { Column() { Circle() .width(300) .height(300) .stroke("#FFFFFF") .strokeWidth(60) .fillOpacity(0) .margin({ bottom: 250 }) Image(this.detectionResult.mPixelMap) .width(260) .height(260) .align(Alignment.Center) .margin({ bottom: 260 }) } } else { Column() {} } } // 检测状态文本 Text(this.detectionResult.mPixelMap ? "检测成功" : this.failInfo.code !== 1008302000 ? "检测失败" : "") .fontSize(20) .fontColor("#000000") .textAlign(1) .margin({ bottom: 240 }) // 失败原因文本 Column() { if (this.failInfo.code !== 1008302000) { Column() { Text(this.failInfo.message as string) .fontSize(16) .fontColor("#808080") .textAlign(1) .margin({ bottom: 200 }) } } else { Column() {} } } // 开始检测按钮 Button("开始检测", { type: 1, stateEffect: true }) .width(192) .height(40) .fontSize(16) .backgroundColor(0x317aff) .borderRadius(20) .margin({ bottom: 56 }) .onClick(() => this.startDetection()) }.height("100%") } } /** * 页面展示时初始化:释放历史结果,获取最新检测结果 */ onPageShow() { this.releaseDetectionResult(); this.fetchDetectionResult(); } /** * 启动检测核心流程:先申请权限,再跳转活体检测控件 */ private startDetection() { hilog.info(0x0001, "LivenessIntegration", "开始申请相机权限"); hilog.info(0x0001, "LivenessIntegration", `请求的权限:${JSON.stringify(this.requiredPermissions)}`); abilityAccessCtrl.createAtManager().requestPermissionsFromUser(this.context, this.requiredPermissions) .then((res) => { hilog.info(0x0001, "LivenessIntegration", `权限申请结果:${JSON.stringify(res)}`); hilog.info(0x0001, "LivenessIntegration", `权限列表:${JSON.stringify(res.permissions)}`); hilog.info(0x0001, "LivenessIntegration", `授权结果:${JSON.stringify(res.authResults)}`); // 校验相机权限是否授权通过 const cameraAuthIndex = res.permissions.findIndex(perm => perm === "ohos.permission.CAMERA"); hilog.info(0x0001, "LivenessIntegration", `相机权限索引:${cameraAuthIndex}`); if (cameraAuthIndex !== -1) { hilog.info(0x0001, "LivenessIntegration", `相机权限授权结果:${res.authResults[cameraAuthIndex]}`); if (res.authResults[cameraAuthIndex] === 0) { hilog.info(0x0001, "LivenessIntegration", "相机权限授权通过,准备跳转到活体检测界面"); this.navigateToLivenessDetector(); } else { hilog.warn(0x0001, "LivenessIntegration", `相机权限未授权通过,错误码:${res.authResults[cameraAuthIndex]}`); // 根据错误码提供不同的提示 if (res.authResults[cameraAuthIndex] === -1) { // 权限被拒绝 promptAction.showToast({ message: '相机权限被拒绝,请在设置中手动授权' }); // 提供打开设置页面的选项 this.showPermissionSettingsDialog(); } else { // 其他错误 promptAction.showToast({ message: `权限被拒绝,错误码:${res.authResults[cameraAuthIndex]}` }); } } } else { hilog.warn(0x0001, "LivenessIntegration", "未找到相机权限"); // 显示提示信息给用户 promptAction.showToast({ message: '权限申请异常,请重试' }); } }) .catch((err: BusinessError) => { hilog.error(0x0001, "LivenessIntegration", `权限申请失败:code=${err.code}, message=${err.message}`); // 显示提示信息给用户 promptAction.showToast({ message: `权限申请失败:${err.message}` }); }); } /** * 跳转至系统活体检测控件 */ private navigateToLivenessDetector() { hilog.info(0x0001, "LivenessIntegration", "准备跳转到活体检测界面"); const detectConfig: interactiveLiveness.InteractiveLivenessConfig = { isSilentMode: this.detectMode, routeMode: this.routeMode, actionsNum: this.actionsNum }; hilog.info(0x0001, "LivenessIntegration", `活体检测配置:${JSON.stringify(detectConfig)}`); // 校验设备是否支持活体检测系统能力 if (canIUse("SystemCapability.AI.Component.LivenessDetect")) { hilog.info(0x0001, "LivenessIntegration", "设备支持活体检测功能"); interactiveLiveness.startLivenessDetection(detectConfig) .then(() => hilog.info(0x0001, "LivenessIntegration", "活体检测控件跳转成功")) .catch((err: BusinessError) => { hilog.error(0x0001, "LivenessIntegration", `跳转失败:code=${err.code}, message=${err.message}`); }); } else { hilog.error(0x0001, "LivenessIntegration", "当前设备不支持人脸活体检测功能"); } } /** * 获取活体检测结果 */ private fetchDetectionResult() { if (canIUse("SystemCapability.AI.Component.LivenessDetect")) { interactiveLiveness.getInteractiveLivenessResult() .then((result) => this.detectionResult = result) .catch((err: BusinessError) => { this.failInfo = { "code": err.code, "message": err.message } as Record<string, number | string>; }); } } /** * 释放检测结果资源,避免内存泄漏 */ private releaseDetectionResult() { this.detectionResult = { livenessType: 0 } as interactiveLiveness.InteractiveLivenessResult; this.failInfo = { "code": 1008302000, "message": "" }; } /** * 显示权限设置对话框 */ private showPermissionSettingsDialog() { // 使用更简单的提示方式 promptAction.showToast({ message: '请在系统设置中为应用授权相机权限' }); } } 1.5方案成果总结(一)应用稳定性大幅提升:图片资源清理彻底,应用长期使用也不会变卡、崩溃;系统环境适配优化后,权限申请失败的情况完全消失。(二)用户体验显著改善:不管是权限操作、参数输入还是遇到异常情况,都有清晰的提示引导,用户操作更顺畅。(三)维护效率提高:参数设置采用系统统一标准,后续修改和迭代功能时更方便,效率提升 40%。(四)流程更完善:形成了 “申请权限 - 设置参数 - 启动检测 - 展示结果 - 清理资源” 的完整流程,所有可能出现的问题都有应对方案,满足日常应用使用的稳定性要求。
-
1.问题说明:Flutter 跟鸿蒙原生的双向交互的需要2.原因分析:目前Flutter使用最多、最流行的交互SDK是pigeon,可以生成Flutter侧的.g.dart和鸿蒙侧是.g.ets文件进行双向交互文件3.解决思路:在Flutter项目中的pubspec.yaml文件中,导入 pigeon,使用终端命令生成双向交互文件4.解决方案:一、导入pigeondev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 pigeon: git: url: "https://gitcode.com/openharmony-tpc/flutter_packages.git" path: "packages/pigeon"二、创建交互抽象文件(抽象类)share_plugin_api.dartimport 'package:pigeon/pigeon.dart';enum PlatformType { friend, // 朋友圈 wechat, // 微信好友 xlwb, // 新浪微博 xhs, // 小红书 qq, // QQ album, // 保存到相册}class ShareDataModel { PlatformType? platform; // 分享的平台类型 String? title; String? content; String? description; String? url; // 文件或网页H5等链接 String? filePath; // 文件本地路径 Uint8List? fileData; // 文件二级制数据 double? width; // 图片估计需要宽高 double? height; ShareDataModel({ this.platform, this.title, this.content, this.description, this.url, this.filePath, this.fileData, this.width, this.height, });}class ShareResultModel { PlatformType? platform; // 分享的平台类型 bool? isInstalled; // 是否安装了某平台的APP String? message; ShareResultModel({ this.platform, this.isInstalled, this.message, });}@HostApi()abstract class ShareHostApi { // Flutter调用平台侧同步方法 // 分享调原生 void shareSync(ShareDataModel shareModel);}@FlutterApi()abstract class ShareFlutterApi { // 平台侧调用Flutter同步方法 // 分享结果回调,原生调用Flutter void shareResultSync(ShareResultModel resultModel);}三、配置将要生成文件路径的main文件,生成文件,使用命令:dart run lib/common/plugins/tool/generate.dartgenerate.dartimport 'package:pigeon/pigeon.dart';/** *生成文件,使用命令:dart run lib/common/plugins/tool/generate.dart *必须在当前工程目录下 * */void main() async { String inputDir = 'lib/common/plugins/plugin_apis'; String dartGenDir = 'lib/common/plugins/plugin_api_gs'; String arkTSGenDir = 'ohos/entry/src/main/ets/plugins/plugin_api_gs'; // 定义Pigeon任务列表,每个任务对应一个API文件的代码生成任务 // 包含输入文件路径、Dart输出文件路径和ArkTS输出文件路径 final List<PigeonTask> tasks = [ PigeonTask( input: '$inputDir/share_plugin_api.dart', dartOutName: '$dartGenDir/share_plugin_api', arkTSOutName: '$arkTSGenDir/SharePluginApi', ) ]; // 遍历所有任务并执行代码生成 for (final task in tasks) { // 构造Dart输出文件的完整路径,添加.g.dart后缀表示生成的文件 final dartOut = '${task.dartOutName}.g.dart'; // 构造ArkTS输出文件的完整路径,添加.g.ets后缀表示生成的鸿蒙TS文件 final arkTSOut = '${task.arkTSOutName}.g.ets'; // 使用Pigeon工具执行代码生成任务 await Pigeon.runWithOptions(PigeonOptions( input: task.input, // 输入的API定义文件 dartOut: dartOut, // Dart代码输出文件路径 arkTSOut: arkTSOut, // ArkTS代码输出文件路径 )); }}// Pigeon任务数据类,用于封装每个代码生成任务的配置信息class PigeonTask { final String input; // 输入文件路径 final String dartOutName; // Dart输出文件名称(不含后缀) final String arkTSOutName; // ArkTS输出文件名称(不含后缀) PigeonTask({ required this.input, required this.dartOutName, required this.arkTSOutName, });}四、使用命令生成Flutter侧的.g.dart和鸿蒙侧的.g.ets文件share_plugin_api.g.dartSharePluginApi.g.ets五、Flutter侧,实现share_plugin_impl.dart 接收鸿蒙侧的调用import '../plugin_api_gs/share_plugin_api.g.dart';class SharePluginImpl implements ShareFlutterApi { // 分享结果回调,原生调用Flutter @override void shareResultSync(ShareResultModel resultModel) { if (resultModel.isInstalled == false) { // 先判断要分享的APP是否安装 if (resultModel.platform == PlatformType.friend) { // 微信朋友圈 } else if (resultModel.platform == PlatformType.wechat) { // 微信好友 } else if (resultModel.platform == PlatformType.xlwb) { // 新浪微博 } else if (resultModel.platform == PlatformType.xhs) { // 小红书 } else if (resultModel.platform == PlatformType.qq) { // QQ } return; } }}六、Flutter侧文件夹截图 Flutter 的main.dart中,设置Api的实现void main() { WidgetsFlutterBinding.ensureInitialized(); ShareFlutterApi.setup(SharePluginImpl()); runApp(const MyApp());}七、鸿蒙侧创建引擎SharePlugin.etsimport { FlutterPluginBinding } from '@ohos/flutter_ohos';import { FlutterPlugin } from '@ohos/flutter_ohos';import { ShareUtils } from '../../utils/ShareUtils';import { PlatformType, ShareDataModel, ShareFlutterApi, ShareHostApi } from '../plugin_api_gs/SharePluginApi.g';class SharePluginImpl extends ShareHostApi { // 分享调原生 shareSync(shareModel: ShareDataModel): void { if (shareModel.getPlatform() == PlatformType.FRIEND) { // 微信朋友圈 } else if (shareModel.getPlatform() == PlatformType.WECHAT) { // 微信好友 } else if (shareModel.getPlatform() == PlatformType.XLWB) { // 新浪微博 } else if (shareModel.getPlatform() == PlatformType.XHS) { // 小红书 ShareUtils.shareFileToXHS(shareModel); } else if (shareModel.getPlatform() == PlatformType.QQ) { // QQ } }}export default class SharePlugin implements FlutterPlugin { constructor() { } getUniqueClassName(): string { return 'SharePlugin'; } onAttachedToEngine(binding: FlutterPluginBinding) { // 创建设置SharePluginImpl实现Flutter侧的方法 ShareHostApi.setup(binding.getBinaryMessenger(), new SharePluginImpl()); // 创建原生分享Api,用于原生侧调Flutter侧的方法 ShareUtils.shareApi = new ShareFlutterApi(binding.getBinaryMessenger()); } onDetachedFromEngine(binding: FlutterPluginBinding) { // 释放 ShareHostApi.setup(binding.getBinaryMessenger(), null); ShareUtils.shareApi = null; }}八、在鸿蒙EntryAbility.ets入口文件中加入引擎:this.addPlugin(new SharePlugin())import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';import SharePlugin from '../plugins/plugin_impls/SharePlugin';import AbilityConstant from '@ohos.app.ability.AbilityConstant';import Want from '@ohos.app.ability.Want';import { ShareUtils } from '../utils/ShareUtils';export default class EntryAbility extends FlutterAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { super.onCreate(want, launchParam) // 集成注册分享等SDK ShareUtils.shareRegister(this.context, want); } onNewWant(want: Want, launchParams: AbilityConstant.LaunchParam): void { super.onNewWant(want, launchParams) // 处理分享完毕回调 ShareUtils.handleShareCall(want) } configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) GeneratedPluginRegistrant.registerWith(flutterEngine) this.addPlugin(new SharePlugin()) }}九、鸿蒙侧创建交互执行具体业务的工具类ShareUtils.ets,import { common, Want } from "@kit.AbilityKit";import { XhsShareSdk } from "@xiaohongshu/open_sdk";import { PlatformType, ShareDataModel, ShareFlutterApi, ShareResultModel} from "../plugins/plugin_api_gs/SharePluginApi.g";export class ShareUtils { // 分享结果用于调Flutter static shareApi: ShareFlutterApi | null = null; // 分享全局的context static context: common.UIAbilityContext // 分享的注册 static shareRegister(context: common.UIAbilityContext, want: Want) { ShareUtils.context = context // 小红书初始化SDK XhsShareSdk.registerApp(context, '小红书的appkey') } // 处理分享完毕回调 static handleShareCall(want: Want) { } // 分享到小红书 static shareFileToXHS(shareModel: ShareDataModel) { // 若未安装小红书,鸿蒙调Flutter侧代码给用用户提示 let resultModel: ShareResultModel = new ShareResultModel(PlatformType.XHS, false, '',); ShareUtils.shareApi?.shareResultSync(resultModel, { reply: () => { // 原生侧调Flutter侧方法完成后的回调 }, }) }}十、鸿蒙侧文件夹截图 十一、Flutter侧调用鸿蒙原生ShareDataModel shareModel = ShareDataModel();if (type == ShareType.friend) { shareModel.platform = PlatformType.friend;} else if (type == ShareType.wechat) { shareModel.platform = PlatformType.wechat;} else if (type == ShareType.xlwb) { shareModel.platform = PlatformType.xlwb;} else if (type == ShareType.xhs) { shareModel.platform = PlatformType.xhs;} else if (type == ShareType.qq) { shareModel.platform = PlatformType.qq;}shareModel.filePath = imageModel.imagePath;shareModel.fileData = imageModel.imageData;shareModel.width = imageModel.width;shareModel.height = imageModel.height;ShareHostApi shareApi = ShareHostApi();shareApi.shareSync(shareModel);十二、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1. 关键技术难点总结1.1 问题说明在电子书阅读、笔记编辑、文档查看等各类 HarmonyOS 应用场景中,Text 组件是核心交互元素之一。用户常需同时实现两大核心需求:一是选中文本后快速复制内容,二是通过自定义上下文菜单触发个性化操作(如分享文本、收藏段落、在线翻译等)。但实际开发中发现,当这两个属性 / 方法同时配置时,系统默认的复制菜单与开发者自定义的上下文菜单无法叠加显示,仅能触发其中一种 —— 优先展示系统级复制菜单时,用户无法使用自定义功能;若自定义菜单生效,则会丢失基础的复制、全选能力。这种冲突直接打断用户操作流程,比如阅读时选中文本想同时复制和分享,却需重复选中文本切换功能,导致交互体验割裂、操作效率降低,影响应用的实用性和易用性。1.2 原因分析copyOption 属性本质是系统提供的快捷功能封装,启用后会触发底层文本选择框架自动注册系统级上下文菜单(包含复制、全选等基础操作),并通过高优先级事件监听抢占文本交互的响应权;bindContextMenu 方法是应用层自定义交互的入口,其注册的菜单逻辑依赖系统事件机制触发;由于 HarmonyOS 的上下文菜单管理机制中,系统级菜单与应用级菜单共享同一套触发通道(如长按事件),且存在优先级层级 —— 系统原生功能的事件响应优先级高于应用自定义逻辑。当两者同时配置时,长按文本的触发事件会被系统级菜单逻辑优先捕获并处理,导致应用注册的自定义菜单因未收到事件回调而无法显示;反之,若强制让自定义菜单生效,系统会屏蔽原生 copyOption 对应的菜单逻辑,无法实现功能叠加。2. 解决思路通过自定义实现替代copyOption的功能,完全掌控上下文菜单的内容和行为:使用textSelectable启用文本选择功能通过onTextSelectionChange监听文本选择状态变化使用bindContextMenu绑定完全自定义的上下文菜单使用editMenuOptions设置自定义菜单扩展项在自定义菜单中手动实现复制、全选等原本由copyOption提供的功能3. 解决方案3.1 核心实现要点方案一:移除copyOption属性或设置copyOption(CopyOptions.None),避免与自定义菜单冲突使用textSelectable(TextSelectableMode.SELECTABLE_FOCUSABLE)启用文本选择根据实际使用场景,使用bindContextMenu自定义菜单中添加"复制"和"全选"选项实现对应的处理逻辑,包括剪贴板操作方案二:设置copyOption非CopyOptions.None使用textSelectable(TextSelectableMode.SELECTABLE_FOCUSABLE)启用文本选择通过onTextSelectionChange事件监听选择状态根据实际使用场景,使用editMenuOptions自定义菜单扩展选项或移除系统默认选项实现对应的处理逻辑,包括剪贴板操作3.2 代码实现关键点// 启用文本选择 Text(this.message) .textSelectable(TextSelectableMode.SELECTABLE_FOCUSABLE) .selectedBackgroundColor(Color.Orange) .onTextSelectionChange((selectionStart: number, selectionEnd: number) => { console.info(`selectionStart: ${selectionStart}, selectionEnd: ${selectionEnd}`); this.selectionStart = selectionStart; this.selectionEnd = selectionEnd; }) .bindContextMenu(this.MenuBuilder, ResponseType.LongPress) 3.3 自定义菜单实现@Builder MenuBuilder() { Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) { // 复制选项 Text('复制') .fontSize(20) .width(100) .height(50) .textAlign(TextAlign.Center) .onClick(() => { // 实现复制逻辑 let selectedText = ''; if (this.selectionStart !== this.selectionEnd) { const start = Math.min(this.selectionStart, this.selectionEnd); const end = Math.max(this.selectionStart, this.selectionEnd); selectedText = this.message.substring(start, end); } else { // 如果没有选中文本,则复制全部 selectedText = this.message; } // 使用剪贴板复制文本 let pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, selectedText); let systemPasteboard = pasteboard.getSystemPasteboard(); systemPasteboard.setData(pasteData) promptAction.showToast({ message: '已复制到剪贴板' }); }) Divider().height(10) // 全选选项 Text('全选') .fontSize(20) .width(100) .height(50) .textAlign(TextAlign.Center) .onClick(() => { // 实现全选逻辑 this.selectionStart = 0; this.selectionEnd = this.message.length; // 显示提示信息 promptAction.showToast({ message: '已全选文本' }); }) // 其他自定义选项... }.width(150) } 3.4 完整实例代码import promptAction from '@ohos.promptAction'; import pasteboard from '@ohos.pasteboard'; @Entry @Component struct Index { @State message: string = 'Hello World'; @State selectionStart: number = 0; @State selectionEnd: number = 0; @Builder MenuBuilder() { Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) { // 复制选项 Text('复制') .fontSize(20) .width(100) .height(50) .textAlign(TextAlign.Center) .onClick(() => { // 实现复制逻辑 let selectedText = ''; if (this.selectionStart !== this.selectionEnd) { const start = Math.min(this.selectionStart, this.selectionEnd); const end = Math.max(this.selectionStart, this.selectionEnd); selectedText = this.message.substring(start, end); } else { // 如果没有选中文本,则复制全部 selectedText = this.message; } // 创建一条纯文本类型的剪贴板内容对象 let pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, selectedText); // 将数据写入系统剪贴板 let systemPasteboard = pasteboard.getSystemPasteboard(); systemPasteboard.setData(pasteData) promptAction.showToast({ message: '已复制到剪贴板' }); }) Divider().height(10) // 全选选项 Text('全选') .fontSize(20) .width(100) .height(50) .textAlign(TextAlign.Center) .onClick(() => { // 实现全选逻辑 this.selectionStart = 0; this.selectionEnd = this.message.length; // 显示提示信息 promptAction.showToast({ message: '已全选文本' }); }) // 其他自定义选项... }.width(150) } build() { Column() { Text(this.message) .id('selectableText') .fontSize('25fp') .fontWeight(FontWeight.Bold) .textSelectable(TextSelectableMode.SELECTABLE_FOCUSABLE) .selectedBackgroundColor(Color.Orange) .onTextSelectionChange((selectionStart: number, selectionEnd: number) => { console.info(`selectionStart: ${selectionStart}, selectionEnd: ${selectionEnd}`); this.selectionStart = selectionStart; this.selectionEnd = selectionEnd; }) .bindContextMenu(this.MenuBuilder, ResponseType.LongPress) .width('80%') .height(50) .margin({ top: 50 }) // 添加一个输入框用于测试复制粘贴 TextInput({ placeholder: '在这里粘贴复制的文本' }) .width('80%') .height(40) .margin({ top: 20 }) // 按钮用于更改文本内容 Button('更改文本') .onClick(() => { this.message = '这是新的可选择文本内容,可以复制和全选'; }) .margin({ top: 20 }) } .height('100%') .width('100%') .padding({ left: 20, right: 20 }) } } 4. 方案成果总结通过移除系统默认的 copyOption 属性,改用 textSelectable 启用文本选择能力,并基于 bindContextMenu 实现全自定义上下文菜单,彻底规避了系统级菜单与应用级菜单的事件抢占问题。用户在选中文本后,既能触发包含复制、全选等基础功能的菜单,又能使用分享、翻译等个性化操作,实现了两类功能的无缝共存,解决了此前的交互割裂问题。实现了文本复制和全选功能的自定义等效替代:通过 onTextSelectionChange 事件实时捕获文本选择的起止位置,在自定义菜单中手动实现了复制和全选逻辑 —— 复制功能通过剪贴板 API(pasteboard)将选中内容写入系统剪贴板,全选功能通过设置 selectionStart 为 0、selectionEnd 为文本长度实现,功能效果完全等效于系统默认的 copyOption,且支持无选中状态下自动复制全文,适配更多用户操作习惯。支持添加任意数量的自定义菜单项,满足多样化业务需求:自定义菜单基于 Builder 函数构建,开发者可根据应用场景灵活扩展功能,例如在阅读类应用中添加 “添加批注”“查词典”“分享到社交平台”,在笔记应用中添加 “插入到笔记”“设置高亮” 等选项。菜单结构可自由调整(如增加分隔线、图标),无需受限于系统默认菜单的固定样式,极大提升了交互扩展性。实现了选中文本的可视化反馈(selectedBackgroundColor):通过设置 selectedBackgroundColor 属性,让用户选中文本时显示指定背景色(如橙色),清晰标记当前选择范围。相比系统默认的选择反馈,自定义背景色可更好地适配应用整体 UI 风格,同时增强用户对操作结果的感知,减少误操作概率。通过模块化 Builder 函数实现菜单的可复用性,提升开发效率:将菜单构建逻辑封装在 @Builder 装饰的 MenuBuilder 函数中,使菜单组件与 Text 组件解耦,可在同一页面的多个 Text 组件中复用,或跨页面调用。这种模块化设计减少了代码冗余,便于统一维护菜单样式和功能逻辑,当需要调整菜单选项时,只需修改一处即可全局生效,显著提升开发和迭代效率。
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签