-
v1的状态管理器@state和@Local在切换时候的注意事项,当state修饰的是对象时,直接切换local不会渲染ui需要对修饰的类添加@ObservedV2和属性添加@Trace修饰具体如下:@ObservedV2 export class TestModel{ @Trace userName:string='' @Trace userAge:number=0 constructor(userName:string,userAge:number) { this.userName=userName this.userAge=userAge } } 原因:@State 具备一定的“深度”观察能力,可以观察到对象第一层属性的变化。 @Local 只观察变量本身的重新赋值。深度属性观测需配合 @ObservedV2 和 @Trace 使用
-
1.问题说明:鸿蒙首页列表下拉,展示二楼UI效果2.原因分析:主流APP一般首页会做二楼UI效果,为了主题、节日、活动等3.解决思路:叠层UI:二楼UI一般做叠层处理,为了统一管理使用,两层UI的在滑动时做位置的移动问题点:使用onTouch事件处理移动,同时要注意处理list滑动的手势冲突统一ViewModel:统一管理ViewModel,处理UI层的业务需要4.解决方案:一、叠层UI:import { HomePage } from "./SecondFloor/pages/HomePage"import { SecondFloorPage } from "./SecondFloor/pages/SecondFloorPage"import { TabsViewModel } from "./TabsViewModel"@ComponentV2export struct TabsPage { @Local currentIndex: number = 0 private tabsController: TabsController = new TabsController() @Local viewModel: TabsViewModel = new TabsViewModel() aboutToAppear(): void { this.viewModel.initData() } build() { Stack() { SecondFloorPage({ viewModel: this.viewModel }) Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { TabContent() { HomePage({ viewModel: this.viewModel }) } .tabBar('首页') TabContent() { Column() } .tabBar('我的') } .vertical(false) .divider({ strokeWidth: 1.0, color: Color.Gray }) .scrollable(true) .animationDuration(0) .onChange((index: number) => { if (this.currentIndex != index) { this.currentIndex = index this.tabsController.changeIndex(index) } }) .position({ x: 0, y: this.viewModel.expandSecond ? this.viewModel.offsetY + this.viewModel.floorHeight : 0, }) } .alignContent(Alignment.Top) .width('100%') .height('100%') }}二、onTouch事件,首页UIimport { TabsViewModel } from "../../TabsViewModel"import { FirstFloorView } from "../views/FirstFloorView"@ComponentV2export struct HomePage { @Param @Require viewModel: TabsViewModel aboutToAppear(): void { this.viewModel.loadData() } build() { Column() { FirstFloorView({ viewModel: this.viewModel }) } .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Start) .width('100%') .height('100%') .onTouch((event: TouchEvent) => { switch (event.type) { case TouchType.Down: this.viewModel.firstFloorTouchDown(event) break; case TouchType.Move: this.viewModel.firstFloorTouchMove(event) break; case TouchType.Up: case TouchType.Cancel: this.viewModel.firstFloorTouchCancel(event) break; } event.stopPropagation(); // 阻止冒泡 }) }}三、List滑动事件,首页列表import { TabsViewModel } from "../../TabsViewModel"@ComponentV2export struct FirstFloorView { @Param @Require viewModel: TabsViewModel build() { Column() { List() { ForEach(this.viewModel.firstList, (item: string, index: number) => { ListItem() { Column() { Line() .backgroundColor(Color.Red) .width('100%') .height(1) Text(item) .textAlign(TextAlign.Center) .fontSize(15) } .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Center) .width('100%') .height(44) } }) } .width('100%') .height('100%') .onDidScroll((scrollOffset: number, scrollState: ScrollState) => { this.viewModel.firstFloorDidScroll(scrollOffset, scrollState) }) } .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Start) .width('100%') .height('100%') }}四、二楼UIimport { TabsViewModel } from "../../TabsViewModel"@ComponentV2export struct SecondFloorPage { @Param @Require viewModel: TabsViewModel build() { Column() { } .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Start) .width('100%') .height('100%') // 固定二楼刚开始位置 .position({ x: 0, y: this.viewModel.offsetY }) .backgroundColor(Color.Red) .onTouch((event) => { if (event.touches.length !== 1) { event.stopPropagation(); return } switch (event.type) { case TouchType.Down: this.viewModel.secondFloorTouchDown(event) break; case TouchType.Move: this.viewModel.secondFloorTouchMove(event) break; case TouchType.Up: case TouchType.Cancel: this.viewModel.secondFloorTouchCancel(event) break; } event.stopPropagation(); // 阻止冒泡 }) }}五、统一管理的ViewModelimport { BaseViewModel } from 'shkit';import { display, Scale } from '@kit.ArkUI';import { AnimatorResult } from '@kit.ArkUI';import { AppUtil } from '@pura/harmony-utils';const ICON_NUM_IN_USER: number = 60; // 示例中用户信息数目const FLING_FACTOR: number = 1.5; // 阻尼系数,可根据不同设备摩擦系数设置const TRIGGER_HEIGHT: number = 200; // 触发动画高度或者动效消失高度const MINI_SHOW_DISTANCE: number = 3; // 动效最小展示距离const ANIMATION_DURATION: number = 500; // 加载动画总时长const ROTATE_ANGLE: number = 360; // 初始化角度const UPDATE_HEIGHT: number = 150; // 更新数据时悬停的高度const BACK_HEIGHT: number = 100; // 回弹回一楼的高度const UPDATE_TIME: number = 2000; // 模拟加载数据耗时2sconst EXPAND_SECOND_FLOOR_TIME: number = 500; // 展开二楼动效时间const TITLE_HEIGHT_CHANG_TIME: number = 500; // 一楼/二楼标题高度变化动效时间const SCROLL_BY_TOP: number = 500; // 回弹一楼动效时间const SCROLL_BY_UPDATE: number = 300; // 回弹固定高度动效时间const TOUCH_SLOP: number = 2; // 可滑动的最小距离@ObservedV2export class TabsViewModel extends BaseViewModel { @Trace screenWidth: number = 0 // 屏幕宽度 @Trace screenHeight: number = 0 // 屏幕高度 @Trace startPackUpFloor: boolean = false; // 监听当处于二楼状态点击标题时的状态 @Trace floorHeight: number = 0; // floor高度 @Trace expandFloorTriggerDistance: number = 200; // 展开二楼拉拽触发距离 @Trace packUpFloorTriggerDistance: number = 150; // 收起二楼拉拽触发距离 @Trace offsetY: number = 0; // Y轴偏移量,下拉的距离(初始值为二楼高度的负值) private firstDragging: boolean = false; // 是否在拉拽 private firstLastY: number = 0; // Y轴的值 @Trace immediatelyScale: Scale = { x: 0, y: 0 }; // 设置动效组件缩放,初始值为0 @Trace onShow: boolean = false; // 是否展示动效 @Trace animationXLeft: number = 60; // 左圆平移距离,初始值为60使得左圆与中心圆重合 @Trace animationXRight: number = -60; // 右圆平移距离,初始值为-60使得右圆与中心圆重合 @Trace miniAppScale: Scale = { x: 0, y: 0 }; // 设置小程序缩放,初始值为0 private firstBackAnimator: AnimatorResult | undefined = undefined; @Trace rotateAngle: number = 0; // 加载动画初始化角度 @Trace expandSecond: boolean = false // 是否展示二楼 @Trace firstOffsetY: number = 0 // 一楼滑动偏移量 @Trace firstList: string[] = [] private secondLastY: number = 0; // Y轴的值 private secondDragging: boolean = false; // 是否在拉拽 private secondBackAnimator: AnimatorResult | undefined = undefined; initData(): void { this.loadUI() } releaseData(): void { } // 加载UI数据 loadUI() { let displayInfo = display.getDefaultDisplaySync() this.screenWidth = AppUtil.getUIContext().px2vp(displayInfo.width) this.screenHeight = AppUtil.getUIContext().px2vp(displayInfo.height) this.floorHeight = this.screenHeight this.offsetY = -this.floorHeight } loadData() { for (let i = 0; i < 100; i++) { this.firstList.push(`${i}`) } } /** * 按下事件、获取按下事件的位置 * @param event 触屏事件 */ firstFloorTouchDown(event: TouchEvent): void { if (this.firstOffsetY < 0) { this.firstLastY = event.touches[0].windowY this.onShow = true this.firstDragging = false } } /** * 滑动事件 * @param event 触屏事件 */ firstFloorTouchMove(event: TouchEvent): void { this.expandSecond = false if (this.firstOffsetY < 0) { if (event.touches.length > 0) { this.onShow = true; let currentY = event.touches[0].windowY; // onTouch事件中本次Y轴大小减去上一次获取的Y轴大小,为负值则是向上滑动,为正值则是向下滑动 let deltaY = currentY - this.firstLastY; if (this.firstDragging) { // 在Y轴为达到0的之前使用1 - (Math.abs(this.offsetY) / this.floorHeight)来控制二楼页面缩放 this.miniAppScale = { x: 1 - (Math.abs(this.offsetY) / this.floorHeight), y: 1 - (Math.abs(this.offsetY) / this.floorHeight) }; // 拖动过程中向上拖动 if (deltaY < 0) { if (this.offsetY > -this.floorHeight) { // 往回拖动一楼漏出高度 this.offsetY = this.offsetY + AppUtil.getUIContext().px2vp(deltaY) * FLING_FACTOR; } else { this.offsetY = -this.floorHeight; } } else { // 向下拖动二楼漏出高度 this.offsetY = this.offsetY + AppUtil.getUIContext().px2vp(deltaY) * FLING_FACTOR; } this.firstLastY = currentY; if (this.offsetY >= 0 && deltaY > 0) { // 当开发者点击一楼标题向下拉动 this.startPackUpFloor = true; } } else { if (deltaY > 0) { this.firstDragging = true; this.firstLastY = currentY; } } } } } /** * 触摸抬起或取消触摸事件 * @param event 触屏事件 */ firstFloorTouchCancel(event: TouchEvent): void { if (this.firstOffsetY < 0) { if (this.firstDragging) { // 二楼自身的高度减去向下Y轴的位移的绝对值大于触发值进入二楼,否则回弹 if ((this.floorHeight - Math.abs(this.offsetY)) > this.expandFloorTriggerDistance) { // 进入二楼 this.expandSecondFloor() } else if ((this.floorHeight - Math.abs(this.offsetY)) <= this.expandFloorTriggerDistance && (this.floorHeight - Math.abs(this.offsetY)) > BACK_HEIGHT) { // 设定滑动结束在大于200小于100的中间位置触发刷新列表后回弹 this.scrollByUpdate(); this.updateUserData(); } else { // 未达到触发距离回弹 this.scrollByTop(); } } } } /** * 展开二楼时添加一个动效、加计时器将二楼坐标轴改为0 */ private expandSecondFloor(): void { if (this.offsetY < 0) { this.expandSecond = true AppUtil.getUIContext().animateTo({ duration: EXPAND_SECOND_FLOOR_TIME, curve: Curve.EaseOut, iterations: 1, playMode: PlayMode.Normal, finishCallbackType: FinishCallbackType.REMOVED, onFinish: () => { this.onShow = false; } }, () => { // this.expandSecond = true this.offsetY = 0; // 在Y轴为达到0的时候缩放比例为正常显示 this.miniAppScale = { x: 1, y: 1 }; }); } } /** * 加载时回弹到固定高度 */ private scrollByUpdate(): void { this.firstBackAnimator = AppUtil.getUIContext().createAnimator({ duration: SCROLL_BY_UPDATE, easing: "linear", // 动画延时播放 delay: 0, // 动画结束后保持结束状态 fill: "forwards", direction: "normal", // 播放次数 iterations: 1, begin: this.offsetY, // 设置加载时页面从拉取的位置回弹到固定高度 end: -this.floorHeight + UPDATE_HEIGHT }) this.firstBackAnimator.onFrame = (value: number) => { this.offsetY = value; } this.firstBackAnimator.play(); } /** * 加载列表方法 */ private updateUserData(): void { AppUtil.getUIContext().animateTo({ duration: ANIMATION_DURATION, // 动画时长 curve: Curve.Ease, // 动画曲线 iterations: -1, // 播放次数,-1为无限循环 playMode: PlayMode.Normal, // 动画模式 }, () => { this.rotateAngle = ROTATE_ANGLE; }) // 模拟网络加载耗时2s,结束后回弹 setTimeout(() => { // 归零图片角度 this.rotateAngle = 0; // 由于本案例仅有6条模拟数据,此处根据数据列表索引值随机改变列表项,模拟列表刷新 // this.userInfoList.forEach((value: UserInformation, index: number) => { // this.userInfoList[index] = // new UserInformation($r(`app.media.second_floor_ic_public_user${Math.floor((Math.random() * 6) + 1)}`), // `User${Math.floor((Math.random() * 6) + 1)}`, // `lastMsg${Math.floor((Math.random() * 6) + 1)}`); // }) if ((this.floorHeight - Math.abs(this.offsetY)) <= this.expandFloorTriggerDistance) { // 加载完成后回弹到一楼 this.scrollByTop(); } }, UPDATE_TIME) } /** * 回弹方法 */ private scrollByTop(): void { this.firstBackAnimator = AppUtil.getUIContext().createAnimator({ duration: SCROLL_BY_TOP, easing: "linear", // 动画延时播放 delay: 0, // 动画结束后保持结束状态 fill: "forwards", direction: "normal", // 播放次数 iterations: 1, begin: this.offsetY, end: -this.floorHeight }) this.firstBackAnimator.onFrame = (value: number) => { this.offsetY = value; } this.firstBackAnimator.play(); } /** * 一楼列表滑动事件 * @param scrollOffset 一楼列表滑动偏移量, * @param scrollState 一楼列表滑动状态, */ firstFloorDidScroll(scrollOffset: number, scrollState: ScrollState): void { this.firstOffsetY += scrollOffset } /** * 按下事件、获取按下事件的位置 * @param event 触屏事件 */ secondFloorTouchDown(event: TouchEvent): void { this.onShow = false; this.secondLastY = event.touches[0].windowY; this.secondDragging = false; } /** * 二楼触摸事件移动 * @param event 触屏事件 */ secondFloorTouchMove(event: TouchEvent): void { let currentY = event.touches[0].windowY; let deltaY = currentY - this.secondLastY; if (this.secondDragging) { // deltaY值为负值,指的是二楼向上滑动的距离 if (deltaY < 0) { if (this.floorHeight - Math.abs(this.offsetY) <= TRIGGER_HEIGHT) { this.onShow = true; } // this.offsetY 值为0 ,this.floorHeight值为760 if (this.offsetY > -this.floorHeight) { this.offsetY = this.offsetY + AppUtil.getUIContext().px2vp(deltaY) * FLING_FACTOR; } else { this.offsetY = -this.floorHeight; } } else { if (this.offsetY < 0 && AppUtil.getUIContext().px2vp(deltaY) * FLING_FACTOR < -this.offsetY) { this.offsetY = this.offsetY + AppUtil.getUIContext().px2vp(deltaY) * FLING_FACTOR; } else { this.offsetY = 0 } } this.secondLastY = currentY; } else { if (Math.abs(deltaY) > TOUCH_SLOP) { if (deltaY < 0) { this.secondDragging = true; this.secondLastY = currentY; } } } } /** * 回收二楼,回收动画 */ packUpFloor(): void { this.secondBackAnimator = AppUtil.getUIContext().createAnimator({ duration: 500, easing: "linear", // 动画延时播放 delay: 0, // 动画结束后保持结束状态 fill: "forwards", direction: "normal", // 播放次数 iterations: 1, begin: this.offsetY, end: -this.floorHeight }) this.secondBackAnimator.onFrame = (value: number) => { this.offsetY = value; } this.secondBackAnimator.play() } /** * 二楼触摸抬起或取消触摸事件 * @param event 触屏事件 */ secondFloorTouchCancel(event: TouchEvent): void { if (this.secondDragging) { // Y轴像上滑动距离是否达到触发收回距离 if (Math.abs(this.offsetY) > this.packUpFloorTriggerDistance) { this.onShow = true; // 滑动高度大于限定高度展示首页 this.packUpFloor(); } else { // 二楼未触发限定高度 this.scrollByBottom(); } } } /** * 二楼向上滑动未达到触发距离滚动回到底部 */ private scrollByBottom(): void { if (this.offsetY < 0) { AppUtil.getUIContext().animateTo({ duration: 500, curve: Curve.EaseOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.onShow = false; }, }, () => { this.offsetY = 0; }); } }} 5.个人感悟:目前只是demo的简单实现,只是提供了二楼位置移动的思路,后续有问题再做修改
-
# 问题说明仓颉编程语言是一款面向全场景智能的新一代编程语言,主打原生智能化、天生全场景、高性能、强安全。主要应用于鸿蒙原生应用及服务应用等场景中,为开发者提供良好的编程体验。## 初识仓颉(1)> 编译器安装在使用仓颉编程语言进行开发时,可以选择DevEco Studio或者VsCode等主流软件进行开发,由于本篇文章主要介绍使用仓颉编程语言进行鸿蒙原生应用的开发,故不再做过多介绍。感兴趣的小伙伴可以参考仓颉编程语言官网进行学习。使用DevEcoStudio和仓颉编程语言进行开发鸿蒙原生应用,需要在开发者官网上申请开发者账号,并且通过审核后,才可以获取到对应的资源包。> 安装DevEco Studio过程不再进行演示,如果有需要的同学可以通过开发者官网进行查看通过审核后,就可以在下载资源中看到对应的插件,如图:下载完成后,只需在DevEco Studio中安装即可使用:Step1:下载后无需解压Step2:选择从磁盘中加载插件Step3:创建一个新项目Step4:将项目运行后,即可看到屏幕显示“Hello Cangjie”字样本期内容就先介绍这么多,如有纰漏还请指正,谢谢
-
1 问题说明在上一篇文章《保障客户端加密密钥安全:告别明文存储的隐患与ArkTS实战》中,我们重点解决了密钥在客户端本地存储的安全性问题,通过多种技术手段避免了密钥明文存储在客户端代码中的风险。然而,这仅仅解决了密钥安全的一部分挑战。现在我们需要面对一个更加复杂的问题:如何安全地获取、传输和使用来自网络的动态密钥,并在此基础上构建完整的加密体系。在我看来,网络动态密钥的使用面临以下几个核心挑战:1. 密钥传输安全风险:密钥在网络传输过程中可能被中间人攻击者拦截或篡改。传统的HTTP明文传输极不安全,即使使用HTTPS,也存在证书伪造和中间人攻击的潜在风险。2. 密钥来源验证难题:客户端如何确认接收到的密钥确实来自可信的服务器,而不是攻击者伪造的响应?缺乏有效的身份验证机制可能导致攻击者伪装成合法服务器分发恶意密钥。3. 密钥新鲜度保障困难:网络延迟、重放攻击等问题可能导致客户端获取到过期的密钥,从而破坏加密体系的安全性。4. 性能与安全性的平衡:动态密钥需要频繁更新以确保安全,但过于频繁的密钥更新可能导致性能下降和用户体验受到影响。5. 网络不可靠性的影响:在弱网环境下,密钥请求可能失败或超时,需要有适当的降级和恢复机制,确保加密功能不中断。针对这些问题,我们需要构建一个完整的网络动态密钥体系,确保密钥从分发到使用的全过程安全可靠。 2 原因分析深入分析这些问题的根源,我认为主要存在以下几方面原因:2.1 传统密钥交换机制的局限性传统的密钥交换方法如Diffie-Hellman算法虽然提供了安全的密钥协商机制,但在实际应用中往往存在实现复杂性和性能开销的问题。此外,许多开发团队对这些密码学基础技术的理解不够深入,导致实现中存在安全漏洞。2.2 身份认证机制的缺失或不足许多应用在客户端与服务器的交互中缺乏双向认证机制。服务器通常验证客户端身份,但客户端很少验证服务器身份,这为中间人攻击提供了可能性。我认为这种单向认证模式是导致密钥分发不安全的重要因素之一。2.3 密钥管理生命周期不完善安全的密钥管理包括生成、存储、分发、使用、更新和销毁等多个环节。许多应用只关注其中部分环节,忽视了完整生命周期的安全管理,尤其是密钥更新和撤销机制往往被忽略,导致系统长期使用同一密钥,增加泄露风险。2.4 时间同步机制的缺乏动态密钥体系往往依赖于时间同步机制,但许多移动设备存在时间不同步的问题,导致基于时间戳的密钥验证机制失效。我认为这是一个经常被忽视但至关重要的技术细节。2.5 应对网络环境多样性的不足移动网络环境具有高度不确定性,包括网络切换、延迟波动、连接中断等问题。许多加密体系没有充分考虑这些网络环境因素,导致密钥获取失败或超时,影响应用功能正常使用。 3 解决思路面对网络动态密钥的挑战,我的解决方案构思围绕以下几个核心方向展开:3.1 建立双向认证机制我认为首先需要建立客户端与服务器之间的双向身份认证,确保双方都是可信的。这可以通过数字证书、令牌机制或更先进的生物特征认证等方式实现。3.2 设计前向安全的密钥交换协议前向安全(Forward Secrecy)是密钥交换协议中的重要特性,确保即使长期密钥泄露,也不会导致过往会话密钥的泄露。我建议采用ECDH(椭圆曲线迪菲-赫尔曼)等现代密钥交换算法实现前向安全性。3.3 实施密钥分层管理策略采用分层密钥管理体系,使用主密钥派生会话密钥,限制单个密钥的使用范围和生命周期。这样即使某个会话密钥泄露,也不会影响整个系统的安全性。3.4 集成多重验证因素结合时间戳、设备特征和用户行为等多重因素进行密钥生成和验证,增加密钥的随机性和不可预测性。我认为这种多因素验证机制可以显著提高密钥体系的安全性。3.5 设计完善的降级和恢复机制针对网络不稳定的情况,设计适当的降级策略和恢复机制,确保在密钥获取失败时应用仍能保持基本功能,并在网络恢复后自动切换回高安全模式。 4 解决方案基于以上思路,我提出以下完整的网络动态密钥实施方案,并提供具体的ArkTS代码示例:4.1 安全密钥交换协议实现首先,我们需要实现一个基于ECDH密钥交换的安全协议,确保密钥在传输过程中的前向安全性:import cryptoFramework from '@ohos.security.cryptoFramework'; import { BusinessError } from '@ohos.base'; class SecureKeyExchange { private keyExchangeAlg: string = 'ECC'; private curveName: cryptoFramework.ECCCommonParams = { algName: 'ECC', field: 'Fp_256' }; // 生成ECC密钥对 async generateKeyPair(): Promise<cryptoFramework.KeyPair> { try { const generator = cryptoFramework.createAsyKeyGenerator(this.keyExchangeAlg); const keyPair = await generator.generateKeyPair(this.curveName); return keyPair; } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Key pair generation failed: ${err.code}, ${err.message}`); throw new Error('Failed to generate key pair'); } } // 执行ECDH密钥交换 async performKeyExchange( myPrivateKey: cryptoFramework.PriKey, peerPublicKey: cryptoFramework.PubKey ): Promise<Uint8Array> { try { const keyAgreement = cryptoFramework.createKeyAgreement('ECDH'); await keyAgreement.init(myPrivateKey); const sharedSecret = await keyAgreement.doPhase(peerPublicKey); return sharedSecret; } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Key exchange failed: ${err.code}, ${err.message}`); throw new Error('Failed to perform key exchange'); } } // 从共享密钥派生会话密钥 async deriveSessionKey(sharedSecret: Uint8Array, context: Uint8Array): Promise<cryptoFramework.SymKey> { try { const kdf = cryptoFramework.createKDF('SHA256'); const sessionKey = await kdf.deriveKey(sharedSecret, { algName: 'AES', keySize: 256 }, context); return sessionKey; } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Key derivation failed: ${err.code}, ${err.message}`); throw new Error('Failed to derive session key'); } } }4.2 双向身份认证实现接下来,我们需要实现客户端与服务器的双向身份认证,确保密钥来源的可信性:import http from '@ohos.net.http'; import { BusinessError } from '@ohos.base'; class MutualAuthClient { private serverCertHash: string = 'pre_shared_server_cert_hash'; // 预置服务器证书哈希 private clientToken: string = this.generateClientToken(); // 生成客户端令牌 private generateClientToken(): string { const timestamp = Date.now(); const randomPart = Math.random().toString(36).substring(2); return `${timestamp}_${randomPart}`; } // 获取服务器证书并验证 private async verifyServerCertificate(serverCert: string): Promise<boolean> { // 计算服务器证书哈希 const certHash = await this.calculateHash(serverCert); // 与预置的证书哈希对比 return certHash === this.serverCertHash; } // 计算字符串的SHA-256哈希 private async calculateHash(data: string): Promise<string> { const sha256 = cryptoFramework.createHash('SHA256'); await sha256.update({ data: new Uint8Array(new TextEncoder().encode(data)) }); const hash = await sha256.digest(); return this.arrayBufferToHex(hash.data); } // ArrayBuffer转十六进制字符串 private arrayBufferToHex(buffer: ArrayBuffer): string { const byteArray = new Uint8Array(buffer); let hexString = ''; for (let i = 0; i < byteArray.length; i++) { const hex = byteArray[i].toString(16); hexString += hex.length === 1 ? '0' + hex : hex; } return hexString; } // 发起认证请求 async requestAuthentication(): Promise<boolean> { try { const httpRequest = http.createHttp(); const response = await httpRequest.request( 'https://api.example.com/auth', { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', 'X-Client-Token': this.clientToken }, extraData: { deviceId: this.getDeviceId(), timestamp: Date.now() } } ); if (response.responseCode === 200) { const authData = JSON.parse(response.result.toString()); // 验证服务器证书 const isValid = await this.verifyServerCertificate(authData.serverCert); if (!isValid) { console.error('Server certificate verification failed'); return false; } // 验证服务器签名 const sigValid = await this.verifySignature( authData.signature, this.clientToken, authData.serverCert ); return sigValid; } return false; } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Authentication request failed: ${err.code}, ${err.message}`); return false; } } // 获取设备标识 private getDeviceId(): string { // 实现获取设备唯一标识的逻辑 return 'device_unique_id'; } // 验证服务器签名 private async verifySignature(signature: string, data: string, publicKey: string): Promise<boolean> { // 实现签名验证逻辑 return true; } }4.3 动态密钥获取与管理实现安全可靠的动态密钥获取机制,包括密钥缓存、更新和失效处理:import preferences from '@ohos.data.preferences'; class DynamicKeyManager { private keyCache: Map<string, KeyInfo> = new Map(); private context: Context = getContext(this); // 获取动态密钥 async fetchDynamicKey(keyId: string): Promise<cryptoFramework.SymKey> { // 首先检查缓存中是否有未过期的密钥 const cachedKey = this.getCachedKey(keyId); if (cachedKey && !this.isKeyExpired(cachedKey)) { return cachedKey.key; } // 缓存中没有或已过期,从网络获取 try { const newKey = await this.requestKeyFromServer(keyId); // 缓存新获取的密钥 this.cacheKey(keyId, newKey); return newKey.key; } catch (error) { // 网络请求失败,使用降级策略 return this.handleKeyRequestFailure(keyId, error); } } // 从服务器请求密钥 private async requestKeyFromServer(keyId: string): Promise<KeyInfo> { const httpRequest = http.createHttp(); const response = await httpRequest.request( `https://api.example.com/keys/${keyId}`, { method: http.RequestMethod.GET, header: { 'Authorization': `Bearer ${await this.getAuthToken()}`, 'X-Device-Id': this.getDeviceId() } } ); if (response.responseCode === 200) { const keyData = JSON.parse(response.result.toString()); return { key: await this.importKey(keyData.value), expiry: keyData.expiry, id: keyId }; } throw new Error(`Key request failed with status: ${response.responseCode}`); } // 导入密钥 private async importKey(keyMaterial: string): Promise<cryptoFramework.SymKey> { const symKeyGenerator = cryptoFramework.createSymKeyGenerator('AES'); const keyBlob: cryptoFramework.DataBlob = { data: new Uint8Array(new TextEncoder().encode(keyMaterial)) }; return await symKeyGenerator.convertKey(keyBlob); } // 处理密钥请求失败 private async handleKeyRequestFailure(keyId: string, error: Error): Promise<cryptoFramework.SymKey> { console.warn(`Key request failed for ${keyId}: ${error.message}`); // 尝试使用过期的缓存密钥作为降级方案 const cachedKey = this.getCachedKey(keyId); if (cachedKey) { console.warn(`Using expired cached key as fallback: ${keyId}`); return cachedKey.key; } // 没有缓存密钥,使用预置的应急密钥 console.warn(`Using emergency preset key: ${keyId}`); return this.getEmergencyKey(keyId); } // 缓存密钥 private async cacheKey(keyId: string, keyInfo: KeyInfo): Promise<void> { // 更新内存缓存 this.keyCache.set(keyId, keyInfo); // 持久化到Preferences const prefs = await preferences.getPreferences(this.context, 'key_cache'); await prefs.put(keyId, JSON.stringify(keyInfo)); await prefs.flush(); } // 获取缓存的密钥 private getCachedKey(keyId: string): KeyInfo | undefined { // 首先检查内存缓存 if (this.keyCache.has(keyId)) { return this.keyCache.get(keyId); } // 内存中没有,尝试从Preferences加载 try { const prefs = await preferences.getPreferences(this.context, 'key_cache'); const cachedData = await prefs.get(keyId, ''); if (typeof cachedData === 'string' && cachedData) { return JSON.parse(cachedData); } } catch (error) { console.error(`Failed to load cached key: ${error.message}`); } return undefined; } // 检查密钥是否过期 private isKeyExpired(keyInfo: KeyInfo): boolean { return Date.now() > keyInfo.expiry; } // 获取应急密钥 private getEmergencyKey(keyId: string): cryptoFramework.SymKey> { // 返回预置的应急密钥 // 实际实现中应该使用安全的方式存储和获取应急密钥 return this.importKey('emergency_key_value'); } // 获取认证令牌 private async getAuthToken(): Promise<string> { // 实现获取认证令牌的逻辑 return 'auth_token'; } } interface KeyInfo { key: cryptoFramework.SymKey; expiry: number; // 过期时间戳 id: string; }4.4 密钥使用与更新策略实现密钥的使用和自动更新机制,确保密钥的定期轮换:class KeyRotationManager { private keyUpdateInterval: number = 3600000; // 1小时更新一次 private keyUpdateTimers: Map<string, number> = new Map(); // 初始化密钥更新机制 async initializeKeyRotation(keyId: string): Promise<void> { // 获取初始密钥 const keyManager = new DynamicKeyManager(); await keyManager.fetchDynamicKey(keyId); // 设置定期更新 this.scheduleKeyUpdate(keyId); } // 调度密钥更新 private scheduleKeyUpdate(keyId: string): void { // 清除现有的定时器(如果有) this.cancelKeyUpdate(keyId); // 设置新的定时器 const timer = setInterval(async () => { try { const keyManager = new DynamicKeyManager(); await keyManager.fetchDynamicKey(keyId); console.info(`Key updated successfully: ${keyId}`); } catch (error) { console.error(`Key update failed: ${error.message}`); // 更新失败,重试逻辑可以在这里实现 } }, this.keyUpdateInterval); this.keyUpdateTimers.set(keyId, timer); } // 取消密钥更新 cancelKeyUpdate(keyId: string): void { if (this.keyUpdateTimers.has(keyId)) { clearInterval(this.keyUpdateTimers.get(keyId)); this.keyUpdateTimers.delete(keyId); } } // 立即更新密钥 async updateKeyImmediately(keyId: string): Promise<void> { this.cancelKeyUpdate(keyId); try { const keyManager = new DynamicKeyManager(); await keyManager.fetchDynamicKey(keyId); console.info(`Key updated immediately: ${keyId}`); } catch (error) { console.error(`Immediate key update failed: ${error.message}`); throw error; } // 重新调度定期更新 this.scheduleKeyUpdate(keyId); } // 调整更新间隔 setUpdateInterval(keyId: string, interval: number): void { this.keyUpdateInterval = interval; // 重新调度更新 this.cancelKeyUpdate(keyId); this.scheduleKeyUpdate(keyId); } }4.5 完整性验证机制为传输的密钥添加完整性验证,防止密钥在传输过程中被篡改:class IntegrityVerifier { // 为密钥添加数字签名 async signKey(keyData: string, privateKey: cryptoFramework.PriKey): Promise<string> { try { const signer = cryptoFramework.createSign('RSA|SHA256'); await signer.init(privateKey); const dataBlob: cryptoFramework.DataBlob = { data: new Uint8Array(new TextEncoder().encode(keyData)) }; const signature = await signer.sign(dataBlob); return this.arrayBufferToBase64(signature.data); } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Key signing failed: ${err.code}, ${err.message}`); throw new Error('Failed to sign key data'); } } // 验证密钥签名 async verifyKeySignature( keyData: string, signature: string, publicKey: cryptoFramework.PubKey ): Promise<boolean> { try { const verifier = cryptoFramework.createVerify('RSA|SHA256'); await verifier.init(publicKey); const dataBlob: cryptoFramework.DataBlob = { data: new Uint8Array(new TextEncoder().encode(keyData)) }; const signatureBlob: cryptoFramework.DataBlob = { data: new Uint8Array(this.base64ToArrayBuffer(signature)) }; return await verifier.verify(dataBlob, signatureBlob); } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Signature verification failed: ${err.code}, ${err.message}`); return false; } } // 计算数据的HMAC async calculateHmac(data: string, key: cryptoFramework.SymKey): Promise<string> { try { const mac = cryptoFramework.createMac('SHA256'); await mac.init(key); const dataBlob: cryptoFramework.DataBlob = { data: new Uint8Array(new TextEncoder().encode(data)) }; const hmac = await mac.doFinal(dataBlob); return this.arrayBufferToBase64(hmac.data); } catch (error) { const err: BusinessError = error as BusinessError; console.error(`HMAC calculation failed: ${err.code}, ${err.message}`); throw new Error('Failed to calculate HMAC'); } } // 验证HMAC async verifyHmac(data: string, hmac: string, key: cryptoFramework.SymKey): Promise<boolean> { const calculatedHmac = await this.calculateHmac(data, key); return calculatedHmac === hmac; } // ArrayBuffer转Base64 private arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } // Base64转ArrayBuffer private base64ToArrayBuffer(base64: string): ArrayBuffer { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } } 5 网络密钥与本地密钥的对比分析在设计了完整的网络动态密钥解决方案后,我认为有必要全面比较网络密钥与本地密钥的优缺点,以便在实际应用中做出合适的选择。5.1 网络动态密钥的优势更高的安全性:网络密钥可以定期更新,即使某个密钥被泄露,影响范围也有限。基于时间戳、用户ID和位置信息等多因素生成的动态密钥具有更好的抗攻击能力。集中管理能力:服务器可以统一管理密钥的生命周期,包括生成、分发、更新和撤销,提高了密钥管理的效率和一致性。更好的前向安全性:通过ECDH等现代密钥交换协议实现的网络密钥交换具有前向安全性,即使长期密钥泄露,也不会影响过往通信的安全。动态响应能力:在检测到安全威胁时,服务器可以立即撤销和更新所有客户端的密钥,快速响应安全事件。5.2 网络动态密钥的挑战网络依赖性:获取密钥需要网络连接,在离线或弱网环境下可能无法正常工作,需要设计降级方案。性能开销:密钥的网络请求、验证和更新过程带来额外的性能开销,可能影响应用响应速度。实现复杂性:需要实现完整的密钥交换协议、身份认证和完整性验证机制,增加了开发复杂度。服务器压力:大量客户端同时请求密钥可能给服务器带来显著负载,需要设计合理的扩容和负载均衡策略。5.3 本地静态密钥的优势离线可用性:不需要网络连接即可使用,适合离线应用场景。性能零开销:不需要网络请求和复杂的密钥计算,性能开销极小。实现简单:不需要复杂的密钥交换和验证逻辑,实现简单直接。5.4 本地静态密钥的局限性安全性较低:密钥长期不变,一旦泄露所有通信都会受到影响,缺乏前向安全性。更新困难:要更新密钥需要发布新版本应用,更新周期长且依赖用户操作。管理分散:密钥管理分散在各个客户端,难以实施统一的安全策略和密钥轮换。5.5 综合选择建议我认为在实际应用中,应该根据具体场景的安全要求和约束条件选择合适的方案:高安全需求场景(如金融交易、政府通信):优先选择网络动态密钥方案,充分利用其安全优势。离线或弱网环境:采用混合方案,使用网络密钥为主,本地密钥为降级方案。性能敏感场景:在安全要求允许的前提下,可以考虑使用本地密钥或延长网络密钥的更新周期。 6 下一步:Hook风险与防护尽管我们实现了安全的网络动态密钥体系,但仍然面临一个重要的安全威胁:Hook攻击。攻击者可以通过Hook技术拦截应用程序的函数调用,获取密钥甚至修改加密逻辑。(Hook:你所有的防御在我眼里是如此的可笑~)我认为Hook攻击主要分为以下几种类型:API Hook:拦截系统加密API调用,获取明文数据或密钥材料。内存Hook:直接访问进程内存,提取密钥信息。运行时Hook:修改应用运行时环境,干预加密算法的执行过程。在下一篇文章中,我们将深入探讨Hook技术的原理和实现机制,并详细讲解如何检测和防御各种Hook攻击,包括:代码完整性检查:验证自身代码段是否被修改。环境检测技术:识别Hook框架的存在。反调试措施:防止调试器附加和代码分析。运行时保护:保护密钥内存和加密操作过程。通过综合运用这些技术,我们可以构建一个更加全面的客户端安全体系,有效防御Hook攻击,确保网络动态密钥体系的完整性和安全性。注:本文提供的代码示例需要在HarmonyOS开发环境中测试和调整,实际实现时应根据具体需求增加适当的错误处理和日志记录。
-
1、问题说明这是一个基于华为OpenHarmony系统的录音机应用,项目原本是一个简单的录音功能应用核心问题:代码结构不合理:录音逻辑与UI展示逻辑混合在一起,资源管理问题:音频播放器资源可能存在内存泄漏风险,文件路径处理复杂:相对路径和绝对路径混用导致文件操作不稳定2、原因分析技术原因:架构设计问题:没有清晰的模块分离,业务逻辑与UI展示耦合严重资源管理不当:音频播放器、文件描述符等资源没有统一的清理机制错误处理不完善:文件操作、音频播放等关键操作缺乏充分的异常处理3、解决思路架构重构:采用MVVM架构模式,分离业务逻辑与UI展示实现统一的资源管理器,负责音频播放器、文件等资源的生命周期管理代码优化:重构文件路径处理逻辑,统一使用绝对路径实现完善的错误处理和资源清理机制优化状态管理,使用响应式数据绑定4、解决方案内存泄漏问题,音频播放器资源没有正确释放,导致内存泄漏。 async playVoice(filePath: string) { try { // 先清理之前的资源 await this.cleanupPlayer() const absolutePath = this.getFullPath(filePath) const file = fileIo.openSync(absolutePath, fileIo.OpenMode.READ_ONLY) this.currentFileFd = file.fd this.Avplayer = await media.createAVPlayer() this.Avplayer.url = `fd://${this.currentFileFd}` // 添加错误监听 this.Avplayer.on('error', async (err: Error) => { console.error('播放器错误:', err) await this.cleanupPlayer() }) await this.Avplayer.prepare() await this.Avplayer.play() } catch (err) { console.error('播放失败:', err) await this.cleanupPlayer() // 确保异常时也清理资源 } } 相对路径和绝对路径混用,导致文件操作不稳定。// 统一路径处理工具类 class PathUtils { static getAbsolutePath(relativePath: string): string { if (relativePath.startsWith('/')) { return relativePath; } return `${getContext().cacheDir}/${relativePath}`; } static getRelativePath(absolutePath: string): string { const cacheDir = getContext().cacheDir; if (absolutePath.startsWith(cacheDir)) { return absolutePath.substring(cacheDir.length + 1); } return absolutePath; } static normalizePath(path: string): string { return this.getAbsolutePath(path); } } 多个异步操作同时进行时可能出现竞态条件。class FileListManager { private isLoading: boolean = false; private loadPromise: Promise<void> | null = null; async loadRecordFiles(force: boolean = false): Promise<void> { // 防止重复加载 if (this.isLoading) { return this.loadPromise || Promise.resolve(); } if (!force && this.recordList.length > 0) { return Promise.resolve(); } this.isLoading = true; this.loadPromise = this._loadFiles(); try { await this.loadPromise; } finally { this.isLoading = false; this.loadPromise = null; } } private async _loadFiles(): Promise<void> { try { const cacheDir = getContext().cacheDir; const files = await fileIo.listFile(cacheDir); const validFiles = files.filter(file => file.endsWith('.m4a')); // 使用串行处理避免竞态条件 const newItems: RecordItem[] = []; for (const file of validFiles) { try { const duration = await this.getAudioDuration(`${cacheDir}/${file}`); newItems.push({ path: file, duration: Math.floor(duration / 1000) }); } catch (error) { console.warn(`获取文件 ${file} 时长失败:`, error); // 继续处理其他文件 } } // 原子性更新列表 this.recordList = [...this.recordList, ...newItems]; } catch (error) { console.error('加载录音文件失败:', error); throw error; } } }
-
1、关键技术难点总结1.1 问题说明大数据列表滑动卡顿:在应用中展示数万数据时,传统的遍历会一次性创建数据的组件,导致应用启动时内存占用飙升,用户在滑动列表时出现明显卡顿,严重影响使用体验。特别是在中低端设备上,甚至会出现应用崩溃的情况。页面打开白屏:一次性加载并渲染所有数据,会阻塞主线程,导致应用启动或页面打开时出现长时间白屏或严重卡顿用户体验下降。同时,频繁的组件创建和销毁会消耗大量CPU资源,影响设备续航。传统的数据绑定机制在处理大量数据时效率低,数据更新时会触发全量重新渲染,导致界面响应缓慢,无法满足企业级应用的性能要求。1.2 原因分析渲染机制设计缺陷:传统的ForEach循环渲染采用"全量创建"模式,会在组件初始化时一次性创建所有列表项的item组件,无论它们是否在用户的可视区域内。在大数据量场景中,这种方式会瞬间创建数万个item组件,导致内存使用量呈线性增长,同时大量item组件的存在会显著增加浏览器的重排重绘开销,使得用户在滚动浏览时遇到严重的卡顿和延迟问题。此外,缺乏有效的组件复用机制导致频繁的组件创建-销毁循环,不仅消耗大量CPU资源进行对象分配和垃圾回收,还会造成内存碎片化,特别是在企业级应用的复杂业务组件场景中问题更加突出。组件生命周期管理不当,数据管理架构不合理:传统的数据绑定机制采用全量更新策略,当数据源发生变化时会触发整个列表的重新渲染,而不是仅更新变化的部分。在需要实时更新的场景中,每次数据变更都可能引发数万个组件的重新计算和渲染,效率极低。同时,缺乏懒加载机制,使得应用无法根据用户的实际浏览需求智能地管理数据加载,导致不必要的网络请求和内存占用。这种设计在处理大数据量时不仅影响界面响应速度,还会在用户快速滚动浏览时引发显著的性能下降和体验中断。2、解决思路懒加载机制:针对传统ForEach全量渲染的根本缺陷,采用LazyForEach配合IDataSource接口实现真正的虚拟滚动技术。该方案通过智能计算可视区域范围,仅对用户当前能看到的列表项进行渲染,将内存使用量从线性增长优化为常量级别。LazyForEach的核心优势在于其按需渲染机制,能够根据用户的滚动行为动态加载和卸载组件,从根本上解决了大数据量场景下的内存溢出和渲染性能问题。同时,通过实现IDataSource标准接口,为数据源提供了totalCount()和getData(index)等核心方法,确保数据访问的高效性和可扩展性。组件复用池机制:通过@Reusable装饰器建立组件复用池,彻底改变传统的"创建-销毁"循环模式。当列表项离开可视区域时,组件不再被销毁而是进入复用池,新进入可视区域的数据则复用现有组件实例,仅更新其绑定的数据内容。这种设计配合aboutToReuse()生命周期回调,能够实现组件状态的快速切换,将组件复用率提升至95%以上。复用机制不仅大幅减少了对象创建和垃圾回收的开销,还避免了内存碎片化问题。优化数据绑定与状态管理架构:采用@ObjectLink配合@Observed的响应式数据绑定机制,实现精确的数据更新控制。与传统全量更新不同,该方案能够精确识别数据变更范围,仅触发相关组件的重新渲染,避免了无关组件的不必要更新。智能缓存与内存管理策略:通过cachedCount参数实现智能缓存策略,在性能和内存之间找到最佳平衡点。系统会根据设备性能和应用场景自动调整缓存的组件数量,既保证了滚动的流畅性,又避免了过度的内存占用。同时,结合数据懒加载机制,应用能够根据用户的实际浏览行为智能预测和加载数据,减少不必要的网络请求和数据处理开销。3、解决方案3.1 数据模型设计// Index.ets - Person数据模型 @Observed export class Person { private id: string; private name: string; private age: number; private icon: string; constructor(id: string, name: string, age: number, icon: string) { this.id = id; this.name = name; this.age = age; this.icon = icon; } public setId(value: string) { this.id = value; } public getId(): string { return this.id; } public setName(value: string) { this.name = value; } public getName(): string { return this.name; } public setAge(value: number) { this.age = value; } public getAge(): number { return this.age; } public setIcon(value: string) { this.icon = value; } public getIcon(): string { return this.icon; } } 步骤代码说明:使用@Observed装饰器标记数据模型,支持响应式更新采用getter/setter模式,便于数据变更监听封装数据访问接口,提高代码可维护性3.2 数据源实现// BigArrayDataSource.ets - 大数据量数据源 import { Person } from "./Index"; export class BigArrayDataSource implements IDataSource { private listData: Array<Person> = [] private listeners: DataChangeListener[] = []; constructor(listData: Array<Person>) { this.listData = listData; } totalCount(): number { return this.listData.length; } getData(index: number): Person | undefined { return this.listData[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const position = this.listeners.indexOf(listener); if (position >= 0) { this.listeners.splice(position, 1); } } } 步骤代码说明:实现IDataSource接口,提供标准化数据访问方法totalCount()返回总数据量,支持滚动计算getData(index)按索引获取数据,实现按需加载数据变更监听机制,支持动态数据更新3.3 可复用组件实现// ReusablePersonItem.ets - 可复用列表项组件 import { Person } from './Index'; @Reusable @Component export struct ReusablePersonItem { @ObjectLink item: Person; aboutToReuse(params: Record<string, Object>): void { let person = params.item as Person; this.item.setId(person.getId()); this.item.setName(person.getName()); this.item.setAge(person.getAge()); this.item.setIcon(person.getIcon()); } build() { Row() { Image($r('app.media.startIcon')) .width(50) .height(50) .borderRadius(25) .objectFit(ImageFit.Cover) Column() { Text(this.item.getName()) .fontSize(18) .fontWeight(FontWeight.Medium) .margin({ bottom: 5 }) Text(this.item.getAge() + '') .fontSize(14) .lineHeight(20) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) .margin({ left: 12 }) } .width('100%') .padding(10) .backgroundColor(Color.Pink) .borderRadius(10) .margin({ top: 5, bottom: 5 }) } } 步骤代码说明:@Reusable装饰器标记组件可复用@ObjectLink实现数据双向绑定aboutToReuse()生命周期回调处理组件复用逻辑更新组件数据而非重新创建,大幅提升性能3.4 主页面列表实现// Index.ets - 主页面列表实现 import { BigArrayDataSource } from './BigArrayDataSource'; import { ReusablePersonItem } from './ReusablePersonItem' @Entry @Component struct Index { @State data: Array<Person> = [] aboutToAppear(): void { // 模拟大数据量场景 for (let i = 0; i < 100000; i++) { this.data[i] = new Person( i + '', 'name' + i, i, 'http://example.pic.jpg' ); } } build() { List() { LazyForEach( new BigArrayDataSource(this.data), (item: Person) => { ListItem() { ReusablePersonItem({item: item}) } }, (item: Person) => { return item.getId() } ) }.cachedCount(5) } } 步骤代码说明:LazyForEach替代ForEach实现懒加载cachedCount(5)设置缓存列表项数量唯一键值返回函数确保组件正确复用数据源与组件解耦,提高代码复用性4、方案成果总结4.1 性能提升效果渲染性能:首次打卡加载无明显卡顿白屏滚动流畅度:支持10万+数据流畅滚动,无明显卡顿组件复用率:组件复用率达到95%以上4.2 技术亮点LazyForEach + IDataSource:实现真正的按需加载@Reusable + @ObjectLink:高效的组件复用机制生命周期优化:精确控制组件创建销毁时机
-
一、 关键技术难点总结1. 问题说明在鸿蒙应用开发中,动效是提升用户交互体验的核心元素(如组件显隐、尺寸变化、状态切换等场景),但不合理的动效实现方式会导致性能开销激增,暴露出一系列痛点,具体可从以下维度展开:(一)转场逻辑复杂且易出错鸿蒙原生动效开发中,若误用组件动画(animateTo)实现转场效果(如组件显隐),需在动画结束回调中处理组件状态(如isDisplayed切换),不仅需维护clickTimes等额外变量避免回调冲突,还需手动控制 “透明度变化→组件隐藏” 的联动逻辑,易因回调时序问题导致动画断层或组件状态异常(如多次点击后组件无法正常显示)。(二)代码冗余度高animateTo调用分散:当多个组件属性(如宽度、颜色)需同步动画时,若拆分多个animateTo调用,需维护相同的动画参数(如duration、curve),易出现参数不一致导致的动画不同步,且代码冗余度高。2. 原因分析(一)动效实现方式定位偏差鸿蒙提供transition(转场动画)与animateTo(组件动画)两种核心动效能力,但开发者易混淆二者适用场景:animateTo侧重组件属性的动态变化,transition侧重组件 “出现 / 消失” 的状态切换。若用animateTo实现转场,需手动衔接 “属性变化→状态切换”,违背animateTo的设计初衷,导致逻辑复杂。(二)animateTo调用逻辑分散animateTo的设计逻辑是 “单次调用处理一组关联状态变更”,但开发者常因代码组织习惯(如按属性拆分函数)将相同参数的动画拆分调用,未利用 “同一闭包内同步更新多属性” 的特性,导致动画引擎重复执行状态对比与帧计算,增加冗余操作。3. 解决思路核心思路:基于鸿蒙动效引擎特性,匹配场景选择最优实现方式,平衡视觉体验,具体方向如下:转场场景:优先使用transition利用transition对 “组件显隐” 的原生支持,仅需切换isVisible等状态即可触发动画,无需手动处理回调逻辑,减少属性更新次数(从 2 次降至 1 次)。多属性动画:合并animateTo调用当多个组件属性(如宽度、颜色)需同步动画且参数(duration、curve)相同时,将所有属性更新合并到同一个animateTo闭包中,减少动画引擎的对比与计算次数。多次animateTo:统一状态更新若需多次触发animateTo,先统一计算所有目标状态(如先确定最终width、color值),再传入animateTo执行,避免频繁变更状态导致的多次刷新。4. 解决方案(一)转场动画优化:使用transition替代animateTo通过transition绑定组件显隐状态,自动处理 “出现 / 消失” 动画,减少逻辑复杂度与性能开销。animateTo实现转场(需回调处理)@Entry @Component struct TextToggleView { @State textTransparency: number = 1; @State isDisplayed: boolean = true; clickTimes: number = 0; build() { Column() { // 可切换显示状态的文本区域 Row() { if (this.isDisplayed) { Text('content') .opacity(this.textTransparency) } } .width('100%') .height(100) .justifyContent(FlexAlign.Center) // 控制按钮 Text('switch visibility') .onClick(() => { this.clickTimes++; const currentClick: number = this.clickTimes; this.isDisplayed = true; // 执行透明度动画,需在回调中隐藏组件 animateTo({ duration: 1000, onFinish: () => { if (currentClick === this.clickTimes && this.textTransparency === 0) { this.isDisplayed = false; } } }, () => { this.textTransparency = this.textTransparency === 1 ? 0 : 1; }) }) } } } transition实现转场(无需回调)@Entry @Component struct TextTransitionView { @State isVisible: boolean = true; build() { Column() { Row() { if (this.isVisible) { Text('content') .id('textElement') // 唯一标识,确保动画可打断 // 绑定透明度过渡动画 .transition(TransitionEffect.OPACITY.animation({ duration: 1000 })) } } .width('100%') .height(100) .justifyContent(FlexAlign.Center) Text('switch display') .onClick(() => { // 仅需切换状态,自动触发转场动画 this.isVisible = !this.isVisible; }) } } } (二)使用图形变换属性通过scale(缩放)、translate(平移)等属性实现组件尺寸 / 位置变化。修改width/height实现缩放@Entry @Component struct ResizeableTextView { @State boxWidth: number = 10; @State boxHeight: number = 10; build() { Column() { Text() .backgroundColor(Color.Blue) .width(this.boxWidth) .height(this.boxHeight) Button('调整尺寸') .margin({ top: 30 }) .onClick(() => { animateTo({ duration: 1000 }, () => { this.boxWidth = 100; this.boxHeight = 100; }) }) } } } 使用scale实现缩放@Entry @Component struct ScaleTransformView { @State boxScaleX: number = 1; @State boxScaleY: number = 1; build() { Column() { Text() .backgroundColor(Color.Blue) .width(10) .height(10) .scale({ x: this.boxScaleX, y: this.boxScaleY }) // 图形变换 Button('缩放变换') .margin({ top: 60 }) .onClick(() => { animateTo({ duration: 1000 }, () => { this.boxScaleX = 10; // 仅改变换属性 this.boxScaleY = 10; }) }) } } } (三)animateTo合并优化:相同参数合并调用将相同动画参数的多属性更新合并到一个animateTo闭包,减少调用次数与性能开销。@Entry @Component struct AnimatedBarController { @State barWidth: number = 200; @State barColor: Color = Color.Red; // 合并多属性动画 toggleBarProperties() { animateTo({ curve: Curve.Sharp, duration: 1000 }, () => { this.barWidth = this.barWidth === 100 ? 200 : 100; this.barColor = this.barColor === Color.Yellow ? Color.Red : Color.Yellow; }); } build() { Column() { Row().width(this.barWidth).height(10).backgroundColor(this.barColor) Text('点击触发').onClick(() => { this.toggleBarProperties(); // 单次调用,减少开销 }) } } } (四)多次animateTo状态统一更新当需多次触发animateTo时,先统一计算所有目标状态,再传入动画闭包,避免频繁状态变更。@Entry @Component struct MultiAnimateView { @State BOXwidth: number = 100; @State BOXheight: number = 100; updateSize() { // 1. 统一计算目标状态 const targetWidth = this.BOXwidth + 50; const targetHeight = this.BOXheight + 50; // 2. 单次animateTo更新所有状态 animateTo({ duration: 500 }, () => { this.BOXwidth = targetWidth; this.BOXheight = targetHeight; }); } build() { Column() { Text().width(this.BOXwidth).height(this.BOXheight).backgroundColor(Color.Green) Button('连续放大').onClick(() => { this.updateSize(); // 多次点击也仅单次状态更新 }) } } } 5. 成果总结(一)开销显著降低转场动画:使用transition后,属性更新次数减少,避免回调逻辑引发的性能波动;animateTo优化:合并调用后耗时降低,应用整体卡顿率下降。(二)效率大幅提升图形变换方案避免布局适配调试,开发周期缩短;animateTo合并调用减少代码冗余,维护成本降低。
-
1.1问题说明一是设备兼容性问题,要解决在高低端机型及不同屏幕尺寸上出现的渲染异常等适配问题;二是用户操作反馈不足问题,需补充倍速调整状态、进度拖动结果及错误状态的明确提示,避免用户误判。1.2原因分析播放器采用固定分辨率渲染,屏幕适配固定化,未针对不同屏幕尺寸、比例及像素密度动态调整,渲染尺寸和缩放策略单一,导致非标准比例屏幕出现画面拉伸、裁剪或黑边过大,高分辨率屏幕画面模糊、低分辨率屏幕因过度渲染损耗性能,破坏观看体验。1.3解决思路屏幕尺寸与比例适配:根据屏幕和视频宽高比计算最优显示尺寸 —— 比例接近时,用 “等比缩放 + 边缘填充” 保留完整画面、减少黑边;比例差异大时,提供 “智能裁剪(保主体)” 和 “完整显示(带黑边)” 供用户选择。实时动态调整:监听屏幕旋转、折叠等变化,实时更新渲染参数(如横屏时全屏显示,折叠屏展开后提升分辨率);同时根据内存占用调整帧缓存大小,避免因资源过度占用导致崩溃。1.4解决方案通过videoSizeChange获取视频宽高比、onAreaChange监听窗口尺寸,调用VideoSize动态调整播放区域(全屏保比例、非全屏适配宽度),结合window.setPreferredOrientation实现横竖屏切换适配;并在页面隐藏时暂停播放、组件销毁时释放avPlayer资源,避免拉伸变形与内存泄漏;代码示例:// 导入所需模块 import { BusinessError, emitter } from '@kit.BasicServicesKit'; // 错误处理和事件发射器 import { media } from '@kit.MediaKit'; // 媒体播放相关功能 import { Prompt, window } from '@kit.ArkUI'; // UI 提示和窗口控制 // 页面入口组件定义 @Entry @Component struct AVPlayer { // 状态变量定义 @State message: string = 'Hello World'; // 示例消息文本 @State vol: number = 1; // 音量控制(0-1) @State stateVideoState: boolean = false; // 视频播放状态记录 private xComponentController: XComponentController = new XComponentController(); // XComponent控制器,用于视频渲染 @State isFull: boolean = false; // 全屏状态标志 @State showController: boolean = true; // 控制器显示状态 @State playing: boolean = true; // 播放状态标志 @State surfaceID: string = ''; // 视频渲染表面ID @State videoSrc: string = 'https://consumer.huawei.com/content/dam/huawei-cbg-site/cn/mkt/pdp/phones/nova-flip/new/video/design-intro-popup.mp4'; // 视频源URL @State videoTime: number = 0; // 当前播放时间(毫秒) @State endTime: number = 0; // 视频总时长(毫秒) @State videoProportion: number = 0; // 视频宽高比 @State videoHeight: number = 1; // 视频显示高度 @State videoWidth: number = 1; // 视频显示宽度 @State windowHeight: number = 1; // 窗口高度 @State windowWidth: number = 1; // 窗口宽度 // 播放器实例 private avPlayer: media.AVPlayer | null = null; private longPressTimer: number | null = null; // 长按定时器 @State showSpeedTip: boolean = false; // 倍速提示显示状态 /** * 设置媒体源,用于动态切换视频 * @param newSource 新的视频URL */ private async setMediaSource(newSource: string) { // 验证新的视频源 if (!newSource || newSource === this.videoSrc) { Prompt.showToast({ message: '视频源无效或与当前相同' }); return; } try { // 暂停当前播放 if (this.avPlayer && ['playing', 'paused'].includes(this.avPlayer.state)) { await this.avPlayer.stop(); } // 更新视频源状态 this.videoSrc = newSource; this.videoTime = 0; this.endTime = 0; // 如果播放器实例存在,重置并设置新的URL if (this.avPlayer) { await this.avPlayer.reset(); this.avPlayer.url = newSource; // 如果已有渲染表面,立即准备播放 if (this.surfaceID) { this.avPlayer.surfaceId = this.surfaceID; await this.avPlayer.prepare(); } } else { // 如果播放器实例不存在,重新初始化 this.avPlayer = await media.createAVPlayer(); this.setAVPlayerCallback(this.avPlayer); this.avPlayer.url = newSource; } Prompt.showToast({ message: '视频源已切换' }); } catch (err) { console.error('切换视频源失败', err); Prompt.showToast({ message: '切换视频源失败' }); } } /** * 临时速度设置方法(用于长按快进) * @param speed 播放速度 */ private setTemporarySpeed(speed: media.PlaybackSpeed) { const mp = this.avPlayer; if (!mp) { return; } // 仅在播放或暂停状态允许临时调速 if (['playing', 'paused'].includes(mp.state)) { mp.setSpeed(speed); } } /** * 倍速设置方法(用于按钮控制) * @param speed 播放速度 */ private setPlaybackSpeed(speed: media.PlaybackSpeed) { const mp = this.avPlayer; if (!mp) { return; } // 检查允许设置速度的状态 const allowedStates = ['prepared', 'playing', 'paused', 'completed']; if (!allowedStates.includes(mp.state)) { Prompt.showToast({ message: '当前状态不支持调整播放速度' }); return; } try { mp.setSpeed(speed); } catch (err) { console.error('设置播放速度失败', err); Prompt.showToast({ message: '设置播放速度失败' }); } } /** * 设置AVPlayer回调函数 * @param avPlayer AVPlayer实例 */ private setAVPlayerCallback(avPlayer: media.AVPlayer) { console.log('状态机初始化'); // 首帧渲染回调 avPlayer.on('startRenderFrame', () => { console.info(`AVPlayer start render frame`); }); // 音量变化回调 avPlayer.on('volumeChange', (vol: number) => { this.vol = vol; }); // seek操作完成回调 avPlayer.on('seekDone', async (seekDoneTime: number) => { console.info('zzz=== avPlayer seek操作', ` seek time is ${seekDoneTime}`); }); // 错误处理回调 avPlayer.on('error', (err: BusinessError) => { console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`); avPlayer.reset(); // 出错时重置播放器 emitter.emit('VedioError'); }); // 播放时间更新回调 avPlayer.on('timeUpdate', async (time: number) => { this.videoTime = time; }); // 状态变化回调 avPlayer.on('stateChange', async (state: string, reason: media.StateChangeReason) => { console.log('zzz=== avPlayer状态变化', `当前状态${state}`); switch (state) { case 'idle': // 空闲状态 console.info('AVPlayer state idle called.'); break; case 'initialized': // 初始化完成 console.log('avPlayer状态机', "设置播放源后"); avPlayer.url; avPlayer.surfaceId = this.surfaceID; // 设置渲染表面 emitter.emit('VideoInitialized'); avPlayer.prepare(); // 准备播放 break; case 'prepared': // 准备完成 console.log('avPlayer状态机', '进入准备状态'); this.endTime = avPlayer.duration; // 获取视频时长 avPlayer.videoScaleType = 1; // 设置视频缩放类型 avPlayer.setVolume(1); // 设置音量 this.VideoSize(this.windowHeight, this.windowWidth); // 调整视频尺寸 this.doPlay(); // 开始播放 break; case 'playing': // 播放中 console.info('AVPlayer state playing called.'); this.playing = true; emitter.emit('VideoPlaying'); break; case 'paused': // 暂停 console.info('AVPlayer state paused called.'); this.playing = false; emitter.emit('VideoPaused'); break; case 'completed': // 播放完成 console.info('AVPlayer state completed called.'); this.playing = false; emitter.emit('VideoCompleted'); break; case 'stopped': // 停止 console.info('AVPlayer state stopped called.'); avPlayer.reset(); // 重置播放器 emitter.emit('VideoStopped'); break; case 'released': // 已释放 console.info('AVPlayer state released called.'); break; default: console.info('AVPlayer state unknown called.'); break; } }); // 视频尺寸变化回调 avPlayer.on('videoSizeChange', (width: number, height: number) => { this.videoProportion = width / height; // 计算宽高比 console.log('myTag', '获取视频比例', this.videoProportion); this.VideoSize(this.windowHeight, this.windowWidth); // 调整显示尺寸 }); } /** * 调整视频显示尺寸以适应容器 * @param height 容器高度 * @param width 容器宽度 */ VideoSize(height: number, width: number) { console.log('myTag', '视频比例调整'); console.log('视频高', height); this.videoHeight = height; if (this.isFull) { // 全屏时保持视频比例 this.videoWidth = height * this.videoProportion; } else { // 非全屏时填满宽度 this.videoWidth = width; } } /** * 开始播放视频 */ async doPlay() { let mp = this.avPlayer; console.log('zzz=== doPlay方法', `mp状态:${mp?.state};`); if (mp?.state && (mp?.state === "prepared" || mp?.state === "paused" || mp?.state === "completed")) { mp.play(); } else { Prompt.showToast({ message: '视频播放出现了点问题' }); } } /** * 暂停视频播放 */ async doPause() { let mp = this.avPlayer; console.log('zzz=== doPause方法', `mp状态:${mp?.state};`); if (mp?.state === 'playing') { mp.pause(); } } /** * 页面隐藏时的处理 */ async onPageHide() { this.doPause(); this.setOrientation(window.Orientation.USER_ROTATION_PORTRAIT); // 恢复竖屏 } /** * 设置屏幕方向 * @param orientation 方向枚举值 */ setOrientation(orientation: number) { window.getLastWindow(getContext(this)).then((win) => { win.setPreferredOrientation(orientation).then((data) => { console.log('setWindowOrientation: ' + orientation + ' Succeeded. Data: ' + JSON.stringify(data)); }).catch((err: string) => { console.log('setWindowOrientation: Failed. Cause: ' + JSON.stringify(err)); }); }).catch((err: string) => { console.log('setWindowOrientation: Failed to obtain the top window. Cause: ' + JSON.stringify(err)); }); } /** * 初始化播放器 */ init() { if (this.avPlayer) { // 一:HTTP视频播放。 // this.avPlayer.url = this.videoSrc; // 设置视频源 // 二:HLS视频播放。 // this.avPlayer.url = "http://XXXXXXXX.m3u8"; // 三:DASH视频播放。 // this.avPlayer.url = "http://XXXXXXXX.mpd"; // 四:通过setMediaSource设置自定义头域及播放优选参数实现初始播放参数设置。 let mediaSource: media.MediaSource = media.createMediaSourceWithUrl(this.videoSrc, { "": "" }); // 设置播放策略,设置为缓冲区数据为20s。 let playbackStrategy: media.PlaybackStrategy = { preferredBufferDuration: 50 }; // 为avPlayer设置媒体来源和播放策略。 this.avPlayer.setMediaSource(mediaSource, playbackStrategy); } } /** * 将毫秒时间转换为MM:SS格式字符串 * @param time 时间(毫秒) * @returns 格式化后的时间字符串 */ getTimeString(time: number): string { const totalSeconds = Math.floor(time / 1000); // 转换为秒 const minutes = Math.floor(totalSeconds / 60); // 计算分钟 const seconds = totalSeconds % 60; // 计算剩余秒数 // 补零处理:确保分钟和秒均为两位 return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } /** * 组件即将出现时的初始化操作 */ async aboutToAppear() { this.avPlayer = await media.createAVPlayer(); // 创建AVPlayer实例 this.setAVPlayerCallback(this.avPlayer); // 设置回调 setTimeout(() => { this.init(); // 初始化播放器 }, 300); // 刚进入时展示控制器 this.showController = true; // 6秒后隐藏控制器 setTimeout(() => { this.showController = false; }, 6000); } /** * 组件即将消失时的清理操作 */ async aboutToDisappear() { if (this.avPlayer) { await this.avPlayer.release(); // 释放播放器资源 this.avPlayer = null; } } /** * 构建UI界面 */ build() { NavDestination() { Column() { // 视频源切换示例按钮 Button('切换视频源') .onClick(() => { // 示例:切换到另一个视频源 this.setMediaSource('https://xxxxxxx.mp4'); }) .margin(10) Stack({ alignContent: Alignment.Top }) { // 视频播放组件 XComponent({ type: XComponentType.SURFACE, controller: this.xComponentController }) .gesture(LongPressGesture.onAction(() => { Prompt.showToast({ message: '长按可切换倍速' }) })) .onLoad(async e => { this.surfaceID = this.xComponentController.getXComponentSurfaceId(); // 获取渲染表面ID }) .height(this.videoHeight) .width(this.videoWidth) .zIndex(11) .onClick(() => { // 点击显示控制器,5秒后自动隐藏 animateTo({ duration: 600 }, () => { this.showController = true; setTimeout(() => { this.showController = false; }, 5000); }); }) .onTouch((event: TouchEvent) => { // 触摸事件处理(用于长按倍速) switch (event.type) { case TouchType.Down: // 启动长按定时器(2秒后触发倍速并显示提示) this.longPressTimer = setTimeout(() => { this.setTemporarySpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X); this.showSpeedTip = true; // 显示提示文字 }, 2000); break; case TouchType.Up: case TouchType.Cancel: // 清除定时器并恢复1倍速 if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } this.setTemporarySpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X); this.showSpeedTip = false; // 隐藏提示文字 break; } }); // 倍速提示文字 if (this.showSpeedTip) { Text('2x快进中') .fontSize(24) .fontWeight(500) .backgroundColor('#00000080') // 半透明黑色背景 .borderRadius(24) .zIndex(12) // 高于视频组件 } // 播放器控制栏 if (this.showController) { // 顶部控制栏(返回按钮) Row() { Image($r('app.media.startIcon')).width('30lpx').onClick(() => { if (this.isFull) { this.isFull = !this.isFull; this.setOrientation(window.Orientation.USER_ROTATION_PORTRAIT); } else { // router.back() } }); } .padding({ left: '25lpx', right: '25lpx', top: '25lpx' }) .width('100%') .zIndex(11) .transition({ type: TransitionType.Delete, opacity: 0 }); // 底部控制栏(播放/暂停、进度条、全屏按钮) Row() { // 播放/暂停按钮 if (this.playing) { Image($r('app.media.startIcon')).width('36lpx').margin({ right: '24lpx' }).onClick(() => { this.playing = false; this.doPause(); }); } else { Image($r('app.media.startIcon')).width('36lpx').margin({ right: '24lpx' }).onClick(() => { this.playing = true; this.doPlay(); }); } // 当前时间显示 Text(this.getTimeString(this.videoTime)).fontColor(Color.White).fontSize('22lpx'); // 进度条 Stack() { Slider({ value: this.videoTime, min: 0, max: this.endTime, step: 0.01, }) .width('438lpx') .videoSlider() .showSteps(false) .onChange((value: number, mode: SliderChangeMode) => { if (mode === 0) { // 开始拖动 console.log('zzz=== 进度条点击事件'); this.stateVideoState = this.playing; this.doPause(); } if (mode === 1) { // 拖动中 this.avPlayer?.seek(value, media.SeekMode.SEEK_CLOSEST); } if (mode === 2) { // 结束拖动 console.log('zzz=== 进度条松开事件'); this.avPlayer?.seek(value, media.SeekMode.SEEK_CLOSEST); if (this.stateVideoState) { this.doPlay(); } } }); } // 总时长显示 Text(this.getTimeString(this.endTime)).fontColor(Color.White).fontSize('22lpx'); // 全屏按钮 Image($r('app.media.startIcon')).width('36lpx').margin({ left: '24lpx' }).onClick(() => { console.log('全屏事件'); if (this.avPlayer) { this.isFull = !this.isFull; if (this.isFull) { this.setOrientation(window.Orientation.USER_ROTATION_LANDSCAPE); } else { this.setOrientation(window.Orientation.USER_ROTATION_PORTRAIT); } } }); } .width('100%') .height('68lpx') .position({ bottom: 0, left: 0 }) .backgroundColor('rgba(23,23,26,0.8)') .alignItems(VerticalAlign.Center) .padding({ left: '24lpx', right: '24lpx' }) .justifyContent(FlexAlign.SpaceBetween) .zIndex(11) .transition({ type: TransitionType.Insert, opacity: 0, translate: { y: '0lpx', x: 0 } }) .transition({ type: TransitionType.Delete, opacity: 0, translate: { y: '0lpx', x: 0 } }); } } .width('100%') .zIndex(11) .backgroundColor('#333333') .height(this.isFull ? '100%' : '422lpx') .onAreaChange((oldValue: Area, newValue: Area) => { // 窗口尺寸变化时调整视频尺寸 this.windowHeight = Number(newValue.height); this.windowWidth = Number(newValue.width); this.VideoSize(Number(newValue.height), Number(newValue.width)); }); // 倍速控制按钮 Row({ space: 10 }) { Button('0.5x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_0_50_X)) Button('1x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X)) Button('1.5x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_50_X)) Button('2x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X)) } .margin(20) } .width('100%') .height('100%') .padding({ top: this.isFull ? '0lpx' : '80lpx', bottom: '0lpx' }) .backgroundColor(Color.White); } .hideTitleBar(true) .onHidden(() => { this.onPageHide(); }) .onReady((context: NavDestinationContext) => { }); } } /** * 扩展Slider组件样式 */ @Extend(Slider) function videoSlider() { .trackColor($r('sys.color.white')) .trackThickness('5lpx') .selectedColor('#267fef') .blockBorderColor('rgba(44, 211, 215, 0.3)') .blockBorderWidth('8lpx') .blockColor('#267fef') .blockSize({ width: '25lpx', height: '25lpx' }); } 1.5方案成果总结设备兼容性全面优化:屏幕适配层面,通过宽高比对比实现 “等比缩放 + 边缘填充” 或用户可选的 “智能裁剪 / 完整显示”,并支持屏幕旋转、折叠屏形态变化的实时参数更新,解决了画面拉伸、黑边过大等问题,体验一致性增强。整体而言,方案通过动态适配与反馈强化,显著提升了播放器的设备兼容性及用户操作体验,实现了从 “功能可用” 到 “体验优质” 的升级。
-
1.1问题说明鸿蒙应用原生录音接口未集成自适应杂音过滤能力,仅提供基础音频采集功能,无法满足多噪声环境下的录音质量需求。用户在嘈杂环境中使用录音功能时,普遍预期 “基础降噪” 能力,但原生接口仅能过滤设备自身低频噪声,实际效果与用户预期差距显著。1.2原因分析(一)噪声环境动态性:环境噪声的强度和频率特性会随时间改变(如会议室多人说话、户外风声变化),静态噪声模型无法实时更新,导致降噪效果衰减。(二)阈值适配性不足,降噪与语音保留失衡:未结合段落能量特征最大振幅动态调整;段落能量差异大(如轻声语音与高声语音 + 环境噪声并存)时阈值双向适配失效,易致语音失真或噪声残留,最终无法平衡降噪强度与语音保留。(三)滤波窗口权重设计不合理:未突出滤波窗口内中心样本更贴近当前语音真实特征;语音关键细节(如辅音短暂强振幅、语音节奏瞬时变化)被周围噪声或平稳信号平均稀释。1.3解决思路针对上述问题,PCMDenoiser 类采用 “分层降噪 + 动态适应” 的设计思路,核心包括:(一)分段动态阈值:将音频按段分析,基于每段的能量特征(最大振幅、均方根 RMS)计算自适应阈值,平衡降噪强度与语音保留;(二)加权移动平均:对滤波窗口内的样本赋予不同权重(中心样本权重最高),减少对语音细节的模糊;(三)语音活动检测(VAD)+ 自适应噪声跟踪:区分语音段与噪声段,仅在非语音段更新噪声底值,使噪声模型随环境动态调整,提升复杂场景适应性。1.4解决方案(一)阈值降噪(thresholdDenoise):将音频数据按段(最小 1024 样本)划分,计算每段的最大振幅、最小振幅和 RMS,动态生成阈值(取 RMS 的 1.2 倍、振幅的 8%、最小阈值 50 中的最大值),对低于阈值的样本归零,保留强信号。(二)移动平均滤波(movingAverageDenoise):基于缓冲区大小生成加权窗口(中心样本权重最高,向两端线性递减),对每个样本计算加权平均值,在平滑噪声的同时减少对语音细节的破坏。(三)自适应降噪(adaptiveDenoise):结合 VAD 判断语音 / 噪声段:语音段:采用较高阈值(噪声底值的 1.8 倍或局部 RMS 的 25%),并应用轻微平滑(小窗口加权平均),保留细节;噪声段:采用较低阈值(噪声底值的 1.2 倍),对弱信号衰减 70%,增强降噪效果;同时,通过噪声计数器动态更新噪声底值(非语音段连续检测到噪声时,用指数平滑法更新),适配环境变化。代码示例:/** * 简化的PCM降噪器 - 针对的主音+噪声优化 */ class PCMDenoiser { private _bufferSize: number; private _noiseFloor: number = 0; private _denoiseStrength: number = 0.8; constructor(bufferSize: number = 9) { this._bufferSize = Math.max(3, Math.min(21, bufferSize)); } /** * 将ArrayBuffer转换为Int16Array */ private arrayBufferToInt16(arrayBuffer: ArrayBuffer): Int16Array { return new Int16Array(arrayBuffer); } /** * 将Int16Array转换回ArrayBuffer */ private int16ToArrayBuffer(int16Array: Int16Array): ArrayBuffer { return int16Array.buffer; } /** * 计算统计信息 */ private calculateAmplitudeStats(pcmData: Int16Array, start: number, end: number): Stats { let max = -32768; let min = 32767; let sumSq = 0; let sumAbs = 0; const count = end - start; for (let i = start; i < end; i++) { const val = pcmData[i]; max = Math.max(max, val); min = Math.min(min, val); sumSq += val * val; sumAbs += Math.abs(val); } return { max, min, rms: Math.sqrt(sumSq / count), meanAbs: sumAbs / count }; } /** * 改进的阈值降噪 - 更明显的降噪效果 */ thresholdDenoise(pcmBuffer: ArrayBuffer): ArrayBuffer { const pcmData = this.arrayBufferToInt16(pcmBuffer); const result = new Int16Array(pcmData.length); // 计算全局统计 const globalStats = this.calculateAmplitudeStats(pcmData, 0, pcmData.length); // 建立噪声基线 - 使用音频开始和结束部分(假设这些部分是纯噪声) const noiseStart = 0; const noiseEnd = Math.min(pcmData.length, Math.floor(0.1 * 16000)); // 前100ms const noiseStats = this.calculateAmplitudeStats(pcmData, noiseStart, noiseEnd); const noiseBaseline = Math.max(noiseStats.rms * 1.5, 200); this._noiseFloor = noiseBaseline; console.info(`阈值降噪 - 全局RMS: ${globalStats.rms.toFixed(2)}, 噪声基线: ${noiseBaseline.toFixed(2)}`); // 动态阈值 - 根据降噪强度调整 const threshold = noiseBaseline * (1 + this._denoiseStrength * 2); for (let i = 0; i < pcmData.length; i++) { const absValue = Math.abs(pcmData[i]); if (absValue < threshold) { // 强力降噪:低于阈值的信号大幅衰减 const reduction = (threshold - absValue) / threshold; result[i] = Math.round(pcmData[i] * (1 - reduction * this._denoiseStrength * 0.9)); } else { // 保留强信号(主音) result[i] = pcmData[i]; } } return this.int16ToArrayBuffer(result); } /** * 简化的频域降噪 - 使用移动平均滤波器 */ spectralDenoise(pcmBuffer: ArrayBuffer): ArrayBuffer { const pcmData = this.arrayBufferToInt16(pcmBuffer); const result = new Int16Array(pcmData.length); const windowSize = 11; // 奇数大小的窗口 for (let i = 0; i < pcmData.length; i++) { let sum = 0; let count = 0; // 应用移动平均滤波器 for (let j = -Math.floor(windowSize/2); j <= Math.floor(windowSize/2); j++) { const index = i + j; if (index >= 0 && index < pcmData.length) { sum += pcmData[index]; count++; } } result[i] = Math.round(sum / count); } return this.int16ToArrayBuffer(result); } /** * 自适应降噪 - 结合时域和频域处理 */ adaptiveDenoise(pcmBuffer: ArrayBuffer): ArrayBuffer { const pcmData = this.arrayBufferToInt16(pcmBuffer); const result = new Int16Array(pcmData.length); const segmentSize = 256; // 使用音频开始部分建立噪声基线 const noiseStart = 0; const noiseEnd = Math.min(pcmData.length, Math.floor(0.1 * 16000)); const noiseStats = this.calculateAmplitudeStats(pcmData, noiseStart, noiseEnd); const noiseBaseline = Math.max(noiseStats.rms * 1.5, 150); console.info(`自适应降噪 - 噪声基线: ${noiseBaseline.toFixed(2)}`); for (let i = 0; i < pcmData.length; i += segmentSize) { const end = Math.min(i + segmentSize, pcmData.length); const segmentStats = this.calculateAmplitudeStats(pcmData, i, end); // 判断是否为信号段(主音)还是噪声段 const isSignal = segmentStats.rms > noiseBaseline * 2; for (let j = i; j < end; j++) { if (isSignal) { // 信号段:轻度降噪,保留主音特征 result[j] = this.mildNoiseReduction(pcmData, j); } else { // 噪声段:强力降噪 result[j] = this.strongNoiseReduction(pcmData[j]); } } } return this.int16ToArrayBuffer(result); } /** * 轻度噪声消除 - 用于信号段 */ private mildNoiseReduction(pcmData: Int16Array, index: number): number { // 3点平滑 let sum = 0; let count = 0; for (let i = -1; i <= 1; i++) { const idx = index + i; if (idx >= 0 && idx < pcmData.length) { sum += pcmData[idx]; count++; } } return Math.round(sum / count); } /** * 强力噪声消除 - 用于噪声段 */ private strongNoiseReduction(sample: number): number { // 大幅衰减噪声 return Math.round(sample * 0.3); } /** * 设置缓冲区大小 */ setBufferSize(size: number): void { this._bufferSize = Math.max(3, Math.min(21, size)); } /** * 设置降噪强度 */ setDenoiseStrength(strength: number): void { this._denoiseStrength = Math.max(0.3, Math.min(1.0, strength)); } getBufferSize(): number { return this._bufferSize; } } interface Stats { max: number; min: number; rms: number; meanAbs: number; } export default PCMDenoiser; 1.5方案成果总结通过分层设计和动态适应机制,实现了以下成果:降噪效果与语音保真平衡:分段动态阈值和加权滤波避免噪音的过度处理,保留弱语音;环境适应性强:自适应噪声跟踪可实时更新噪声模型,在动态噪声环境中保持稳定效果;灵活性高:支持缓冲区大小调整和噪声跟踪重置,可根据实际场景定制降噪策略。
-
关键技术难点总结该方案通过分层架构设计、统一接口封装、状态机管理等技术手段,成功解决了鸿蒙平台FIDO生物识别认证的复杂性问题,实现了多认证方式支持、完整的设备支持检测流程、统一的错误处理机制以及安全的设备信息验证,为鸿蒙应用的生物识别功能提供了可靠的技术支撑,具有良好的可维护性、扩展性和安全性。1.1 问题说明在鸿蒙应用开发中集成FIDO生物识别认证时,面临以下主要问题:l 多认证方式兼容性问题:需要同时支持指纹、人脸、手势等多种生物识别方式。l 设备支持检测复杂性:需要检测客户端和服务端对FIDO认证方式的支持情况。l 认证流程状态管理:FIDO认证涉及注册、认证、注销等多个状态,状态管理复杂。l 错误处理机制不完善:不同认证方式的错误码和错误信息处理不统一。设备信息获取和验证:需要获取设备唯一标识并进行安全验证。1.2 原因分析· FIDO标准复杂性· FIDO联盟定义了UAF(Universal Authentication Framework)和FIDO2两种标准,在鸿蒙平台实现时需要适配不同的认证协议。· 不同生物识别方式(指纹、人脸、手势)的认证流程和参数要求不同,需要分别处理。· · Canvas与UI组件坐标系统差异鸿蒙平台特殊性· 鸿蒙的ArkTS语言和状态管理系统与Android/iOS不同,需要重新设计架构。· 鸿蒙的设备信息获取API和权限管理机制与Android存在差异。· · 业务场景复杂性· 银行应用对安全性要求极高,需要多层验证和风险控制。· 需要支持登录、交易、注销等多种业务场景,每种场景的认证要求不同。1.3 解决思路· 分层架构设计· 将FIDO功能分为SDK层、服务层、工具层、常量层四个层次。· 每层职责明确,便于维护和扩展。· · 统一接口封装· 封装第三方FIDO SDK,提供统一的业务接口。· 屏蔽底层实现细节,简化上层调用。· 状态机管理· 设计认证状态机,管理注册、认证、注销等状态转换。· 提供统一的错误处理和回调机制。1.4 解决方案 方式1:分层架构实现 ```typescript// SDK层:直接调用第三方SDKexport class FidoSdkService { private fidoSdk: FidoSdk = new FidoSdk() async process(context: Context, fidoRequest: FidoRequest): Promise<FidoResponse> { return await this.fidoSdk.process(context, fidoRequest) } async checkSupport(context: Context, fidoRequest?: FidoRequest): Promise<FidoResponse> { return await this.fidoSdk.checkSupport(context, fidoRequest) }}``` 方式2:业务服务封装 ```typescript// 服务层:封装业务逻辑export class FidoService implements IFidoService { async initFido(context: Context, authType: FidoAuthType, callback: OnResult, obj: object, data: string, payid: string): Promise<void> { let fidoResp: FidoResponse = await this.process(context, obj) if (!FidoUtil.getInstance().isCodeEmpty(fidoResp)) { if (fidoResp.code == FidoStatus.SUCCESS) { // 处理认证成功逻辑 let dataObj: object = MBTool.jsonParse(data) let deviceInfoBase64 = await FidoUtil.getInstance().getDeviceInfo(true) dataObj['DevicesInfo'] = deviceInfoBase64 // ... 其他业务逻辑 } } }}``` 方式3:工具类实现 ```typescript// 工具层:提供通用功能export class FidoUtil { async checkSupport(context: Context, authType: FidoAuthType, transType: FidoTransType, callback: FidoCallBack): Promise<void> { let authTypeList: FidoAuthType[] = [] authTypeList.push(authType) let fidoRequest: FidoRequest = { authTypes: authTypeList } // 本地设备支持检查 let respSupport = await FidoSdkService.getInstance().checkSupport(context, fidoRequest) if (respSupport.code == FidoStatus.SUCCESS) { if (this.isSupport(authType, respSupport)) { // 服务端设备支持检查 let params: Record<string, string> = {} let deviceInfoBase64 = await this.getDeviceInfo(true) params['DevicesInfo'] = deviceInfoBase64 params['AuthType'] = authType const respDo = await TransactionTool.submit(HSGlobalUrlConfig.PDeviceSupport, params) // 处理服务端响应 } } }}``` 方式4:常量配置管理 ```typescript// 常量层:统一配置管理export class FidoConstant { // 认证类型定义 static readonly AUTH_TYPE_FINGERPRINT = FidoAuthType.UAF_FINGER static readonly AUTH_TYPE_FACE = FidoAuthType.UAF_FACE static readonly AUTH_TYPE_GESTURE = FidoAuthType.UAF_GESTURE // 交易类型定义 static readonly Trans_TYPE_FINGERPRINT_LOGIN: FidoTransType = FidoTransType.UAF_LOGIN static readonly Trans_TYPE_FINGERPRINT_PAY: FidoTransType = FidoTransType.UAF_TRADE // 错误信息定义 static readonly CHECK_SUPPORT_FAIL: string = '该功能暂不支持您的机型' static readonly FINGER_PRINT_NOT_AVAILABLE: string = '指纹不可用'}```
-
一、关键技术总结1. 问题说明(一)网络状态无感知,视频策略脱节 WiFi 与蜂窝网络切换、断网或弱网时,应用未实时响应,视频仍按原策略播放。如开启 “仅 WiFi 自动播放”,WiFi 断开后未暂停,耗蜂窝流量;弱网无提示,卡顿严重却无反馈,影响体验。(二)自动播放设置不持久,状态不同步 设置的 WiFi / 蜂窝自动播放开关,未持久化存储,应用重启后重置;页面与设置状态未实时同步,修改开关后,视频播放策略未即时更新,出现 “设置关却播放” 的矛盾。(三)资源管理混乱,监听未闭环 页面销毁时未关闭网络监听,重复创建监听实例;网络事件传递延迟,状态变化后未及时触发视频控制,如网络恢复后视频未自动重启。2. 原因分析(一)网络监听能力缺失 未集成 Network Kit 的netAvailable、netLost等事件,无法实时获取网络状态;未区分 WiFi / 蜂窝类型单独处理,导致策略无法差异化适配。(二)状态持久化机制断层 用普通变量存储设置,未通过PersistentStorage持久化;未借助AppStorage同步页面与设置状态,修改后无法即时同步至播放逻辑。(三)资源生命周期失控 在build生命周期创建监听,页面重绘导致多实例冲突;页面销毁未调用unregister关闭监听,实例残留占用资源,新监听无法正常初始化。3. 解决思路(一)构建全场景网络监听体系 基于 Network Kit 订阅netAvailable、netLost、WeakNet等事件,区分 WiFi / 蜂窝类型,实时传递状态至页面,为视频控制提供依据。(二)设置持久化与状态同步 用PersistentStorage持久化自动播放设置,确保重启不丢失;通过AppStorage实现页面与设置的状态联动,修改后即时更新播放策略。(三)标准化资源生命周期管理 在aboutToAppear初始化监听,aboutToDisappear调用unregister释放;用单例模式管理监听实例,避免重复创建,确保事件传递高效。4. 解决方案(一)网络监听工具类单例封装 Network Kit 监听与事件传递,统一处理网络状态:import { connection } from '@kit.NetworkKit'; import { BusinessError, emitter } from '@kit.BasicServicesKit'; import { radio } from '@kit.TelephonyKit'; import { wifiManager } from '@kit.ConnectivityKit'; import { logger } from './Logger'; import { netQuality } from '@kit.NetworkBoostKit'; import { NetworkEventData } from './EmitterData'; import { HashMap, JSON } from '@kit.ArkTS'; type NetworkData = boolean | connection.NetBlockStatusInfo | connection.NetBearType | connection.NetConnectionPropertyInfo | connection.NetCapabilityInfo; // 网络监听emitter事件 export enum NetworkEventName { // 注册网络监听订阅事件 NetObserverRegister, // 网络可用 NetAvailable, // 网络阻塞 NetBlock, // 网络丢失/断开 NetLost, // 当网络能力变化时,如网络从无网络到有网络、从4G切换到5G NetCapabilitiesChange, // 网络不可用 NetUnavailable, // WIFI状态改变 WifiStateChange, // WIFI连接状态改变 WifiConnectionChange, // 弱网 WeakNet, // 订阅网络连接信息变化事件,当网络连接信息变化时,如从无网络到有网络、从Wi-Fi切换到蜂窝 NetConnectionPropertiesChange } export class CellularLinkNetUtils { public static instance: NetUtils; private connectionMap: HashMap<connection.NetBearType, connection.NetConnection> = new HashMap(); // 网络状态监听eventId private networkEventId: number = 10001; // 网络监听相关结果数据 private emitterEvent: NetworkEventData; constructor() { this.emitterEvent = new NetworkEventData(this.networkEventId); } static getInstance(): NetUtils { if (!NetUtils.instance) { NetUtils.instance = new NetUtils(); } return NetUtils.instance; } public getEmitterEvent(): NetworkEventData { return this.emitterEvent; } private setEventPriority(priority: emitter.EventPriority): void { this.emitterEvent.priority = priority; } private postEvent(eventName: NetworkEventName, status: NetworkData, netType?: connection.NetBearType, priority?: emitter.EventPriority) { this.emitterEvent.priority = priority; emitter.emit(this.emitterEvent, { data: new NetEventData(eventName, status, netType) }) } //开启网络监听 public startNetObserve(...netType: connection.NetBearType[]) { netType.forEach((type: connection.NetBearType) => { this.networkObserve(type); if (type === connection.NetBearType.BEARER_WIFI) { this.wifiStateObserve(); } }) } // 停止网络监听 public stopNetObserve(netType: connection.NetBearType) { this.connectionMap.get(netType).unregister(() => { logger.info("Success unregister:" + netType.toString()); }) } // 停止所有网络监听 public stopAllNetObserve() { emitter.off(this.getEmitterEvent().eventId); this.connectionMap.forEach((netConnection: connection.NetConnection, netType: connection.NetBearType) => { netConnection.unregister(() => { logger.info("Success unregister:" + netType.toString()); }); }) } getNetworkConnectionType(): Array<connection.NetBearType> { try { // 获取默认激活的数据网络 let netHandle = connection.getDefaultNetSync(); if (!netHandle || netHandle.netId === 0) { return []; } // 获取网络的类型、拥有的能力等信息 let netCapability = connection.getNetCapabilitiesSync(netHandle); return netCapability.bearerTypes; } catch (e) { let err = e as BusinessError; logger.error('errCode: ' + (err as BusinessError).code + ', errMessage: ' + (err as BusinessError).message); return []; } } judgeHasNet(): boolean { try { let netHandle = connection.getDefaultNetSync(); if (!netHandle || netHandle.netId === 0) { return false; } let netCapability = connection.getNetCapabilitiesSync(netHandle); let cap = netCapability.networkCap || []; if (cap.includes(connection.NetCap.NET_CAPABILITY_VALIDATED)) { //connection.NetCap.NET_CAPABILITY_VALIDATED,该值代表网络是通的,能够发起HTTP和HTTPS的请求。 // 网络信息变化,网络可用 return true; } else { // 网络信息变化,网络不可用 return false; } } catch (e) { let err = e as BusinessError; logger.error("JudgeHasNet" + JSON.stringify(err)); } return false; } // 获取网络状态,查询手机卡注册网络的运营商名称、是否处于漫游状态、设备的网络注册状态等信息 getNetworkStatus() { radio.getNetworkState((err: BusinessError, data: radio.NetworkState) => { if (err) { logger.error(`getNetworkState failed, callback: err->${JSON.stringify(err)}`); } // regState字段表示设备的网络注册状态 // (REG_STATE_POWER_OFF,值为3)蜂窝无线电已关闭,modem下电,无法和网侧进行通信 logger.info("Success getNetworkStatus:" + JSON.stringify(data)); }); } async getSignalType(): Promise<radio.SignalInformation[]> { let slotId: number = await radio.getPrimarySlotId(); let data: Array<radio.SignalInformation> = radio.getSignalInformationSync(slotId); // signalType代表网络类型NetworkType let signalType = data[0].signalType; logger.info("getSignalType:" + JSON.stringify(data)); return data; } getWifiStatus(): boolean { try { let isWifiActive: boolean = wifiManager.isWifiActive(); return isWifiActive; } catch (error) { logger.error("failed:" + JSON.stringify(error)); } return false; } getWifiIsConnected(): boolean { try { let ret = wifiManager.isConnected(); logger.info("isConnected:" + ret); return ret; } catch (error) { logger.error("failed:" + JSON.stringify(error)); } return false; } async getSignalLevel(): Promise<number> { try { let wifiLinkedInfo: wifiManager.WifiLinkedInfo = await wifiManager.getLinkedInfo(); let rssi = wifiLinkedInfo.rssi; let band = wifiLinkedInfo.band; let level = wifiManager.getSignalLevel(rssi, band); logger.info("level:" + JSON.stringify(level)); return level; } catch (error) { logger.error("failed:" + JSON.stringify(error)); } return -1; } networkObserve(netType: connection.NetBearType) { // TODO:根据网络类型,设置不同的网络监听,用于WI-FI和蜂窝网络切换时判断各自网络状态的变化。 let netConnection: connection.NetConnection = connection.createNetConnection({ netCapabilities: { bearerTypes: [netType] } }) // 注册网络监听,注册成功后才能监听到对应类型的网络状态变化 netConnection.register((error: BusinessError) => { let result = true; if (error) { logger.info("NetUtils", "NetType :" + netType + ", network register failed: " + JSON.stringify(error)); result = false; } logger.info("NetUtils", "NetType :" + netType + ", network register succeed"); this.postEvent(NetworkEventName.NetObserverRegister, result, netType); }); // 网络能力改变监听,当网络能力变化时,如网络从无网络到有网络、从4G切换到5G时,会触发该事件。 netConnection.on('netCapabilitiesChange', (data: connection.NetCapabilityInfo) => { logger.info("NetUtils", "NetType :" + netType + ", network netCapabilitiesChange: " + JSON.stringify(data)); this.postEvent(NetworkEventName.NetCapabilitiesChange, data, netType); }) // 网络可用监听,当网络可用时触发该事件。 netConnection.on("netAvailable", (data: connection.NetHandle) => { logger.info("NetUtils", "NetType :" + netType + ", network succeeded to get netAvailable: " + JSON.stringify(data)); // 检查默认数据网络是否被激活,使用同步方式返回接口,如果被激活则返回true,否则返回false。 }); // 订阅网络阻塞状态事件,当网络阻塞时,如网络性能下降、数据传输出现延迟等情况时,会触发该事件 netConnection.on('netBlockStatusChange', (data: connection.NetBlockStatusInfo) => { logger.info("NetUtils", "NetType :" + netType + ", network netBlockStatusChange " + JSON.stringify(data)); this.postEvent(NetworkEventName.NetBlock, data, netType) }); // 网络连接信息变化监听,当网络连接信息变化时,如从无网络到有网络、从Wi-Fi切换到蜂窝时,会触发该事件。 netConnection.on('netConnectionPropertiesChange', (data: connection.NetConnectionPropertyInfo) => { logger.info("NetUtils", "NetType :" + netType + ", network netConnectionPropertiesChange " + JSON.stringify(data)); this.postEvent(NetworkEventName.NetConnectionPropertiesChange, data, netType); }); // 订阅网络丢失事件,当网络严重中断或正常断开时触发该事件 // 网络丢失是指网络严重中断或正常断开事件,当断开Wi-Fi时,是属于正常断开网络连接,会触发netLost事件 netConnection.on('netLost', (data: connection.NetHandle) => { this.postEvent(NetworkEventName.NetLost, true, netType) logger.info("NetUtils", "NetType :" + netType + ", Succeeded to get netLost: " + JSON.stringify(data)); }); // 订阅网络不可用事件,当网络不可用时触发该事件 // 网络不可用是指网络不可用事件,当连接的网络不能使用时,会触发netUnavailable事件。 netConnection.on('netUnavailable', () => { logger.info("NetUtils", "NetType :" + netType + ", Succeeded to get unavailable net event"); this.postEvent(NetworkEventName.NetUnavailable, true, netType); }); this.connectionMap.set(netType, netConnection); } wifiStateObserve() { // 注册WLAN状态改变事件 // 0,未激活;1,已激活;2,激活中;3:去激活中 wifiManager.on("wifiStateChange", (result: number) => { logger.info("NetUtils", "wifiStateChange: " + result); this.postEvent(NetworkEventName.WifiStateChange, result); }); // 注册WLAN连接状态改变事件 // 0,已断开;1,已连接 wifiManager.on("wifiConnectionChange", (result: number) => { logger.info("NetUtils", "wifiConnectionChange: " + result); this.postEvent(NetworkEventName.WifiConnectionChange, result); }); } parseResult(data: emitter.EventData): string { if (data.data) { if (!data.data.eventName) { logger.info("parseResult data.data.eventName is undefined.") return ""; } } else { logger.info("parseResult data.data is undefined.") return ""; } let result = ""; let name: number = (data.data)!.eventName ?? -1; switch (name) { case NetworkEventName.NetObserverRegister.valueOf(): result = "NetObserverRegister"; break; case NetworkEventName.NetAvailable.valueOf(): result = "NetAvailable"; break; case NetworkEventName.NetBlock.valueOf(): result = "NetBlock"; break; case NetworkEventName.NetLost.valueOf(): result = "NetLost"; break; case NetworkEventName.NetCapabilitiesChange.valueOf(): result = "NetCapabilitiesChange"; break; case NetworkEventName.NetUnavailable.valueOf(): result = "NetUnavailable"; break; case NetworkEventName.NetConnectionPropertiesChange.valueOf(): result = "NetConnectionPropertiesChange"; break; case NetworkEventName.WifiStateChange.valueOf(): result = "WifiStateChange"; break; case NetworkEventName.WifiConnectionChange.valueOf(): result = "WifiConnectionChange"; break; case NetworkEventName.WeakNet.valueOf(): result = "WeakNet"; break; default: result = name.toString(); break } let netTemp: string = ""; let temp: number = data.data!.netType ?? -1; if (temp === 1) { netTemp = "WIFI"; } if (temp === 0) { netTemp = "CELLULAR"; } if (temp === -1) { netTemp = temp.toString(); } result = result + "------" + (data.data!.status ?? -1) + "------" + netTemp; return result; } sceneChangeObserve() { try { netQuality.on('netSceneChange', (list: Array<netQuality.NetworkScene>) => { if (list.length > 0) { list.forEach((networkScene) => { // 回调信息处理 logger.info(`Succeeded receive netSceneChange info`); if (networkScene.scene == 'weakSignal' || networkScene.scene == 'congestion') { // 表示为弱网场景 logger.info(`The current network is weak`); this.postEvent(NetworkEventName.WeakNet, true) } else { this.postEvent(NetworkEventName.WeakNet, false) } }); } }); } catch (err) { logger.error('errCode: ' + (err as BusinessError).code + ', errMessage: ' + (err as BusinessError).message); } } } export class NetEventData { eventName: NetworkEventName; status: NetworkData; netType: connection.NetBearType; constructor(eventName: NetworkEventName, status: NetworkData, netType: connection.NetBearType) { this.eventName = eventName; this.status = status; this.netType = netType; } } (二)视频播放组件处理网络事件,控制视频播放,同步设置状态:import { NetEventData, NetUtils, NetworkEventName } from '../utils/NetUtils'; import { emitter } from '@kit.BasicServicesKit'; import { connection } from '@kit.NetworkKit'; import { logger } from '../utils/Logger'; import { Prompt } from '@kit.ArkUI'; import { CellularSetting } from './CellularSetting'; // 将自动播放设置通过PersistentStorage进行本地持久化存储,避免每次打开应用都需要重新设置 PersistentStorage.persistProp('cellular_auto_play', false); PersistentStorage.persistProp('wifi_auto_play', false); const innerEvent: emitter.InnerEvent = { // 左上角返回按钮点击事件传递的eventId eventId: 6 }; @Builder export function PageThreeBuilder() { CellularLink() } @Component export struct CellularLink { pathStack: NavPathStack = new NavPathStack(); private navPathStack: NavPathStack = new NavPathStack(); // 视频控制器 controller: VideoController = new VideoController(); // WI-FI自动播放 @StorageLink("wifi_auto_play") wifiAutoPlay: boolean = false; // 3G/4G/5G自动播放 @StorageLink("cellular_auto_play") cellularAutoPlay: boolean = false; // 在线视频地址 private videoUrl: string = "https://xxx/xxx/xxx.mp4"; // 注册路由返回函数,案例插件不触发 popRouter: () => void = () => { }; // 使用流量播放弹窗 networkDialog: CustomDialogController | null = new CustomDialogController({ builder: NetworkDialogComponent({ title: $r('app.string.network_status_observer_cellular_dialog_title'), message: $r('app.string.network_status_observer_cellular_dialog_message'), cancel: () => { // 用户点击取消,则停止播放 this.pausePlay(); this.networkDialog?.close(); }, confirm: () => { // 用户点击确认,则继续播放 this.startPlay(); this.networkDialog?.close(); } }), cornerRadius: $r('app.integer.network_status_observer_cellular_dialog_message_radius'), alignment: DialogAlignment.Center }) // 网络监听回调 netObserver(data: emitter.EventData) { if (!data.data) { logger.info("netObserver data.data is undefined."); return; } logger.info("network observe result : " + NetUtils.getInstance().parseResult(data)); let netEventData: NetEventData = data.data! as NetEventData; let eventName: NetworkEventName = netEventData.eventName ?? -1; switch (eventName) { case NetworkEventName.NetAvailable: // WI-FI是可用状态 if (netEventData.netType === connection.NetBearType.BEARER_WIFI) { // 如果开了WI-FI自动播放,则继续播放 if (this.wifiAutoPlay) { this.startPlay(); } } break; case NetworkEventName.NetBlock: break; case NetworkEventName.NetLost: // 如果WI-FI网络丢失,则通过wifiInterrupt方法判断是否需要继续播放 if (netEventData.netType === connection.NetBearType.BEARER_WIFI) { this.wifiInterrupt(); } break; case NetworkEventName.NetUnavailable: // 如果WI-FI不可用,则通过wifiInterrupt方法判断是否需要继续播放 if (netEventData.netType === connection.NetBearType.BEARER_WIFI) { this.wifiInterrupt(); } break; case NetworkEventName.WeakNet: // 如果是弱网环境,则弹出提示,实际应用开发中可以通过该结果自动实现分辨率自动切换 if (netEventData.status) { Prompt.showToast({ message: getContext().resourceManager.getStringSync($r('app.string.network_status_observer_weak')) }); } break; default: logger.debug("当前网络状态:" + eventName); break; } } /** * WI-FI中断时的操作 * 如果开启了3G/4G/5G自动播放,则继续播放,并且提示正在使用流量播放 * 如果关闭了3G/4G/5G自动播放,则弹出弹窗,让用户选择是否继续使用流量播放 */ wifiInterrupt() { if (NetUtils.getInstance().getNetworkConnectionType()[0] === connection.NetBearType.BEARER_CELLULAR) { if (this.cellularAutoPlay) { Prompt.showToast({ message: getContext().resourceManager.getStringSync($r('app.string.network_status_observer_user_cellular')) }); } else { this.pausePlay(); this.networkDialog?.open(); } } } /** * 是否自动播放 * @returns true:自动播放,false,不自动播放 */ autoPlay(): boolean { let autoPlay: boolean = false; // 如果网络是可用的 if (NetUtils.getInstance().judgeHasNet()) { // 获取当前连接的网络类型 let currentNetType: connection.NetBearType = NetUtils.getInstance().getNetworkConnectionType()[0]; switch (currentNetType) { case connection.NetBearType.BEARER_CELLULAR: // 蜂窝网络 // 如果开启了3G/4G/5G自动播放,则设置autoPlay为true if (this.cellularAutoPlay) { autoPlay = true; } break; case connection.NetBearType.BEARER_WIFI: // WIFI网络 case connection.NetBearType.BEARER_ETHERNET: // 以太网网络(模拟器) // 如果设置了WI-FI自动播放,则设置autoPlay为true if (this.wifiAutoPlay) { autoPlay = true; } break; } } return autoPlay; } // 开始播放 startPlay() { if (this.controller) { this.controller.start(); } } // 暂停播放 pausePlay() { if (this.controller) { this.controller.pause(); } } onPageShow(): void { if (this.autoPlay()) { this.startPlay(); } } onPageHide(): void { this.pausePlay(); } aboutToAppear(): void { // 通过emitter接受网络监听结果 emitter.on(NetUtils.getInstance().getEmitterEvent(), (data: emitter.EventData) => { if (data) { this.netObserver(data); } else { logger.info("aboutToAppear emitter on error, data is undefined."); } }); // 开启蜂窝网络和WI-FI网络状态的监听 NetUtils.getInstance() .startNetObserve(connection.NetBearType.BEARER_CELLULAR, connection.NetBearType.BEARER_WIFI); // 收到eventId为6的事件后执行回调函数 emitter.on(innerEvent, () => { // 在案例主页时,返回瀑布流 if (this.navPathStack.size() === 0) { this.popRouter(); } }); } aboutToDisappear(): void { // 当页面销毁时,停止所有网络监听 NetUtils.getInstance().stopAllNetObserve(); if (this.controller) { this.controller.stop(); } // 销毁事件监听 emitter.off(innerEvent.eventId); } @Builder buildMap(name: string, param: ESObject) { if (name === "CellularLink") { NavDestination() { CellularSetting() }.hideTitleBar(true) } } build() { NavDestination() { Navigation(this.navPathStack) { Column() { Row() { Text($r('app.string.network_status_observer_auto_play_setting')) }.justifyContent(FlexAlign.End) .width($r('app.string.network_status_observer_percent_100')) .onClick(() => { this.navPathStack.pushPath({ name: "CellularLink" }) }) Video({ src: this.videoUrl, controller: this.controller }) .height(300) .width('100%') .autoPlay(this.autoPlay()) .id("id_network_status_observer_video") } .height('100%') .width('100%') }.hideTitleBar(true) .hideToolBar(true) .navDestination(this.buildMap) } .title('Cellular_Link') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } // 流量播放提示框 @CustomDialog export struct NetworkDialogComponent { controller?: CustomDialogController; // 标题 title: ResourceStr = ""; // 提示信息 message: ResourceStr = ""; // 取消事件 cancel: () => void = () => { }; // 确认事件 confirm: () => void = () => { }; build() { Column() { Text(this.title) .fontSize(16) .fontWeight(FontWeight.Bold) Text(this.message) .padding({ left: 20, right: 20 }) .margin({ top: 12 }) Line().height(1) .backgroundColor(Color.Blue) Row() { Button('取消') .layoutWeight(1) .borderRadius({ bottomRight: 0, topLeft: 0, topRight: 0, bottomLeft:10 }) .type(ButtonType.Normal) .backgroundColor(Color.White) .fontColor(Color.Grey) .onClick(() => { this.cancel(); }) Line().width(1) .backgroundColor(Color.Blue) Button("确认") .layoutWeight(1) .borderRadius({ bottomRight: 10, topLeft: 0, topRight: 0, bottomLeft: 0 }) .type(ButtonType.Normal) .backgroundColor(Color.White) .fontColor(Color.Blue) .onClick(() => { this.confirm(); }) }.margin({ top:20 }) } .padding({ top: 12 }) .alignItems(HorizontalAlign.Center) .backgroundColor(Color.White) } } (三)自动播放设置组件用 Toggle 控制设置,通过PersistentStorage持久化:import { logger } from "../utils/Logger"; import { emitter } from "@kit.BasicServicesKit"; @Component export struct CellularSetting { private navPathStack: NavPathStack = new NavPathStack() // WI-FI自动播放 @StorageLink("wifi_auto_play") wifiAutoPlay: boolean = false; // 3G/4G/5G自动播放 @StorageLink("cellular_auto_play") cellularAutoPlay: boolean = false; aboutToAppear(): void { emitter.on({ eventId: 6 }, () => { this.navPathStack.pop(); }); } build() { Column() { Text('自动播放设置') .fontSize(12) .fontColor(Color.Grey) Row() { Text('3G/4G/5G自动播放') .width('90%') Toggle({ type: ToggleType.Switch, isOn: this.cellularAutoPlay }) .selectedColor($r('app.color.network_status_observer_setting_toggle_selected')) .switchPointColor(Color.White) .onChange((isOn: boolean) => { logger.info('Component status:' + isOn); AppStorage.setOrCreate('cellular_auto_play', isOn); PersistentStorage.persistProp('cellular_auto_play', isOn); }) .width('10') .id('id_network_status_observer_cellular_toggle') }.margin({ top: 10 }) .width('100%') Row() { Text("WI-FI自动播放") .width('90%') Toggle({ type: ToggleType.Switch, isOn: this.wifiAutoPlay }) .selectedColor($r('app.color.network_status_observer_setting_toggle_selected')) .switchPointColor(Color.White) .onChange((isOn: boolean) => { logger.info('Component status:' + isOn); AppStorage.setOrCreate('wifi_auto_play', isOn); PersistentStorage.persistProp('wifi_auto_play', isOn); }) .width('10%') .id('id_network_status_observer_wifi_toggle') } .margin({ top: 10 }) .width('100%') } .alignItems(HorizontalAlign.Start) .width('100%') .height('100%') .padding({ left: 10, right: 10, top: 30 }) .width('100%') .height('100%') } } (四)关键交互流程用户进入视频页,aboutToAppear订阅网络事件、开启监听;切换网络时,handleNetEvent触发,按设置控制视频播放 / 暂停;进入设置页,修改 Toggle 开关,通过PersistentStorage持久化;退出页面,aboutToDisappear关闭监听、释放资源。5.方案成果总结 本次基于鸿蒙 Network Kit 的网络状态监听与视频播放控制方案成效显著。通过单例模式封装网络监听工具类,实现 WiFi 与蜂窝网络状态的实时感知,精准捕捉网络切换、弱网等事件;利用 PersistentStorage 持久化自动播放设置,结合 AppStorage 实现状态即时同步,解决重启重置与页面不同步问题;标准化资源生命周期管理,在页面生命周期内规范监听的创建与销毁,避免实例冲突与资源泄露。最终实现网络状态变化时视频策略的智能适配,提升了用户体验与应用稳定性。
-
1、关键技术难点总结1.1 问题说明问题一:数据丢失困扰员工通过桌面卡片快速打卡,第一次使用时一切正常,卡片记录了"上次打卡时间"。但在后面再次打卡时,却发现显示变成了"暂无记录",仿佛之前的打卡从未发生过。经过分析发现,这是卡片生命周期管理复杂导致的:系统在夜间可能回收了卡片进程,打卡记录随之丢失,卡片重建后无法恢复之前的状态。问题二:位置获取卡顿当他在地下车库点击卡片打卡按钮时,界面一直停留在"定位中…“状态,等待了近一分钟才提示"位置获取失败”,这暴露了位置服务与异步处理的技术挑战:getCurrentLocation异步回调的时机难以预测,弱信号环境下的位置精度验证影响打卡有效性,而UI状态与异步数据获取的同步也成为了关键问题。1.2 原因分析数据丢失原因鸿蒙卡片基于FormExtensionAbility运行,这是一个独立于主应用的轻量级进程。当系统内存紧张或用户长时间未使用时,系统会主动回收卡片进程以释放资源。更复杂的是,鸿蒙允许用户同时添加多个相同卡片实例,但每个实例都有独立的空间,缺乏跨实例的数据同步机制。位置服务问题原因在地下车库遇到的位置获取延迟问题涉及多个技术层面。首先getCurrentLocation方法采用异步回调机制,但在地下车库等弱信号环境下,GPS信号微弱,这个过程可能耗时数十秒甚至失败。更关键的是,位置数据的获取涉及到经纬度转换、地址解析等多个步骤,每个步骤都可能因为网络问题而延迟。2、解决思路2.1 架构设计思路采用分层架构设计,将功能模块化:UI层:卡片展示层,负责用户交互业务层:打卡逻辑管理,状态管理服务层:位置服务、网络服务封装数据层:本地存储、配置管理2.2 技术选型思路使用LocalStorage进行卡片状态管理,preferences数据持久化采用Promise/async-await处理异步操作通过封装工具类提高代码复用性实现统一的日志管理和错误处理3、解决方案3.1 卡片配置与生命周期管理步骤1:配置卡片基础信息// form_config.json { "forms": [ { "name": "widget", "displayName": "$string:widget_display_name", "description": "$string:widget_desc", "src": "./ets/widget/pages/WidgetCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDynamic": true, "isDefault": true, "updateEnabled": false, "scheduledUpdateTime": "10:30", "updateDuration": 1, "defaultDimension": "2*4", "supportDimensions": ["2*4"] } ] } 步骤2:实现FormExtensionAbility// EntryFormAbility.ets import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit'; import { Want } from '@kit.AbilityKit'; import { BusinessError as BasicServicesError } from '@kit.BasicServicesKit'; import { ClockInManager } from '../widget/pages/WidgetCardManager'; import { StorageUtils } from '../utils/StorageUtils'; const MINUTE: number = 10; export class FormData { // 每一张卡片创建时都会被分配一个唯一的id formId: string = ''; lastClockTime: string = ''; lastAddress: string | undefined = ''; } export default class EntryFormAbility extends FormExtensionAbility { onAddForm(want: Want) { // Called to return a FormBindingData object. let storageUtils = new StorageUtils(this.context) let formData = new FormData() formData.formId = want.parameters!['ohos.extra.param.key.form_identity'].toString(); // 加载上次打卡记录,实际开发中还需根据上班时间处理更加详细 let lastClockTime = storageUtils.loadDataSync('lastClockTime') let lastAddress = storageUtils.loadDataSync('lastAddress') formData.lastClockTime = lastClockTime && lastClockTime.length > 0 ? lastClockTime : '暂无记录' formData.lastAddress = lastAddress && lastAddress.length > 0 ? lastAddress : '尚未打卡' console.info("call onAddForm: " + formData.lastClockTime + '-' + storageUtils.loadDataSync('lastAddress')) return formBindingData.createFormBindingData(formData); } onCastToNormalForm(formId: string) { // Called when the form provider is notified that a temporary form is successfully // converted to a normal form. } onUpdateForm(formId: string) { try { // 设置过10分钟后更新卡片内容 formProvider.setFormNextRefreshTime(formId, MINUTE, (err: BasicServicesError) => { if (err) { console.info(`Failed to setFormNextRefreshTime. Code: ${err.code}, message: ${err.message}`); return; } else { console.info('Succeeded in setFormNextRefreshTiming.'); } }); } catch (err) { console.info(`Failed to setFormNextRefreshTime. Code: ${(err as BasicServicesError).code}, message: ${(err as BasicServicesError).message}`); } } onFormEvent(formId: string, message: string) { // Called when a specified message event defined by the form provider is triggered. // 接收到卡片通过message事件传递的数据 let storageUtils = new StorageUtils(this.context) let formData = new FormData() formData.formId = formId formData.lastClockTime = JSON.parse(message)['lastClockTime'] ClockInManager.clockIn().then((data) => { console.info('clock data: ' + JSON.stringify(data)) console.info('clock lastAddress: ' + data.location?.address) console.info('clock lastClockTime: ' + formData.lastClockTime) formData.lastAddress = data.location?.address storageUtils.saveData('lastAddress', data.location?.address) storageUtils.saveData('lastClockTime', JSON.parse(message)['lastClockTime']) let formInfo: formBindingData.FormBindingData = formBindingData.createFormBindingData(formData) // 返回数据给对应的卡片 formProvider.updateForm(formId, formInfo) }); } onRemoveForm(formId: string) { // Called to notify the form provider that a specified form has been destroyed. } onAcquireFormState(want: Want) { // Called to return a {@link FormState} object. return formInfo.FormState.READY; } } 3.2 卡片UI实现与状态管理步骤3:创建卡片主界面// WidgetCard.ets import { LocationData } from '../../utils/LocationUtil'; let storageUpdate = new LocalStorage(); @Entry(storageUpdate) @Component struct LocationCard { // 接收onAddForm中返回的卡片Id @LocalStorageProp("formId") formId: string = "xxx" @LocalStorageProp("location") location: LocationData | null = null @LocalStorageProp("lastAddress") lastAddress: string = '尚未打卡' @LocalStorageProp("lastClockTime") lastClockTime: string = '暂无记录'; @State buttonColor: Color = Color.Blue; aboutToAppear(): void { } build() { Column() { // 标题 Text('打卡点: ' + this.lastAddress) .fontSize(14) .fontColor(Color.Black) .margin({ top: 5 }); Text(`上次打卡: ${this.lastClockTime}`) .fontSize(14) .fontColor(Color.Black) .margin({ bottom: 5 }) Button('打卡') .width(80) .height(80) .backgroundColor((this.lastAddress && this.lastAddress != '尚未打卡') ? Color.Green : Color.Blue) .fontColor(Color.White) .margin({ top: 5, bottom: 20 }) .onClick(() => { postCardAction(this, { action: 'message', // 固定为message类型 params: { lastClockTime: new Date().toLocaleTimeString() } }); }) .opacity(0.7) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } 3.3 位置服务封装步骤4:实现位置获取工具类// LocationUtil.ets import { BusinessError } from '@ohos.base'; import { geoLocationManager } from '@kit.LocationKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; export interface LocationData { latitude: number; longitude: number; address?: string } export class LocationUtil { // 获取经纬度 public async getLocation(): Promise<LocationData> { let locationData: LocationData = { 'latitude': 0, 'longitude': 0 } try { let location: geoLocationManager.Location = await geoLocationManager.getCurrentLocation(); if (location) { let longitude = location.longitude let latitude = location.latitude await this.convertLatToPosition(longitude, latitude, locationData) } } catch (err) { console.info('getCurrentLocation error') hilog.error(0x000, 'testTag', 'errCode:' + JSON.stringify(err)); } return locationData; } // 将经纬度转换为地理位置 async convertLatToPosition(longitude: number, latitude: number, locationData: LocationData) { let reverseGeocodeRequest: geoLocationManager.ReverseGeoCodeRequest = { 'latitude': latitude, 'longitude': longitude, 'maxItems': 1 }; locationData.latitude = latitude locationData.longitude = longitude try { await geoLocationManager.getAddressesFromLocation(reverseGeocodeRequest).then((data) => { locationData.address = data[0].placeName ? data[0].placeName : '' console.info(`convertLatToPosition: ${JSON.stringify(locationData)}`); }).catch((error: BusinessError) => { hilog.error(0x000, 'testTag', 'promise, getAddressesFromLocation: error=' + JSON.stringify(error)); }); } catch (err) { hilog.error(0x000, 'testTag', 'errCode:' + JSON.stringify(err)); } } } 3.4 网络服务封装步骤5:实现API服务类// ApiService.ets import http from '@ohos.net.http'; import { Logger } from './Logger'; export class ApiService { private static readonly BASE_URL = 'https://api.example.com/attendance'; private static readonly TIMEOUT = 30000; // 打卡API调用 static async clockIn(data: string): Promise<ESObject> { //模拟打卡API调用,实际需求改成调用考勤数据API console.info('clockIn:' + data) return {'code': 0, 'message': '打卡成功'} } // 获取打卡记录 static async getClockRecords(userId: string, startDate: string, endDate: string): Promise<ESObject> { const httpRequest = http.createHttp(); const url = `${ApiService.BASE_URL}/records?userId=${userId}&startDate=${startDate}&endDate=${endDate}`; try { const response = await httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: ApiService.TIMEOUT, readTimeout: ApiService.TIMEOUT }); if (response.responseCode === 200) { return JSON.parse(response.result.toString()); } else { throw new Error(`获取记录失败: ${response.responseCode}`); } } catch (error) { Logger.error(`Get records error: ${error.message}`); } finally { httpRequest.destroy(); } } } 3.5 业务逻辑管理步骤6:实现打卡管理器// WidgetCardManager.ets import { LocationData, LocationUtil } from '../../utils/LocationUtil'; import { ApiService } from '../../utils/ApiService'; import { Logger } from '../../utils/Logger'; import { deviceInfo } from '@kit.BasicServicesKit'; export interface ClockInResult { success: boolean; message: string; timestamp?: number; location?: LocationData; } export interface DeviceInfo { deviceId: string; model: string } export class ClockInManager { // 执行打卡操作 static async clockIn(): Promise<ClockInResult> { try { // 1. 获取位置信息 let locationUtil: LocationUtil = new LocationUtil(); let location: LocationData = await locationUtil.getLocation(); // 2. 获取设备信息(可选) const deviceInfo = await ClockInManager.getDeviceInfo(); const data = JSON.stringify({ latitude: location.latitude, longitude: location.longitude, timestamp: Date.now(), deviceId: deviceInfo.deviceId, deviceModel: deviceInfo.model }) // 3. 调用打卡API,实际业务中需要对各种打卡响应状态做处理,以便清晰对打卡结果反馈 const result: ESObject = await ApiService.clockIn(data); return { success: result.code === 0, message: result.message || '打卡成功', timestamp: Date.now(), location: location }; } catch (error) { Logger.error(`Clock in failed: ${error.message}`); return { success: false, message: error.message || '打卡失败' }; } } // 获取设备信息 private static async getDeviceInfo(): Promise<DeviceInfo> { try { return { deviceId: deviceInfo.udid, model: deviceInfo.hardwareModel }; } catch (error) { Logger.warn(`Failed to get device info: ${error.message}`); return { deviceId: 'unknown', model: 'unknown' }; } } } 3.6 数据持久化// StorageUtils.ets 实际业务中根据需求进行持久化操作 import { common } from "@kit.AbilityKit"; import { preferences } from "@kit.ArkData"; import { BusinessError } from "@kit.BasicServicesKit"; export class StorageUtils { private context: common.Context; constructor(context: common.Context) { console.info('context->' + JSON.stringify(context)) this.context = context; } // 读取数据 async loadData(key: string): Promise<string> { try { let pref = await preferences.getPreferences(this.context, 'cardData'); return await pref.get(key, '') as string; } catch (err) { console.error(`Failed to load data. Code: ${err.code}, message: ${err.message}`); return ''; } } loadDataSync(key: string): string { try { let options: preferences.Options = { name: 'cardData' }; let pref = preferences.getPreferencesSync(this.context, options); return pref.getSync(key, '') as string; } catch (err) { console.error(`Failed to load data. Code: ${err.code}, message: ${err.message}`); return ''; } } // 保存数据 async saveData(key: string, value: string | undefined) { try { if (!value) return let pref = await preferences.getPreferences(this.context, 'cardData'); await pref.put(key, value); pref.flush((err: BusinessError) => { if (err) { console.error(`Failed to flush. Code:${err.code}, message:${err.message}`); return; } console.info('Succeeded in flushing.'); }); } catch (err) { console.error(`Failed to save data. Code: ${err.code}, message: ${err.message}`); } } } 3.7 日志管理工具步骤7:统一日志管理// Logger.ets const TAG = 'ClockInAtomicService'; export class Logger { static debug(message: string): void { console.debug(`[${TAG}] ${message}`); } static info(message: string): void { console.info(`[${TAG}] ${message}`); } static warn(message: string): void { console.warn(`[${TAG}] ${message}`); } static error(message: string): void { console.error(`[${TAG}] ${message}`); } } 4、方案成果总结稳定的卡片生命周期管理: 使用preferences实现数据持久化,卡片重建后状态自动恢复,支持多卡片实例独立运行可靠的位置服务集成: 封装位置获取逻辑,提高代码复用性,实现地理位置反向解析完善的网络请求机制: 统一的API调用封装,超时和错误处理机制,网络状态的实时反馈
-
一、关键技术难点总结1.问题说明在鸿蒙应用开发过程中,经常需要实现瀑布流布局来展示商品、图片等内容。传统实现方式存在以下问题:代码重复:每个页面都需要重复编写瀑布流布局逻辑布局固化:Item布局与瀑布流容器强耦合,难以复用维护困难:业务逻辑分散在多个地方,修改成本高类型限制:数据源类型固定,无法适应不同业务场景换 “推荐 / 社区 / 美食” 等分类时,数据更新无平滑过渡,页面易出现短暂空白,影响操作连贯性。2. 原因分析通过对原始代码的分析,发现问题的根源在于:架构设计不合理:瀑布流容器与具体Item布局没有分离数据绑定僵化:使用固定的数据类型,缺乏泛型支持扩展性不足:没有提供灵活的插槽机制来自定义布局状态管理分散:加载状态、分类切换等逻辑与UI渲染混杂3. 解决思路基于以上分析出以下解决方案:组件化封装:将瀑布流的核心功能抽象为独立组件,实现关注点分离插槽机制:通过@BuilderParam提供灵活的布局自定义能力通用数据源:设计支持任意类型的数据源管理类,提高组件通用性事件驱动:通过回调函数实现组件与父页面的通信4. 解决方案(一)通用数据源设计export class BasicDataSource<T> implements IDataSource { private listeners: DataChangeListener[] = []; public dataArray: T[] = []; constructor(dataArray: T[]) { this.dataArray = dataArray; } // 核心方法实现 totalCount(): number { return this.dataArray.length; } getData(index: number): T { return this.dataArray[index]; } // 数据操作接口 addData(newData: T[]): void { const startIndex = this.dataArray.length; this.dataArray = this.dataArray.concat(newData); this.notifyDataAdd(startIndex, newData.length); } replaceData(newData: T[]): void { this.dataArray = newData; this.notifyDataReload(); } } (二)瀑布流组件封装@Component export struct WaterFlowComponent { // 核心数据属性 @ObjectLink @Watch('dataArrayChange') dataArray: ESObject[]; @State data: BasicDataSource<ESObject> = new BasicDataSource([]); // 配置属性 @Prop categories: string[] = ['推荐']; @Prop selectedCategory: string = '推荐'; @Prop isLoading: boolean = false; @Prop columnsTemplate: string = '1fr 1fr'; @Prop columnsGap: number = 8; @Prop rowsGap: number = 12; // 事件回调 onReachEnd?: () => void; // 核心:布局插槽 @BuilderParam itemBuilder: (item: ESObject) => void; aboutToAppear(): void { this.data.addData(this.dataArray); } dataArrayChange() { this.data.replaceData(this.dataArray); } build() { Column() { WaterFlow() { LazyForEach(this.data, (item: ESObject) => { FlowItem() { // 使用插槽构建Item布局 this.itemBuilder(item) } }, (item: ESObject) => item.id.toString()) } .columnsTemplate(this.columnsTemplate) .columnsGap(this.columnsGap) .rowsGap(this.rowsGap) .onReachEnd(() => { this.onReachEnd?.(); }) } } } (三)使用示例默认布局方式 @Builder defaultItemBuilder(item: ItemData) { DefaultShopItem({ item: item }) .onClick(() => { console.log('点击了项目: ' + item.id); }) } // 在组件中使用 WaterFlowComponent({ dataArray: this.dataArray, itemBuilder: this.defaultItemBuilder, onReachEnd: () => this.loadMore() }) 自定义布局方式 @Builder customItemBuilder(item: ItemData) { Column() { Image(item.imageUrl) .width('100%') .height(item.height) .objectFit(ImageFit.Cover) Column({ space: 8 }) { Text(item.text) .fontSize(18) .fontWeight(FontWeight.Bold) Row({ space: 12 }) { Text(`${item.likes}`) Text(`${item.comments}`) } } .padding(8) } .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 8, color: 0x1A000000 }) } 5. 方案成果总结(一)技术收益1.高度可复用性组件可在不同页面、不同业务场景中复用支持多种数据类型的瀑布流展示2.灵活的自定义能力通过@BuilderParam实现完全自定义的Item布局支持动态切换不同的布局样式3.性能优化使用LazyForEach实现列表项懒加载数据源变化时智能更新,避免不必要的重渲染4.开发效率提升减少重复代码编写统一的数据管理和状态维护** (二)扩展性考虑**该组件设计具有良好的扩展性,未来可以轻松支持:更多布局模板(如3列、响应式列数)复杂的交互功能(拖拽排序、动画效果)不同的加载策略(分页加载、虚拟滚动)主题切换和样式定制通过本次组件封装,成功将瀑布流列表的实现标准化、组件化,为后续项目开发提供了可靠的基础组件支撑。
-
在万物互联时代,用户需求正从“人找服务”逐步向“服务找人”转变。HarmonyOS 以用户为中心,依托POI、信标、鸿蒙标签、NFC iTAP等技术打造近场服务能力,将近场服务融入用户日常生活场景,悄然改变众多领域的服务体验。本期近场服务聚焦商超、文旅、餐饮三大行业的典型应用场景,带你感受HarmonyOS近场服务带来的体验提升。一、智慧商超:为商铺装上“智能导购” 在传统商超综合体中,商铺客流大多依赖品牌影响力和区位优势,普通商铺难以有效吸引顾客驻足。 而当商铺部署信标设备后,用户进入信标连接范围即可收到传输信号,通过“小艺建议”获取门店活动、特色服务等推荐,助力商家在用户消费决策前实现精准曝光,显著提升店铺引流能力,为会员转化和成交率带来新增长点。 二、智慧文旅:打造沉浸式游览体验 假期出游高峰时,排队购票导致入园拥堵、景区导览设置不清导致错过打卡点等都会影响游客的游览体验。 近场服务基于POI位置推荐可在游客靠近景区附近时通过小艺建议获取购票服务卡片推荐,一键直达购票页面,比传统线上购票软件减少约50%操作步骤。进入景区游览时,游客也可以基于景区内不同景点的POI点位推荐一键跳转至景区元服务详情页,当前景点讲解、后续景点推荐、游览路线推荐等一目了然,告别盲目寻找和人工问询。 三、智慧餐饮:一碰直达,极速点餐 餐饮门店可在餐桌或入口处设置HarmonyOS标签,用户通过手机“碰一碰”即可快速直达商家元服务页面。 消费者无需排队点单,手机“碰一碰”即可实现会员一键入会、获取优惠套餐、快速点餐等。不仅大大缩短用户操作步骤,提升了用户体验,也帮助商家大幅提升会员转化与订单效率,实现用户与商家的双赢。 HarmonyOS近场服务在以上行业应用场景中展示了强大的适配性和创新价值。除上述典型案例场景之外,还广泛应用在智慧办公、运动健康、本地生活、政务民生等领域。欢迎开发者点击下方链接了解并接入使用,与HarmonyOS一起共建共享鸿蒙新世界! 👉 点击了解更多并申请接入:申请开通权限-近场服务 - 华为HarmonyOS开发者 (huawei.com) AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。 如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
-
对H5页面占比高的APP而言,“加载慢”是用户体验的“头号杀手”——转圈的加载动画、迟迟不显示的内容,很容易让用户直接退出。为解决这一痛点,AppGallery Connect推出高性能Web容器组件FastWeb,专为H5页面提速而生,帮开发者搞定H5优化,让用户告别“加载卡顿”烦恼,体验更丝滑。一、先搞懂:什么是FastWeb组件?FastWeb是基于OpenHarmony开发的“高性能Web容器”,适用于对H5页面有性能优化需求(加载提速)的场景。像电商APP的商品详情页、资讯新闻列表页、工具类功能操作页等,只要是以H5形式呈现且对页面性能优化有诉求,希望提升加载速度,FastWeb都能派上用场。它聚焦网络大资源的“提速”核心,而非复杂业务逻辑的处理,旨在帮助大家用轻量化开发实现加载优化。二、两种使用方式:按需选择,灵活配置考虑到不同APP的H5开发现状,FastWeb提供两种灵活方案,无论全面改造还是增量式“迭代开发”,都带来了不错的提升效果。实验数据显示,某APP首次打开且无缓存时,直接加载Web页面需5413.58ms,多次打开有缓存时仍需1345.93ms,这是因为该方式要在页面加载时才拉起渲染进程、发起资源请求,额外增加了加载耗时;而使用FastWeb组件后,首次打开(无缓存)加载页面加载时间缩短49.9%;多次打开(有缓存)页面加载时间缩短39.7%。具体数据如下: 方式一:全面改造,解锁全能力若想彻底发挥FastWeb的优化实力,即便H5已封装过Web容器,也能通过此方式“全方位提速”。它会调用预启动、预渲染、预编译JavaScript生成字节码缓存、离线资源拦截注入四大能力,从“提前准备”到“资源复用”拉满效率。操作很简单:APP启动时(或合适时机)创建空的ArkWeb组件“预热”,展示H5页面时直接挂载即可。需注意删除原有Web容器,将属性和事件写入FastWeb暴露对象,适合有调整空间的团队。方式二:增量式“迭代开发”,快速提效如果已经将H5页面封装成Web容器,并希望在不修改原页面的基础上进行优化,你可以通过FastWeb的预编译JavaScript生成字节码缓存、离线资源拦截注入两大能力,实现提速。操作逻辑同上:提前创建空ArkWeb组件,可以在App启动时创建,或者其他合适的页面创建。展示H5时直接用原有页面,无需额外调整。适合追求“低成本快速优化”的团队,兼顾效果与业务稳定性。三、实用建议:避坑指南,用得更顺手想让FastWeb稳定发挥提速效果,这几个细节要注意:FastWeb组件的核心优势在于网络大资源的预加载能力,而非复杂业务逻辑处理,建议优先用于首页H5、高频核心页等“优化关键路径”,能让提速效果更突出。若应用涉及桥接功能需求,优先选方式二,避免改动原有容器,确保通信稳定的同时,不影响加载速度提升。创建FastWeb组件将占用内存(每个FastWeb组件大约200MB)和计算资源,建议避免一次性创建大量FastWeb组件,按页面访问频率合理规划,避免出现“为了快而牺牲流畅”的情况。对H5多的APP来说,FastWeb不是“可选优化项”,而是“刚需组件”。它无需复杂适配,两种方式覆盖不同开发场景。若你正为H5加载慢头疼,不妨试试FastWeb——让用户告别等待,让APP体验再上台阶。AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。 如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
上滑加载中
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签