-
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 组件中复用,或跨页面调用。这种模块化设计减少了代码冗余,便于统一维护菜单样式和功能逻辑,当需要调整菜单选项时,只需修改一处即可全局生效,显著提升开发和迭代效率。
-
1.1问题说明在鸿蒙应用开发中,主题风格适配存在应用内各页面、组件的颜色风格不统一,缺乏全局一致性;切换手机浅色 / 深色模式时,按钮、背景等 UI 元素颜色无法自动适配,显示不协调;主题切换后,部分界面元素(如列表项背景)颜色未同步更新,出现视觉异常;无法灵活实现局部页面的独立主题风格(与全局主题区分开)。1.2原因分析(一)未建立统一的主题颜色管理规则,各组件颜色使用混乱,导致风格不统一;(二)未针对浅色 / 深色模式配置对应的颜色资源,无法实现模式切换时的自动适配;(三)未监听主题变化事件,导致主题更新后 UI 元素颜色无法实时同步;(四)缺乏局部主题隔离机制,无法在不影响全局的情况下定制部分页面的独特风格。1.3解决思路基于鸿蒙 ArkUI 的主题系统,通过以下步骤解决问题:定义统一的主题颜色规则(如品牌色、背景色等),关联浅色 / 深色模式对应的资源;设置全局默认主题,确保应用整体风格一致;监听主题变化事件,实时更新依赖主题色的 UI 元素;利用组件实现局部页面主题隔离,支持独立风格定制与切换。1.4解决方案通过定义全局主题(含品牌色、警示色等,关联深浅色模式资源)和局部主题(如紫色系、蓝色系),利用 ThemeControl 设置全局默认主题,监听主题变化以实时更新 UI 元素颜色;实现局部页面主题隔离,支持独立切换,并配合资源文件中深浅色对应的颜色配置,既保证应用全局风格统一,又能灵活定制局部独特风格。定义主题颜色与全局主题配置代码示例:// 主题颜色与配置定义 import { CustomColors, CustomTheme } from '@kit.ArkUI'; // 基础主题颜色(支持深浅色模式自适应) export class AppBaseColors implements CustomColors { brand: ResourceColor = '#FF75D9'; // 品牌主色 warning: ResourceColor = $r('app.color.warning_color'); // 警示色(关联深浅色资源) backgroundPrimary: ResourceColor = $r('sys.color.background_primary'); // 系统默认背景色 fontPrimary: ResourceColor = $r('sys.color.font_primary'); // 新增:系统默认文本色 } // 全局主题 export class AppGlobalTheme implements CustomTheme { colors: AppBaseColors = new AppBaseColors(); } // 局部主题类型枚举(新增) export enum LocalThemeType { PURPLE, BLUE } // 局部主题1(紫色系)- 继承基础颜色类 export class PurpleThemeColors extends AppBaseColors implements CustomColors { // 重写需要自定义的属性,继承其他基础属性 fontPrimary: ResourceColor = $r('app.color.purple_font'); backgroundEmphasize: ResourceColor = $r('app.color.purple_bg'); } export class PurpleTheme implements CustomTheme { colors: PurpleThemeColors = new PurpleThemeColors(); } // 局部主题2(蓝色系)- 继承基础颜色类 export class BlueThemeColors extends AppBaseColors implements CustomColors { fontPrimary: ResourceColor = $r('app.color.blue_font'); backgroundEmphasize: ResourceColor = $r('app.color.blue_bg'); } export class BlueTheme implements CustomTheme { colors: BlueThemeColors = new BlueThemeColors(); } // 全局主题实例 export const GlobalTheme = new AppGlobalTheme(); 资源配置代码示例:{ "color": [ { "name": "warning_color", "value": "#FF4D4F", "darkMode": "#FF7A7A" }, { "name": "purple_font", "value": "#7B61FF", "darkMode": "#A391FF" }, { "name": "purple_bg", "value": "#F2EDFF", "darkMode": "#2E2759" }, { "name": "blue_font", "value": "#1677FF", "darkMode": "#69B1FF" }, { "name": "blue_bg", "value": "#E8F3FF", "darkMode": "#15315B" } ] 主题组件代码示例:import { Theme, ThemeControl } from '@kit.ArkUI'; import { GlobalTheme, PurpleTheme, BlueTheme } from './AppTheme'; // 设置全局默认主题 ThemeControl.setDefaultTheme(GlobalTheme); @Entry @Component struct ThemeDemoPage { // 全局主题相关状态 @State currentMode: string = 'light'; // 当前模式(浅色/深色) @State menuItemColor: ResourceColor = $r('sys.color.background_primary'); // 添加亮度状态变量(重命名以避免与基类属性冲突) @State brightnessValue: number = 40; // 默认亮度值 // 添加一个根据亮度计算颜色的方法 private getBrightnessColor(): string { // 根据亮度值计算颜色,亮度越高颜色越浅 const brightnessValue = Math.min(100, Math.max(0, this.brightnessValue)); // 改进计算逻辑:确保颜色变化更明显 // 使用一个基础的灰色范围,从深灰到浅灰 const minGray = 50; // 最暗时的灰度值 const maxGray = 230; // 最亮时的灰度值 const grayValue = Math.floor(minGray + (maxGray - minGray) * (brightnessValue / 100)); const hexValue = grayValue.toString(16).padStart(2, '0'); return `#${hexValue}${hexValue}${hexValue}`; } // 根据当前模式和亮度值计算菜单项颜色 private getMenuItemColor(): string { // 根据当前模式和亮度值计算颜色 if (this.currentMode === 'dark') { // 深色模式下,亮度越高颜色越浅 const brightnessFactor = this.brightnessValue / 100; const darkValue = Math.floor(51 + (102 - 51) * (1 - brightnessFactor)); // 从 #333333 到 #666666 const hexValue = darkValue.toString(16).padStart(2, '0'); return `#${hexValue}${hexValue}${hexValue}`; } else { // 浅色模式下,亮度越高颜色越深 const brightnessFactor = this.brightnessValue / 100; const lightValue = Math.floor(255 - (255 - 204) * brightnessFactor); // 从 #FFFFFF 到 #CCCCCC const hexValue = lightValue.toString(16).padStart(2, '0'); return `#${hexValue}${hexValue}${hexValue}`; } } // 局部主题相关状态 @State localTheme: CustomTheme = new PurpleTheme(); // 局部主题默认值 // 监听主题变化,更新全局UI颜色 onWillApplyTheme(theme: Theme) { this.menuItemColor = theme.colors.backgroundPrimary; } // 切换深浅色模式 private changeMode(mode: string) { this.currentMode = mode; // 更新菜单项颜色以反映主题变化和亮度 this.menuItemColor = this.getMenuItemColor(); } // 切换局部主题 private switchLocalTheme() { this.localTheme = this.localTheme instanceof PurpleTheme ? new BlueTheme() : new PurpleTheme(); } // 更新亮度值的方法 private updateBrightness(value: number) { this.brightnessValue = Math.min(100, Math.max(0, value)); // 确保值在0-100范围内 // 更新菜单项颜色以反映亮度变化 this.menuItemColor = this.getMenuItemColor(); } build() { Column() { // 全局主题区域(遵循全局风格) Text('全局主题区域') .fontSize(20) .margin(10) .fontWeight(700) // 使用数字值代替 FontWeight.Bold List({ space: 10 }) { // 1. 颜色模式切换 ListItem() { Column({ space: 15 }) { Text('颜色模式切换') .margin({ top: 5, left: 20 }) .width('100%') Row() { Column() { Text('浅色模式') .fontSize(16) .alignSelf(1) Radio({ group: 'mode', value: 'light' }) .checked(this.currentMode === 'light') .onChange(() => this.changeMode('light')) } .width('40%') Column() { Text('深色模式') .fontSize(16) .alignSelf(1) Radio({ group: 'mode', value: 'dark' }) .checked(this.currentMode === 'dark') .onChange(() => this.changeMode('dark')) } .width('40%') } } .width('100%') .height(90) .borderRadius(10) // 使用根据模式和亮度计算的颜色 .backgroundColor(this.getMenuItemColor()) } // 2. 亮度调节 ListItem() { Column() { Text('亮度调节') .margin({ top: 5, left: 20 }) .width('100%') Text(`当前亮度: ${this.brightnessValue}%`) .fontSize(14) .margin({ left: 14, top: -5 }) .fontColor('#666666') // 使用 Row 组件创建一个进度条容器 Row() { // 进度条填充部分 Column() { } .width(`${this.brightnessValue}%`) .height('100%') .borderRadius(5) .backgroundColor('#444444') // 进度条背景部分 Column() { } .width(`${100 - this.brightnessValue}%`) .height('100%') .borderRadius(5) .backgroundColor('#DDDDDD') } .width('100%') .height(10) .borderRadius(5) .margin({ left: 14, top: 5, right: 14, bottom: 5 }) Slider({ value: this.brightnessValue, min: 0, max: 100 }) .width('100%') .margin({ left: 14, right: 14, top: 5, bottom: 14 }) .onChange((value: number) => { this.updateBrightness(value); }) } .width('100%') .height(90) // 增加高度以容纳所有元素 .borderRadius(10) // 根据亮度值改变背景颜色 .backgroundColor(this.getBrightnessColor()) } } .margin({ bottom: 10 }) // 进一步增加底部间距,避免被局部主题区域遮挡 // 局部主题区域(独立风格) Text('局部主题区域(可独立切换)') .fontSize(20) .margin({ top: 20, bottom: 10, left: 10, right: 10 }) // 增加顶部间距 .fontWeight(700) Column() { Column() { Text('局部主题文本') .fontSize(18) .fontColor(this.localTheme && this.localTheme.colors ? this.localTheme.colors.fontPrimary : $r('sys.color.font_primary')) .margin(5) Text('局部主题背景示例') .backgroundColor(this.localTheme && this.localTheme.colors ? this.localTheme.colors.backgroundEmphasize : $r('sys.color.background_primary')) .padding(10) .borderRadius(5) .margin(5) Button('切换局部主题') .onClick(() => this.switchLocalTheme()) .margin(5) } .width('100%') .padding(10) .borderRadius(10) .backgroundColor('#F5F5F5') } } .padding(10) .width('100%') .height('100%') } } 1.5方案成果总结通过上述方案,解决了鸿蒙应用主题适配的核心问题,达成以下成果:(一)全局风格统一:通过AppGlobalTheme定义统一主题颜色,结合ThemeControl设置全局默认主题,确保应用各页面风格一致;(二)深浅色模式自适应:通过资源文件配置颜色的 “浅色 / 深色版本”,配合$r引用,实现模式切换时颜色自动适配;(三)实时更新 UI:通过onWillApplyTheme监听主题变化,确保列表项背景等 UI 元素颜色随主题实时更新,避免视觉异常;(四)局部主题灵活定制:使用组件隔离局部页面,支持独立主题(如紫色系 / 蓝色系)的定义与切换,不影响全局风格。(五)该方案充分利用鸿蒙 ArkUI 的主题能力,兼顾了全局一致性与局部灵活性,提升了应用的视觉体验和可维护性。
-
1. 关键技术难点总结1.1 问题说明在蓝牙设备连接场景中,主要面临以下技术难点:设备发现不稳定:蓝牙设备可能因电源管理或信号干扰导致发现不稳定,例如电动自行车通过蓝牙发现时可能出现信号波动配对流程复杂:不同类型的蓝牙设备有着各自的安全验证机制和配对要求,有些设备需要输入密码,有些需要确认配对请求,还有些设备需要特定的配对顺序。用户在连接设备时往往需要经过多个步骤才能完成配对,操作繁琐且容易出错。连接状态管理困难:设备在使用过程中可能出现连接断开或信号不稳定的情况1.2 原因分析硬件兼容性问题:不同品牌设备的蓝牙模块实现可能存在差异,如电动自行车、耳机、手环等设备的蓝牙模块差异连接状态监听:蓝牙连接状态变化需要及时监听和响应,否则会影响用户体验异常处理机制:设备在使用过程中可能遇到各种异常情况,需要完善的异常处理机制2. 解决思路针对蓝牙设备连接场景,采用以下解决思路:分层架构设计:将蓝牙功能分为设备发现、配对管理、连接控制三个层次,每一层专注处理特定任务,降低系统复杂性连接状态跟踪:实时跟踪设备的连接状态变化,确保能及时了解设备是已连接、已断开还是正在连接中异步操作处理:所有蓝牙操作均采用异步方式处理,避免阻塞主线程完善的异常处理:建立完整的异常处理机制,确保在各种异常情况下都能正确处理3. 解决方案3.1 核心代码实现3.1.1 设备发现模块 (DiscoveryDeviceManager.ets)export class DiscoveryDeviceManager { /** * 开始扫描蓝牙设备 * @param callback 扫描结果回调 */ public startDiscovery(callback: Callback<Array<connection.DiscoveryResult>>) { try { // 注册扫描结果监听 connection.on('discoveryResult', callback); } catch (err) { console.error('注册扫描回调失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } try { // 检查是否正在扫描 let scan = connection.isBluetoothDiscovering(); if (!scan) { // 开始扫描设备 connection.startBluetoothDiscovery(); } } catch (err) { console.error('启动蓝牙扫描失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } } /** * 停止扫描蓝牙设备 * @param callback 扫描结果回调 */ public stopDiscovery(callback: Callback<Array<connection.DiscoveryResult>>) { try { let scan = connection.isBluetoothDiscovering(); if (scan) { // 停止扫描 connection.stopBluetoothDiscovery(); } // 取消注册扫描结果监听 connection.off('discoveryResult', callback); } catch (err) { console.error('停止蓝牙扫描失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } } /** * 获取已配对设备 * @returns 已配对设备列表 */ public getPairedDevices() { try { // 获取已配对设备信息 let devices = connection.getPairedDevices(); return devices; } catch (err) { console.error('获取已配对设备失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); return []; } } } 3.1.2 配对管理模块 (PairDeviceManager.ets)export class PairDeviceManager { device: string = ''; pairState: connection.BondState = connection.BondState.BOND_STATE_INVALID; a2dpSrc = a2dp.createA2dpSrcProfile(); hfpAg = hfp.createHfpAgProfile(); hidHost = hid.createHidHostProfile(); /** * 发起配对 * @param device 设备ID * @param callback 配对状态回调 */ public startPair(device: string, callback: Callback<connection.BondStateParam>) { this.device = device; try { // 订阅配对状态变化事件 connection.on('bondStateChange', callback); } catch (err) { console.error('订阅配对状态失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } try { // 发起配对 connection.pairDevice(device).then(() => { console.info('开始配对设备'); }, (error: BusinessError) => { console.error('配对设备失败: errCode:' + error.code + ',errMessage:' + error.message); }); } catch (err) { console.error('发起配对失败: errCode:' + err.code + ',errMessage:' + err.message); } } /** * 连接设备 * @param device 设备ID */ public async connect(device: string) { try { // 获取设备支持的Profile let uuids = await connection.getRemoteProfileUuids(device); console.info('设备支持的Profile: '+ JSON.stringify(uuids)); let allowedProfiles = 0; // 根据设备支持的Profile注册对应的连接状态监听 if (uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_A2DP_SINK.toLowerCase())) { console.info('设备支持A2DP'); allowedProfiles++; this.a2dpSrc.on('connectionStateChange', this.onA2dpConnectStateChange); } if (uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_HFP_HF.toLowerCase())) { console.info('设备支持HFP'); allowedProfiles++; this.hfpAg.on('connectionStateChange', this.onHfpConnectStateChange); } if (uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_HID.toLowerCase()) || uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_HOGP.toLowerCase())) { console.info('设备支持HID'); allowedProfiles++; this.hidHost.on('connectionStateChange', this.onHidConnectStateChange); } // 如果存在可用的Profile,则发起连接 if (allowedProfiles > 0) { connection.connectAllowedProfiles(device).then(() => { console.info('连接设备成功'); }, (error: BusinessError) => { console.error('连接设备失败: errCode:' + error.code + ',errMessage:' + error.message); }); } } catch (err) { console.error('连接设备异常: errCode:' + err.code + ',errMessage:' + err.message); } } // A2DP连接状态变化回调 onA2dpConnectStateChange = (data: baseProfile.StateChangeParam) => { console.info(`A2DP连接状态: ${JSON.stringify(data)}`); }; // HFP连接状态变化回调 onHfpConnectStateChange = (data: baseProfile.StateChangeParam) => { console.info(`HFP连接状态: ${JSON.stringify(data)}`); }; // HID连接状态变化回调 onHidConnectStateChange = (data: baseProfile.StateChangeParam) => { console.info(`HID连接状态: ${JSON.stringify(data)}`); }; } 3.1.3 UI页面实现 (BluetoothDevicePage.ets)@Observed class DeviceInfo { deviceId: string = ''; discoveryResult: connection.DiscoveryResult | null = null; deviceState: connection.BondState | constant.ProfileConnectionState = connection.BondState.BOND_STATE_INVALID } @Entry @Component struct BluetoothDevicePage { @State message: string = '蓝牙设备连接'; @State deviceList: DeviceInfo[] = []; @State isScanning: boolean = false; @State connectedDevice: string = ''; @State deviceId: string = ''; pairState: connection.BondState = connection.BondState.BOND_STATE_INVALID; private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; // 扫描结果回调 private onReceiveEvent = (data: Array<connection.DiscoveryResult>) => { console.info('发现蓝牙设备: ' + JSON.stringify(data)); data.forEach(item => { let deviceInfo: DeviceInfo = new DeviceInfo(); deviceInfo.deviceId = item.deviceId; deviceInfo.discoveryResult = item; this.deviceList.push(deviceInfo); }) } // 配对状态变化回调 private onBondStateEvent = (data: connection.BondStateParam) => { console.info('配对结果: '+ JSON.stringify(data)); if (data && data.deviceId == this.deviceId) { this.pairState = data.state; } if (data.state == connection.BondState.BOND_STATE_BONDED) { // 配对成功,显示提示并连接设备 promptAction.showToast({message: '配对成功'}) this.message = '连接成功'; this.connectedDevice = data.deviceId this.setDeviceState(data, connection.BondState.BOND_STATE_BONDED) // 连接已配对设备 pairDeviceManager.connect(data.deviceId); } } private setDeviceState(data: connection.BondStateParam, state: connection.BondState) { // 更新设备状态 this.deviceList = this.deviceList.map((item) => { let temp = new DeviceInfo(); temp.deviceId = item.deviceId.trimEnd() + ' ' temp.discoveryResult = item.discoveryResult; if (item.deviceId == data.deviceId) { temp.deviceState = state; } else { temp.deviceState = item.deviceState; } return temp; }) } private startDiscovery() { if (this.isScanning) { return; } let permissions: string[] = ['ohos.permission.ACCESS_BLUETOOTH']; SysPermissionUtils.request(this.context, (isGranted: boolean, permission: string) => { if (permissions.indexOf(permission) >= 0 && isGranted) { this.isScanning = true; this.message = '正在扫描设备...'; discoveryDeviceManager.startDiscovery(this.onReceiveEvent); // 10秒后自动停止扫描 setTimeout(() => { this.stopDiscovery(); }, 10000); } }, 'ohos.permission.ACCESS_BLUETOOTH'); } private stopDiscovery() { if (!this.isScanning) { return; } this.isScanning = false; this.message = '扫描已停止'; discoveryDeviceManager.stopDiscovery(this.onReceiveEvent); } private connectToDevice(device: connection.DiscoveryResult) { this.message = '正在连接设备...'; this.deviceId = device.deviceId; // 发起配对 pairDeviceManager.startPair(device.deviceId, this.onBondStateEvent); } build() { Column() { Text(this.message) .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) if (this.connectedDevice) { Text('已连接设备: ' + this.connectedDevice) .fontSize(16) .fontColor('#007DFF') .margin({ top: 10 }) } Row() { Button(this.isScanning ? '停止扫描' : '开始扫描') .onClick(() => { if (this.isScanning) { this.stopDiscovery(); } else { this.startDiscovery(); } }) .width('45%') .height(50) .margin({ right: 10 }) Button('查看已配对设备') .onClick(() => { let pairedDevices = discoveryDeviceManager.getPairedDevices(); if (pairedDevices.length > 0) { this.message = '找到 ' + pairedDevices.length + ' 个已配对设备'; } else { this.message = '没有已配对的设备'; } }) .width('45%') .height(50) } .width('90%') .margin({ top: 20 }) Text('发现的设备列表') .fontSize(18) .fontWeight(FontWeight.Bold) .margin({ top: 30, bottom: 10 }) List() { ForEach(this.deviceList, (item: DeviceInfo) => { ListItem() { Column() { Row() { Text(item.discoveryResult?.deviceName || '未知设备') .fontSize(16) .fontWeight(FontWeight.Bold) Blank() if ( connection.BondState.BOND_STATE_BONDED == item.deviceState ) { Text('已配对') .fontSize(14) .fontColor('#00B51D') .onClick(() => { // 连接已配对设备 if (item.discoveryResult) { pairDeviceManager.connect(item.discoveryResult.deviceId); } }) } else if(constant.ProfileConnectionState.STATE_CONNECTED == item.deviceState) { Text('已连接') .fontSize(14) .fontColor('#00B51D') } else { Button(`连接`) .onClick(() => { if (item.discoveryResult) { this.connectToDevice(item.discoveryResult); } }) .height(30) .fontSize(14) } } .width('100%') Row() { Text('ID: ' + item.deviceId) .fontSize(12) .fontColor('#888888') Blank() Text('信号: ' + item.discoveryResult?.rssi + 'dBm') .fontSize(12) .fontColor('#888888') } .width('100%') .margin({ top: 5 }) } .width('100%') .padding(15) } }, (item: DeviceInfo) => item.deviceId) } .layoutWeight(1) .width('90%') .margin({ bottom: 20 }) } .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Start) .width('100%') .height('100%') } } 4. 方案成果总结本方案通过分层架构设计,实现了稳定可靠的蓝牙设备连接功能。系统能够自动发现附近的蓝牙设备,如电动自行车、耳机、手环等,并支持一键配对和连接。用户可以通过直观的界面轻松管理设备连接状态,享受便捷的无线控制体验。能够及时感知设备连接状态的变化,无论是设备连接成功、断开还是正在连接中,用户都能获得清晰的反馈。这种设计大大提升了用户体验,让用户能够随时掌握设备连接情况。该技术方案具有良好的通用性和扩展性,适用于各种蓝牙设备连接场景。通过完善的异常处理机制和优化的扫描策略,能够有效应对不同设备的连接挑战,为用户提供稳定可靠的蓝牙连接服务,满足日常生活中各种智能设备的连接需求。
-
1.1问题说明在开发鸿蒙应用的屏幕录制功能时,会遇到不少实际问题,主要集中在这几个方面:一是录屏时要全程处理文件的创建、读写和关闭,要是操作不规范,很容易出现文件资源无法正常释放,或者录制内容写不进文件的问题;二是录屏功能对手机系统资源消耗较大,如果功能启动后没有及时关闭,会导致手机卡顿、耗电快,影响应用和系统的正常运行;三是录屏时存在隐私泄露风险,需要准确避开包含隐私信息的窗口,还要能灵活应对进入或退出隐私场景的情况;四是录屏过程中可能出现多种意外状况,比如用户主动停止录屏、来了电话、麦克风用不了、切换账号等,要是没有全面的监控机制,就会出现功能失控或出问题后没有提示的情况;1.2原因分析(一)文件问题根源:录屏内容需持续保存到本地文件,若未按“打开-使用-关闭”流程操作(如中断后未关文件),会导致资源占用;另外,在鸿蒙系统的安全文件管理规则下,如果写错了文件保存路径,会直接导致文件创建失败,录屏也没法继续。(二)资源占用根源:鸿蒙录屏组件资源消耗大,需通过系统接口启停。如果只启动了录屏功能,结束后却没有执行关闭操作,会导致手机的音频、视频处理资源一直被占用,进而引发应用卡顿或和其他功能冲突的问题。(三)隐私安全根源:应用的部分窗口会显示用户敏感信息,比如输入密码的界面、私人数据展示页,要是录屏功能默认录制所有窗口,就会泄露隐私;(四)状态监控根源:录屏功能会受到用户操作、系统提示、硬件状态等多种因素影响,可能出现的情况比较复杂。如果没有做好全面的监控设置,就没法及时应对各种变化;要是缺少问题处理机制,出现麦克风权限被拒绝这类问题时,开发人员和用户都没法及时知道原因。(五)设置匹配根源:录屏参数需适配手机硬件与文件格式。清晰度过高或过低会影响效果,音视频参数不兼容会导致无声音,格式与后缀不匹配则视频无法播放。1.3解决思路(一)规范文件操作:按照鸿蒙系统的文件管理规则,获取应用专属的安全文件路径,建立“创建文件-保存内容-关闭文件”的完整流程,确保录屏从开始到结束,文件操作都有章可循,避免资源浪费。(二)做好功能启停管理:明确录屏功能组件的启动、初始化、使用和关闭时机,不管是正常结束录屏还是出现意外情况,都要及时关闭功能组件,释放手机的音频、视频处理等系统资源。(三)构建隐私保护机制:通过指定隐私窗口的标识,精准跳过这些窗口的录制;同时监控手机是否进入或退出隐私场景,做到进入隐私场景时暂停录制敏感内容,退出后再恢复,避免隐私泄露。(四)完善监控和问题处理:设置录屏状态变化和问题提示的监控机制,覆盖用户操作、系统事件、硬件故障等各种情况,确保不管出现什么状态都能及时发现,遇到问题也能快速定位原因。(五)优化参数设置:结合大多数手机的性能,设置合适的默认参数,比如视频清晰度、声音采样质量等,同时让视频文件格式和后缀保持一致,确保录制的视频能正常播放,兼容性更好。1.4解决方案借助鸿蒙系统的媒体处理和文件管理工具,开发了一个专门的录屏管理功能模块,把文件操作、参数设置、状态监控、录屏启停等核心功能整合在一起,实现了功能的模块化设计,方便后续复用和调整。组件代码示例:// 导入鸿蒙系统媒体库与文件管理核心工具 import { media } from '@kit.MediaKit'; import { fileIo as fs } from '@kit.CoreFileKit'; import { Context } from '@kit.AbilityKit'; /** * 鸿蒙应用录屏管理类 * 整合文件操作、参数配置、状态监控、隐私保护等核心能力 */ export class HarmonyScreenCaptureManager { // 录屏核心实例 private captureRecorder?: media.AVScreenCaptureRecorder; // 录屏文件句柄 private captureFile: fs.File | null = null; // 录屏配置参数 private captureConfig?: media.AVScreenCaptureRecordConfig; // 隐私窗口ID列表(可根据实际需求修改) private privacyWindowIds: number[] = [57, 86]; // 录屏状态标识 private isCapturing: boolean = false; // 类销毁时自动清理资源 public destroy(): void { if (this.isCapturing) { this.stopCapture().catch(err => console.error('销毁时停止录屏失败:', err)); } } /** * 1. 规范创建录屏文件(沙箱路径,避免文件路径错误) * @param context 应用上下文 * @returns 是否创建成功 */ private async createCaptureFile(context: Context): Promise<boolean> { try { // 鸿蒙应用沙箱路径:确保文件操作符合系统安全规则 const capturePath = `${context.filesDir}/screen_record_${Date.now()}.mp4`; // 读写+创建模式打开文件,获取文件句柄 this.captureFile = fs.openSync(capturePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); console.info(`录屏文件创建成功,路径:${capturePath}`); return true; } catch (err) { console.error('录屏文件创建失败:', (err as Error).message); this.captureFile = null; return false; } } /** * 2. 初始化录屏配置参数(匹配硬件性能) * 对应技术总结中"优化参数设置"需求 */ private initCaptureConfig(): boolean { if (!this.captureFile) return false; // 核心参数配置:与技术总结中参数保持一致 this.captureConfig = { frameWidth: 768, // 视频宽度 frameHeight: 1280, // 视频高度 fd: this.captureFile.fd, // 关联文件句柄 videoBitrate: 10 * 1024 * 1024, // 视频比特率10Mbps audioSampleRate: 48000, // 音频采样率48kHz audioChannelCount: 2, // 双声道 audioBitrate: 96 * 1024, // 音频比特率96kbps displayId: 0, // 录制主屏幕 // 主流编码格式:确保视频兼容性 preset: media.AVScreenCaptureRecordPreset.SCREEN_RECORD_PRESET_H264_AAC_MP4 }; return true; } /** * 3. 关闭录屏文件(释放文件资源) */ private closeCaptureFile(): void { if (this.captureFile) { try { fs.closeSync(this.captureFile); console.info('录屏文件已安全关闭'); } catch (err) { console.error('关闭录屏文件失败:', (err as Error).message); } finally { this.captureFile = null; } } } /** * 1. 隐私保护:跳过指定隐私窗口录制 * 对应技术总结"构建隐私保护机制"需求 */ private async setPrivacyProtection(): Promise<void> { if (this.captureRecorder && this.privacyWindowIds.length > 0) { try { // 豁免隐私窗口:避免密码页、支付页被录制 await this.captureRecorder.skipPrivacyMode(this.privacyWindowIds); console.info(`已豁免隐私窗口,窗口ID:${this.privacyWindowIds.join(',')}`); } catch (err) { console.error('设置隐私窗口豁免失败:', (err as Error).message); } } } /** * 2. 状态与异常监控(覆盖12种核心场景) * 对应技术总结"完善监控和问题处理"需求 */ private registerCaptureCallbacks(): void { if (!this.captureRecorder) return; // 录屏状态变化监听 this.captureRecorder.on('stateChange', async (state: media.AVScreenCaptureStateCode) => { switch (state) { case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_STARTED: this.isCapturing = true; console.info('录屏已成功启动'); break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_CANCELED: console.info('录屏被系统拒绝(无权限或资源占用)'); await this.stopCapture(); break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_STOPPED_BY_USER: console.info('用户主动停止录屏'); await this.stopCapture(); break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_ENTER_PRIVATE_SCENE: console.info('进入隐私场景,暂停录屏'); await this.captureRecorder.pauseRecording(); // 暂停录制敏感内容 break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_EXIT_PRIVATE_SCENE: console.info('退出隐私场景,恢复录屏'); await this.captureRecorder.resumeRecording(); // 恢复录制 break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_STOPPED_BY_CALL: console.info('通话中断,停止录屏'); await this.stopCapture(); break; default: console.info(`录屏状态变更:${state}`); break; } }); // 异常监听:快速定位故障原因 this.captureRecorder.on('error', (err: { code: number, message: string }) => { console.error(`录屏异常 - 错误码:${err.code},原因:${err.message}`); this.stopCapture().catch(e => console.error('异常后停止录屏失败:', e)); }); } /** * 3. 取消监听:避免内存泄漏 */ private unregisterCaptureCallbacks(): void { if (this.captureRecorder) { this.captureRecorder.off('stateChange'); this.captureRecorder.off('error'); } } /** * 启动录屏(完整流程:初始化-配置-启动) * @param context 应用上下文 * @returns 启动结果 */ public async startCapture(context: Context): Promise<boolean> { try { // 1. 初始化录屏实例 this.captureRecorder = await media.createAVScreenCaptureRecorder(); if (!this.captureRecorder) { console.error('录屏实例创建失败'); return false; } // 2. 创建录屏文件(核心前置步骤) const fileCreated = await this.createCaptureFile(context); if (!fileCreated) return false; // 3. 初始化录屏配置 const configInited = this.initCaptureConfig(); if (!configInited || !this.captureConfig) { console.error('录屏参数配置失败'); this.closeCaptureFile(); return false; } // 4. 初始化录屏组件 await this.captureRecorder.init(this.captureConfig); // 5. 注册监控与隐私保护 this.registerCaptureCallbacks(); await this.setPrivacyProtection(); // 6. 启动录制 await this.captureRecorder.startRecording(); return true; } catch (err) { console.error('录屏启动失败:', (err as Error).message); // 异常时清理资源 this.stopCapture().catch(e => console.error('启动失败后清理资源出错:', e)); return false; } } /** * 停止录屏(规范释放资源,避免卡顿) * 对应技术总结"做好功能启停管理"需求 */ public async stopCapture(): Promise<void> { if (!this.isCapturing) return; try { // 1. 停止录制核心操作 if (this.captureRecorder) { await this.captureRecorder.stopRecording(); console.info('录屏已停止'); } } finally { // 2. 强制清理资源(无论是否异常都执行) this.unregisterCaptureCallbacks(); if (this.captureRecorder) { await this.captureRecorder.release(); // 释放录屏组件资源 this.captureRecorder = undefined; } this.closeCaptureFile(); // 关闭文件句柄 this.isCapturing = false; } } /** * 获取当前录屏状态 * @returns 是否正在录屏 */ public getCaptureState(): boolean { return this.isCapturing; } } 演示代码示例:// 在Ability或Page中集成使用 import { HarmonyScreenCaptureManager } from './HarmonyScreenCaptureManager'; import { Ability } from '@kit.AbilityKit'; export default class MainAbility extends Ability { // 初始化录屏管理实例 private captureManager: HarmonyScreenCaptureManager = new HarmonyScreenCaptureManager(); // 启动录屏(如按钮点击事件) async startCaptureClick() { const isStarted = await this.captureManager.startCapture(this.context); if (isStarted) { // 提示用户录屏已启动 console.info('用户已启动录屏'); } else { // 提示用户启动失败 console.info('录屏启动失败,请检查权限'); } } // 停止录屏(如按钮点击事件) async stopCaptureClick() { await this.captureManager.stopCapture(); console.info('用户已停止录屏'); } // 应用销毁时清理 onDestroy() { this.captureManager.destroy(); super.onDestroy(); } } 1.5方案成果总结(一) 功能完整性:实现了录屏从启动到停止的全流程管理,支持用户主动控制录屏启停,也能应对来电、账号切换等系统事件导致的录屏停止,还能有效跳过隐私窗口。该功能覆盖了12种常见的录屏状态和问题场景,既能满足基础的录屏需求,也为后续功能扩展留足了空间。(二)稳定性与性能:通过规范的录屏组件使用和释放流程,以及标准的文件操作步骤,避免了资源浪费和文件损坏问题;合理的音视频参数设置降低了手机的处理压力,在大多数鸿蒙手机上都能实现流畅录屏,不会出现明显的卡顿或手机发热现象。(三)隐私与安全:采用“精准跳过隐私窗口+监控隐私场景变化”的双重保护方式,有效避免了隐私窗口和系统隐私场景的内容被误录,符合鸿蒙应用的隐私安全要求,降低了应用因隐私问题违规的风险。(四)可维护性:将录屏功能拆分成多个独立模块,比如文件操作、参数设置、监控管理等,每个模块只负责一项工作,让代码更容易理解;同时完整记录录屏状态和问题信息,方便开发人员排查故障,降低后续维护难度。
-
1、关键技术难点总结1.1 问题说明(一)应用启动引导页重复显示:在安装应用首次启动时,确保应用展示引导页及重启后能正确识别是否已展示过引导页。若状态管理不当,可能导致引导页重复显示或首次启动时不显示,影响用户体验。(二)高亮引导指引错位及体验一致性:用户首次完成启动引导进入主页面后,面对应用内的多个功能按钮和操作区域,往往需要直观的指引来快速理解核心功能的使用方式 —— 这是降低用户学习成本、提升功能使用率的关键。若不引入专业的高亮引导组件,仅通过自定义方式实现操作指引,会面临诸多问题:难以精准定位目标功能元素,容易出现指引与实际按钮错位,尤其在不同尺寸设备上适配性差;无法实现蒙层聚焦效果(即仅高亮目标元素、模糊其他区域),导致用户注意力被无关内容分散,指引效果大打折扣;多步骤引导的流程控制复杂,手动管理步骤切换易出现逻辑漏洞(如步骤卡顿、重复或跳过);自定义指引样式与交互的一致性难以保证,可能与应用整体设计风格冲突,影响用户体验统一性。1.2 原因分析(一)状态管理复杂性:HarmonyOS的@StorageLink装饰器需要正确初始化才能实现跨页面状态共享,需在Ability中使用PersistentStorage.persistProp方法进行持久化配置。若初始化时机或配置方式错误,会导致状态无法持久化或跨页面同步。(二)高亮引导组件集成与适配逻辑复杂:组件配置依赖精准绑定:引导组件的高亮定位依赖目标元素正确绑定。若未按组件规范配置目标元素,会导致指引与目标元素错位。多设备适配逻辑缺失:不同设备的屏幕尺寸、分辨率存在差异,组件需通过自适应算法调整高亮区域和指示器位置。若未配置组件的自适应参数,或未针对不同设备尺寸做适配处理,会导致跨设备使用时指引错位。多步骤流程管理缺失:多步骤引导需通过统一管理步骤切换、监听页面变化。若未正确注册步骤切换回调、未处理步骤终止 / 重启逻辑,会导致步骤卡顿、重复或跳过,破坏指引流程连贯性。样式配置未统一规范:组件的指示器样式(如大小、位置、文字样式)需与应用设计规范一致。若未通过配置化方式统一管理样式参数,或自定义指示器时未遵循组件接口要求,会导致指引样式与应用整体风格冲突,影响体验一致性。2、解决思路通过状态持久化+路由控制+第三方组件的方式,设计APP启动引导与操作指引解决方案:基于@StorageLink和PersistentStorage实现首次启动状态的持久化管理优化页面生命周期中的路由跳转逻辑,确保条件性导航的稳定性引入@ohos/high_light_guide组件,简化多步骤高亮引导的配置与使用,第三方组件地址:https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fhigh_light_guide3、解决方案步骤1:配置应用入口的状态初始化// EntryAbility.ets import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; import { PersistentStorage } from '@ohos.data.persistentStorage'; const DOMAIN = 0x0000; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); } onDestroy(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); } onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } // 初始化首次启动状态,持久化存储 PersistentStorage.persistProp('isFirstLaunch', true); hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); }); } onWindowStageDestroy(): void { // Main window is destroyed, release UI related resources hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); } onForeground(): void { // Ability has brought to foreground hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); } onBackground(): void { // Ability has back to background hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); } } 步骤2:实现启动引导页面// GuidePage.ets import { router } from '@kit.ArkUI' @Entry @Component struct GuidePage { @State currentIndex: number = 0 @State isLastPage: boolean = false // 关联全局持久化状态 @StorageLink('isFirstLaunch') isFirstLaunch: boolean = true // 引导页数据 private guideData: Array<GuideItem> = [ { id: 1, title: '欢迎使用我们的应用', description: '发现更多精彩功能,提升您的使用体验', image: $r('app.media.startIcon'), backgroundColor: '#FF6B6B' }, { id: 2, title: '智能推荐', description: '基于您的喜好个性化推荐内容', image: $r('app.media.startIcon'), backgroundColor: '#4ECDC4' }, { id: 3, title: '随时同步', description: '多设备数据同步,无缝切换使用', image: $r('app.media.startIcon'), backgroundColor: '#45B7D1' }, { id: 4, title: '开始探索', description: '立即开始您的专属体验之旅', image: $r('app.media.startIcon'), backgroundColor: '#96CEB4' } ] build() { Column() { // 引导内容区域 this.buildGuideContent() // 指示器和按钮区域 this.buildIndicatorAndButton() } .width('100%') .height('100%') .backgroundColor(this.guideData[this.currentIndex].backgroundColor) } @Builder buildGuideContent() { Swiper() { ForEach(this.guideData, (item: GuideItem) => { this.buildGuideItem(item) }, (item: GuideItem) => item.id.toString()) } .width('100%') .height('85%') .index(this.currentIndex) .autoPlay(false) .indicator(false) .loop(false) .onAnimationEnd((index: number) => { this.handlePageChange(index) }) } @Builder buildGuideItem(item: GuideItem) { Column() { // 图片容器 Stack() { Image(item.image) .width(200) .height(200) .objectFit(ImageFit.Contain) .opacity(0.9) } .width('100%') .height('60%') // 文字内容 Column() { Text(item.title) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 12 }) Text(item.description) .fontSize(16) .fontColor(Color.White) .opacity(0.8) .textAlign(TextAlign.Center) .padding({ left: 40, right: 40 }) } .width('100%') .height('40%') .padding(20) } .width('100%') .height('100%') } @Builder buildIndicatorAndButton() { Column() { // 页面指示器 Row() { ForEach(this.guideData, (item: GuideItem, index: number) => { Circle({ width: 8, height: 8 }) .fill(index === this.currentIndex ? Color.White : '#FFFFFF60') .margin({ right: 8 }) .animation({ duration: 200, curve: Curve.EaseInOut }) }, (item: GuideItem) => item.id.toString()) } .margin({ bottom: 30 }) // 操作按钮 if (this.isLastPage) { Button('立即体验', { type: ButtonType.Capsule }) .width(200) .height(40) .backgroundColor(Color.White) .fontColor(this.guideData[this.currentIndex].backgroundColor) .fontSize(16) .fontWeight(FontWeight.Medium) .onClick(() => { this.handleStartApp() }) } else { Row() { Button('跳过', { type: ButtonType.Normal }) .borderRadius(20) .backgroundColor('#FFFFFF40') .fontColor(Color.Grey) .fontSize(14) .padding({ left: 20, right: 20 }) .onClick(() => { this.handleStartApp() }) Button('下一步', { type: ButtonType.Capsule }) .width(120) .height(40) .backgroundColor(Color.White) .fontColor(this.guideData[this.currentIndex].backgroundColor) .fontSize(14) .fontWeight(FontWeight.Medium) .margin({ left: 20 }) .onClick(() => { this.handleNext() }) } } } .width('100%') .height('15%') .justifyContent(FlexAlign.Center) } // 处理页面变化 private handlePageChange(index: number): void { this.currentIndex = index this.isLastPage = index === this.guideData.length - 1 } // 下一步操作 private handleNext(): void { if (this.currentIndex < this.guideData.length - 1) { this.currentIndex++ this.isLastPage = this.currentIndex === this.guideData.length - 1 } } // 开始使用应用 private handleStartApp(): void { // 更新首次启动状态为false this.isFirstLaunch = false // 跳转到主页面,使用replaceUrl避免返回引导页 router.replaceUrl({ url: 'pages/Index' }) } } // 数据类型定义 interface GuideItem { id: number title: string description: string image: Resource backgroundColor: string } 步骤3:实现主页面与操作指引功能// Index.ets import { router } from '@kit.ArkUI'; import { Controller, GuidePage, HighLightGuideBuilder, HighLightGuideComponent } from '@ohos/high_light_guide'; interface GuideConfig { targetId: string // 目标元素ID title: string // 指引标题 description: string // 指引描述 x: number // 指引显示位置x坐标 y: number // 指引显示位置y坐标 indicator: Function | null } @Entry @Component struct Index { @State message: string = 'Hello World'; // 关联全局持久化状态,判断是否需要跳转引导页 @StorageLink('isFirstLaunch') isFirstLaunch: boolean = true private builder: HighLightGuideBuilder | null = null; private controller: Controller | null = null; // 操作指引配置 private guideConfig: GuideConfig[] = [ { targetId: 'Simple', title: '核心功能', description: '这是应用的核心功能按钮,点击可以执行主要操作', x: 100, y: 100, indicator: this.SimpleIndicator }, { targetId: 'SimpleTwo', title: '数据输入', description: '在这里输入您需要处理的内容,支持多种格式', x: 200, y: 200, indicator: this.SimpleIndicatorTwo }, { targetId: 'SimpleEnd', title: '辅助功能', description: '这个按钮提供额外的辅助功能选项', x: 200, y: 400, indicator: this.SimpleIndicatorThree } ] // 在页面初始化时判断是否需要跳转引导页 aboutToAppear() { if (this.isFirstLaunch) { router.replaceUrl({ url: 'pages/GuidePage' }) } // 初始化高亮引导组件 this.builder = new HighLightGuideBuilder() .setLabel('guide1') .alwaysShow(true) .setOnPageChangedListener({ onPageChanged: (pageIndex: number) => { console.info('current page: ' + pageIndex) } }) // 配置引导步骤 this.guideConfig.forEach((item) => { this.builder?.addGuidePage(GuidePage.newInstance().addHighLight(item.targetId).setHighLightIndicator(item.indicator)) }) } build() { Column() { Stack() { // 集成高亮引导组件 HighLightGuideComponent({ // 限制引导页组件的蒙版覆盖的UI组件 highLightContainer: this.HighLightComponent, currentHLIndicator: null, builder: this.builder, onReady: (controller: Controller) => { this.controller = controller; // 开始显示引导页 this.controller.show(); } }) } } .width('100%') } // 引导页覆盖的主页面UI @Builder private HighLightComponent() { // 首页UI Column() { Text('Hello World') .fontSize(40) // 需要高亮的组件 Button('第一步') .onClick(() => { if (this.controller) { this.controller.show(); } }).id('Simple') Button('第二步') .id('SimpleTwo').margin({top: 50}) Button('完成') .id('SimpleEnd').margin({top: 50}) } .alignItems(HorizontalAlign.Center) .width('100%') .height('100%'); } // 引导指示器样式1 @Builder private SimpleIndicator() { Image($r('app.media.startIcon')) .width('100px') .height('100px') .position({ x: 100, y: 100 }) Text(`下一步`) .fontColor(Color.White) .position({ x: 100, y: 140 }) } // 引导指示器样式2 @Builder private SimpleIndicatorTwo() { Image($r('app.media.startIcon')) .width('100px') .height('100px') .position({ x: 230, y: 200 }) Text('下一步') .fontColor(Color.White) .position({ x: 230, y: 240 }) } // 引导指示器样式3 @Builder private SimpleIndicatorThree() { Image($r('app.media.startIcon')) .width('100px') .height('100px') .position({ x: 230, y: 290 }) Text('完成') .fontColor(Color.White) .position({ x: 230, y: 330 }) } } 4、方案成果总结(一)功能完整性:实现了完整的APP启动引导与操作指引功能。首次启动时自动展示多页引导内容,支持滑动切换、跳过和下一步操作;主页面集成了分步骤的功能高亮指引,清晰展示核心功能的使用方法,帮助用户快速熟悉应用。(二)状态管理可靠性:通过@StorageLink与PersistentStorage的结合,实现了首次启动状态的持久化存储。应用重启后能准确识别是否已展示过引导页,避免重复显示或漏显示,确保状态在跨页面和应用生命周期中保持一致。(三)导航逻辑稳定性:优化了页面跳转时机与方式,在主页面aboutToAppear生命周期中进行跳转判断,使用router.replaceUrl方法避免用户返回引导页,防止出现跳转循环或失败的问题,提升了导航流程的健壮性。(四)组件集成灵活性:成功集成@ohos/high_light_guide组件,通过配置化方式定义引导步骤、高亮元素和指示器样式。开发者可通过修改配置数组轻松增减引导步骤、调整指示器位置和样式,满足不同场景的指引需求,扩展性强。
-
1. 关键技术难点总结1.1 问题说明在鸿蒙应用开发中,实现设备列表发现及设备WiFi连接功能面临以下挑战:设备发现的准确性和实时性难以保证:在家庭网络环境中,设备可能随时上线或下线,需要准确识别在线设备并实时更新设备列表。用户体验优化问题:设备扫描过程需要一定时间,在此期间需要给用户适当的加载反馈,避免用户误以为应用无响应。设备WiFi连接的安全性和稳定性问题:在设备配网过程中,如何安全地传输WiFi密码,以及如何处理连接失败等异常情况。局域网设备发现与WiFi热点扫描的技术区别:很多开发者容易混淆WiFi热点扫描(发现可连接的WiFi网络)和局域网设备发现(发现已连接到当前网络的设备),需要明确区分这两种不同的技术实现。1.2 原因分析网络扫描是异步操作,需要合理处理异步流程和超时机制扫描过程需要时间,需要给用户适当的反馈和加载提示设备配网涉及网络通信,需要考虑安全传输和连接稳定性局域网设备发现需要网络编程技术,比简单的WiFi扫描更复杂2. 解决思路建立统一的设备发现机制:通过标准化网络扫描流程、优化设备识别算法、实现自动状态检测等方式,提高设备发现的准确性和实时性。构建可复用的设备发现组件:将设备发现和连接的核心逻辑封装成独立组件,提供标准化的接口供不同页面调用,减少重复代码编写。实现统一的网络状态管理:通过集中管理WiFi状态、连接状态、设备状态等信息,建立完善的异常处理机制,确保在网络环境变化时能够及时响应和处理。提供简化的设备连接接口:封装复杂的连接流程,提供一键式设备连接功能,开发者只需调用简单接口即可实现设备配网。一键联网时序图:3. 解决方案3.1 权限配置方案配置必要的权限:{ "module": { "requestPermissions": [ { "name": "ohos.permission.GET_WIFI_INFO", "reason": "$string:access_wifi_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }, { "name": "ohos.permission.SET_WIFI_INFO", "reason": "$string:set_wifi_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }, { "name": "ohos.permission.INTERNET", "reason": "$string:internet_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } } ] } } 3.2 核心代码实现方案局域网设备发现核心代码// 发现局域网设备 async discoverDevices(): Promise<void> { if (this.isDiscovering) { return; } // 检查WiFi状态 const isWifiActive = wifiManager.isWifiActive(); if (!isWifiActive) { promptAction.showToast({ message: 'WiFi未启用,请先开启WiFi' }); return; } this.isDiscovering = true; this.devices = []; promptAction.showToast({ message: '正在发现局域网设备...' }); try { // 获取当前网络段 const networkSegment = this.getNetworkSegment(); console.info(`开始扫描网络段: ${networkSegment}`); // 并行扫描多个IP地址 const promises: Promise<LanDevice | null>[] = []; for (let i = 1; i <= 20; i++) { const ip = `${networkSegment}.${i}`; promises.push(this.pingDevice(ip)); } // 等待所有扫描完成 const results = await Promise.all(promises); // 过滤掉空结果 this.devices = results.filter(device => device !== null) as LanDevice[]; promptAction.showToast({ message: `发现完成,找到${this.devices.length}个在线设备` }); } catch (error) { const businessError = error as BusinessError; promptAction.showToast({ message: `设备发现失败: ${businessError.message}` }); } finally { this.isDiscovering = false; } } // ping设备检查是否在线,实际业务中根据设备支持协议修改 async pingDevice(ip: string): Promise<LanDevice | null> { try { // 创建TCP socket连接测试端口 const tcp = socket.constructTCPSocketInstance(); // 尝试连接常见端口 const ports = [80, 443, 22, 23, 53]; for (const port of ports) { try { await tcp.connect({ address: { family: 1, address: ip, port: port }, timeout: 2000 }); await tcp.close(); // 如果连接成功,说明设备在线 return { ip: ip, name: `设备-${ip}`, port: port, status: 'online' }; } catch (connectError) { // 连接失败,继续尝试下一个端口 continue; } } // 所有端口都连接失败,设备可能离线 return { ip: ip, name: `设备-${ip}`, status: 'offline' }; } catch (error) { // 发生其他错误,返回离线设备 return { ip: ip, name: `设备-${ip}`, status: 'offline' }; } } 设备连接核心代码// 连接设备到WiFi网络 connectToDeviceWithPassword() { if (!this.selectedDevice || !this.wifiPassword || !this.currentSSID) { promptAction.showToast({ message: '请输入完整的WiFi信息' }); return; } this.isConnecting = true; this.showDialog = false; try { // 在实际应用中,这里应该通过网络协议将WiFi配置发送到目标设备 // 例如通过HTTP请求、UDP广播或其他通信方式 console.info(`正在将WiFi配置发送到设备 ${this.selectedDevice.ip}`); console.info(`SSID: ${this.currentSSID}, Password: ${this.wifiPassword}`); // 模拟设备配网过程 setTimeout(() => { this.isConnecting = false; this.selectedDevice = null; this.wifiPassword = ''; promptAction.showToast({ message: '设备配网成功' }); }, 3000); } catch (error) { this.isConnecting = false; const businessError = error as BusinessError; promptAction.showToast({ message: `设备配网失败: ${businessError.message}` }); } } 设备列表组件@Component struct DeviceItem { @Prop device: LanDevice; @Link isConnecting: boolean; showPasswordDialog: (device: LanDevice) => void = (device: LanDevice) => {}; // 提供默认空函数 build() { Row() { Column() { Text(this.device.name || '未知设备') .fontSize(16) .fontWeight(FontWeight.Bold) Text(`IP: ${this.device.ip}`) .fontSize(12) .fontColor('#666666') if (this.device.mac) { Text(`MAC: ${this.device.mac}`) .fontSize(10) .fontColor('#999999') } // 添加设备在线状态显示 Row() { Circle() .width(8) .height(8) .fill(this.device.status === 'online' ? '#00cc00' : '#cc0000') .margin({ right: 5 }) Text(this.device.status === 'online' ? '在线' : '离线') .fontSize(12) .fontColor(this.device.status === 'online' ? '#00cc00' : '#cc0000') } .margin({ top: 5 }) } .layoutWeight(1) .padding({ left: 10 }) // 一键联网按钮 Button('一键联网') .fontSize(12) .backgroundColor('#007DFF') .fontColor('#ffffff') .margin({ right: 10 }) .enabled(!this.isConnecting) .onClick(() => { // 调用showPasswordDialog函数 this.showPasswordDialog(this.device); }) } .height(80) .width('100%') .border({ width: 1, color: '#eeeeee' }) .borderRadius(8) .padding(10) } } 4. 方案成果总结设备连接功能:提供设备WiFi连接功能,支持密码输入和连接状态反馈,使用wifiManager.getIpInfo()获取当前网络信息,通过网络段扫描实现局域网设备发现,使用TCP socket连接测试设备在线状态,合理处理异步操作和UI状态更新,实现设备WiFi连接功能,通过模拟方式演示配网过程数据展示清晰,用户体验优化:直观显示设备信息,包括设备状态,提供扫描状态提示和进度反馈
-
1.1问题说明在鸿蒙应用界面开发中,天气相关场景存在以下核心问题:动态联动缺失:界面背景无法根据实时天气(晴天、下雨、多云等)自动切换对应动态效果(如晴天阳光 GIF、雨天雨滴 GIF),静态背景难以传递天气信息,用户体验割裂;加载与流畅度问题:动态图(GIF/WebP)加载缓慢、卡顿,首次加载白屏,重复加载浪费资源;适配与切换体验差:动图在不同屏幕尺寸下拉伸 / 裁剪,天气切换时背景突变无过渡,视觉突兀;异常场景不稳定:未知天气类型、资源加载失败时无兜底方案,导致界面显示异常。1.2原因分析(一)需求与资源层:未将 “天气 - 动态背景联动” 纳入核心交互设计,缺乏天气类型与动图资源的标准化映射关系,资源管理混乱;(二)加载策略层:未采用预加载、缓存机制,动图加载依赖实时解析,且未区分设备性能(如低内存设备加载大图),导致加载慢、卡顿;(三)渲染与适配层:未利用鸿蒙布局能力处理屏幕适配,动图尺寸与容器不匹配;未设计过渡动画,状态切换时无视觉缓冲;(四)状态与异常层:缺乏稳定的天气状态监听机制,无法实时触发背景更新;未考虑资源加载失败、内存不足等异常场景,无兜底逻辑。1.3解决思路围绕 “天气状态→资源匹配→高效加载→流畅渲染→异常兜底” 全链路设计解决方案:(一)资源标准化:定义核心天气类型枚举(晴天、下雨等),建立天气与动图资源的映射表,统一管理资源;同时优化动图格式(如 WebP 替代 GIF)、压缩体积,按设备分辨率提供多套资源;(二)状态联动化:设计天气状态管理机制,支持实时监听天气变化(对接 API 或模拟切换),状态变更时自动触发背景更新;(三)加载高效化:通过预加载(启动时加载高频天气动图)、本地缓存(沙箱存储 + 过期清理)减少加载耗时,优先使用内存缓存资源;(四)渲染流畅化:利用鸿蒙硬件加速渲染,通过ImageFit.Cover适配屏幕;添加透明度渐变动画,实现天气切换时的平滑过渡;(五)异常可控化:针对加载失败、未知天气、低内存等场景,设置默认静态背景兜底,确保界面稳定性。1.4解决方案基于鸿蒙 ArkTS 语言(Stage 模型),通过 “枚举定义 - 资源映射 - 组件封装 - 状态联动 - 优化策略” 五层架构,实现根据天气自动切换动态背景的功能,同时解决加载速度、适配、流畅度问题。代码示例:// 导入必要模块 import { WeatherType } from './weatherType'; // 假设WeatherType在单独文件中,若同文件可省略 import { ImageSource, PixelMap, image } from '@ohos.multimedia.image'; import fs from '@ohos.file.fs'; import { getContext } from '@ohos.app.ability.UIAbilityContext'; import { animateTo } from '@ohos.ui'; import { Resource, ResourceManager } from '@ohos.resourceManager'; import { ImageFit } from '@ohos.multimedia.image'; // 天气-动图资源映射(使用WebP格式优化体积) export const WeatherGifMap: Record<WeatherType, Resource> = { [WeatherType.SUNNY]: $r('app.media.sunny_webp'), [WeatherType.RAINY]: $r('app.media.rainy_webp'), [WeatherType.CLOUDY]: $r('app.media.cloudy_webp'), [WeatherType.SNOWY]: $r('app.media.snowy_webp'), [WeatherType.OVERCAST]: $r('app.media.overcast_webp') }; export const DEFAULT_STATIC: Resource = $r('app.media.default_background'); // 兜底静态图 // 预加载工具(减少首次加载耗时) export class PreloadManager { private static cache: Map<WeatherType, PixelMap> = new Map(); private static resourceManager: ResourceManager; // 通过外部传入资源管理器 // 初始化资源管理器(必须在使用前调用,如在页面初始化时) static init(resourceMgr: ResourceManager) { this.resourceManager = resourceMgr; } // 预加载指定天气动图 static async preload(weather: WeatherType) { if (this.cache.has(weather) || !this.resourceManager) return; try { const resource = WeatherGifMap[weather]; // 通过资源管理器获取资源内容 const data = await this.resourceManager.getContent(resource.id); const imageSource = image.createImageSource(data); const pixelMap = await imageSource.createPixelMap(); this.cache.set(weather, pixelMap); } catch (err) { console.error(`Preload ${weather} failed: ${err}`); } } // 获取预加载资源 static get(weather: WeatherType): PixelMap | null { return this.cache.get(weather) || null; } } // 缓存工具(本地存储复用) export class CacheManager { private static CACHE_PATH: string; private static initialized: boolean = false; // 初始化缓存目录(必须在使用前调用,传入上下文) static async init(context: UIAbilityContext) { if (this.initialized) return; this.CACHE_PATH = `${context.cacheDir}/weather_gifs/`; // 确保目录存在 try { await fs.access(this.CACHE_PATH); } catch { await fs.mkdir(this.CACHE_PATH, { recursive: true }); } this.initialized = true; } // 缓存动图到沙箱 static async save(weather: WeatherType, data: Uint8Array): Promise<boolean> { if (!this.initialized) { console.error('CacheManager not initialized'); return false; } try { const path = `${this.CACHE_PATH}${weather}.webp`; await fs.writeFile(path, data); return true; } catch (err) { console.error(`Save cache ${weather} failed: ${err}`); return false; } } // 读取缓存(7天过期) static async get(weather: WeatherType): Promise<Uint8Array | null> { if (!this.initialized) { console.error('CacheManager not initialized'); return null; } const path = `${this.CACHE_PATH}${weather}.webp`; try { // 检查文件是否存在 await fs.access(path); // 检查过期时间(7天) const stat = await fs.stat(path); const sevenDays = 7 * 24 * 60 * 60 * 1000; if (Date.now() - stat.mtime.getTime() > sevenDays) { await fs.unlink(path); // 过期则删除 return null; } return await fs.readFile(path); } catch (err) { // 文件不存在或读取失败,返回null return null; } } } @Component export struct WeatherBackground { @Link currentWeather: WeatherType; @State opacity: number = 1; @State currentImg: PixelMap | Resource = DEFAULT_STATIC; private resourceManager: ResourceManager; // 接收资源管理器(从父组件传入) constructor(resourceMgr: ResourceManager) { this.resourceManager = resourceMgr; } async aboutToAppear() { // 初始化预加载管理器 PreloadManager.init(this.resourceManager); // 预加载当前及高频天气动图 await PreloadManager.preload(this.currentWeather); await PreloadManager.preload(WeatherType.CLOUDY); // 等待初始化完成后加载图片 await this.loadImage(this.currentWeather); } // 监听天气变化(使用@Watch替代onPropertyChange) @Watch('currentWeather') onWeatherChange() { animateTo({ duration: 500 }, () => { this.opacity = 0; }, () => { this.loadImage(this.currentWeather).then(() => { animateTo({ duration: 500 }, () => { this.opacity = 1; }); }); }); } // 加载动图(优先预加载/缓存,失败用兜底图) private async loadImage(weather: WeatherType) { try { // 优先用预加载资源 let pixelMap = PreloadManager.get(weather); if (!pixelMap) { // 次优先用本地缓存 const cachedData = await CacheManager.get(weather); if (cachedData) { const imageSource = image.createImageSource(cachedData); pixelMap = await imageSource.createPixelMap(); } else { // 最后加载原始资源并缓存 const resource = WeatherGifMap[weather]; const data = await this.resourceManager.getContent(resource.id); const imageSource = image.createImageSource(data); pixelMap = await imageSource.createPixelMap(); // 缓存到本地 await CacheManager.save(weather, data); } } this.currentImg = pixelMap; } catch (err) { console.error(`Load image failed: ${err}`); this.currentImg = DEFAULT_STATIC; // 加载失败用兜底图 } } build() { Stack() { Image(this.currentImg) .width('100%') .height('100%') .objectFit(ImageFit.Cover) // 适配屏幕 .opacity(this.opacity); } } } @Entry @Component struct MainPage { @State currentWeather: WeatherType = WeatherType.SUNNY; private resourceManager: ResourceManager; private context: UIAbilityContext = getContext(this) as UIAbilityContext; async aboutToAppear() { // 获取资源管理器 this.resourceManager = this.context.resourceManager; // 初始化缓存管理器 await CacheManager.init(this.context); } build() { Stack() { // 传入资源管理器给背景组件 WeatherBackground({ currentWeather: $currentWeather, resourceMgr: this.resourceManager }) // 底部天气切换按钮(模拟API数据更新) Flex({ justifyContent: FlexAlign.SpaceAround }) { Button('晴天').onClick(() => this.currentWeather = WeatherType.SUNNY); Button('下雨').onClick(() => this.currentWeather = WeatherType.RAINY); Button('多云').onClick(() => this.currentWeather = WeatherType.CLOUDY); Button('下雪').onClick(() => this.currentWeather = WeatherType.SNOWY); Button('阴天').onClick(() => this.currentWeather = WeatherType.OVERCAST); } .padding(20) .alignItems(ItemAlign.End) .height('100%') } .width('100%') .height('100%') } } 1.5方案成果总结(一)核心功能实现:成功实现晴天、下雨等 5 种天气与对应动态背景的精准联动,天气状态变更时自动切换,且切换过程通过 500ms 透明度渐变实现平滑过渡,视觉体验流畅;(二)加载速度:通过预加载 + 本地缓存,首次加载耗时降低至 1.5s 内,二次加载(缓存命中)耗时 < 300ms;(三)适配性:通过ImageFit.Cover和多分辨率资源,在手机、平板等设备上均无拉伸 / 裁剪;(四)稳定性:低内存时自动降级为静态图,加载失败时有兜底方案,异常场景界面正常展示;(五)扩展性与复用性:新增天气类型(如雷阵雨)仅需扩展枚举与映射表,无需修改核心组件;组件可直接复用至天气 APP、桌面小组件等场景;
上滑加载中
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签