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