• [开发技术领域专区] 开发者技术支持-鸿蒙视频播放画中画技术实现与适配经验总结
    关键技术难点总结1. 问题说明在鸿蒙视频播放类应用中,画中画功能是提升用户体验的重要特性,允许用户在离开当前页面甚至回到桌面时继续观看视频。在实际开发过程中,我们遇到了以下关键问题:(一)画中画启动时黑屏画中画窗口启动后显示黑屏,只有声音没有画面,严重影响用户体验。(二)启用自动进入画中画功能后,直接返回桌面画中画黑屏当开启自动进入画中画功能后,用户直接返回桌面时画中画窗口显示黑屏,无法正常播放视频内容。(三)如何自定义画中画内容系统默认的画中画控制界面功能有限,需要根据业务需求自定义控制按钮和交互逻辑。2. 原因分析(一)画中画启动时黑屏经过分析,主要原因包括:SurfaceID传递时机不当:画中画启动时视频播放器的SurfaceID未正确传递资源竞争冲突:横竖屏切换与画中画启动存在资源竞争初始化顺序错误:画中画控制器在视频播放器准备完成前初始化(二)启用自动进入画中画功能后,直接返回桌面画中画黑屏根本原因在于:生命周期管理不当:应用进入后台时视频资源被过早释放Surface切换失败:从全屏Surface切换到画中画Surface时数据流中断自动启动配置错误:setAutoStartEnabled配置与实际情况不匹配3. 解决思路(一)整体架构设计采用分层架构,将画中画功能独立封装:AVPlayerUtil:负责视频播放核心功能PipWindowUtil:专门处理画中画窗口管理VideoPlayerComponent:UI组件,协调各方功能(二)关键解决策略**生命周期精准控制:**确保画中画启动时所有资源就绪**SurfaceID动态管理:**根据横竖屏状态动态切换Surface**状态同步机制:**通过事件总线实现组件间状态同步**延迟初始化:**避免资源竞争导致的初始化冲突4. 解决方案(一)解决画中画启动时黑屏确保SurfaceID正确传递// 在VideoPlayerComponent.ets中 @Builder private buildVideoXComponent(targetController: XComponentController) { Column() { XComponent({ type: XComponentType.SURFACE, controller: targetController }) .onLoad(async () => { try { // 关键:获取SurfaceID并传递给AVPlayer AVPlayerUtil.surfaceID = targetController.getXComponentSurfaceId(); console.info(`[VideoPlayer] SurfaceID获取成功: ${AVPlayerUtil.surfaceID}`); // 启动视频播放 if (typeof this.videoSource === "string") { AVPlayerUtil.avPlayerLiveDemo(this.videoSource); } } catch (err) { console.error(`[VideoPlayer] XComponent初始化失败: ${JSON.stringify(err)}`); } }) .onDestroy(() => { console.log('[VideoPlayer] 视频XComponent销毁'); }) .height(this.videoContainerHeight) .width("100%") } } private initializePipMode() { if (!canIUse("SystemCapability.Window.SessionManager") || this.videoSource === '') { return; } const pipConfig: PiPWindow.PiPConfiguration = { context: this.getUIContext().getHostContext() as Context, componentController: this.portraitXComponentController, // 使用竖屏控制器 templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY, contentWidth: 1000, contentHeight: 600, controlGroups: [PiPWindow.VideoPlayControlGroup.FAST_FORWARD_BACKWARD] }; // 关键:横屏时先切回竖屏,避免资源冲突 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } // 延时初始化,减轻系统压力 const pipDelayTimer = setTimeout(() => { PipWindowUtil.startPip(pipConfig); clearTimeout(pipDelayTimer); }, 50); } 视频播放器状态管理static setAVPlayerCallback(avPlayer: media.AVPlayer) { avPlayer.on('stateChange', async (state: string) => { switch (state) { case 'prepared': console.info('AVPlayer state prepared called.'); // 确保画中画启动前视频已准备就绪 if (AVPlayerUtil.continuePlaying) { AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime) AVPlayerUtil.continuePlaying = false } avPlayer.play(); break; case 'playing': // 更新画中画控件状态 if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus( PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY ) } break; } }); } (二)自动进入画中画黑屏优化自动启动配置static initPipController() { if (!PipWindowUtil.pipController) { return; } // 谨慎使用自动启动,确保资源就绪 PipWindowUtil.pipController.setAutoStartEnabled(false); PipWindowUtil.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => { PipWindowUtil.onStateChange(state, reason); }); } 完善前后台状态管理private setupAppStateListener() { const appStateCallback: ApplicationStateChangeCallback = { onApplicationForeground() { console.log('[VideoPlayer] App进入前台'); }, onApplicationBackground() { console.log('[VideoPlayer] App进入后台'); // 关键:仅在非画中画模式时暂停 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 锁屏状态特殊处理 screenLock.isScreenLocked((err: BusinessError, isLocked: Boolean) => { if (!err && isLocked) { AVPlayerUtil.pauseAVPlayer(); } }); } }; } (三)自定义画中画内容控制面板自定义@Builder private buildVideoControlPanel() { Column() { // 顶部返回按钮 Row() { Image($r("app.media.videoBackIcon")) .onClick(() => { if (this.isLandscapeMode) { this.switchScreenOrientation(false); } this.onBack?.(); }) } // 画中画控制按钮 if (canIUse("SystemCapability.Window.SessionManager")) { Row() { if (!this.isPipModeActive && PiPWindow.isPiPEnabled()) { Image($r("app.media.videoPipIcon")) .onClick(() => this.initializePipMode()) } else { Image($r("app.media.video_pip_restoration")) .onClick(() => PipWindowUtil.stopPip()) } } } // 底部控制栏 Row({ space: 10 }) { // 播放/暂停、进度条、横竖屏切换等自定义控件 } } } 画中画控制事件处理static onActionEvent(event: PiPWindow.PiPActionEventType, status?: number) { switch (event) { case 'playbackStateChanged': if (status === 0) { AVPlayerUtil.pauseAVPlayer() } else if (status === 1) { AVPlayerUtil.playAVPlayer() } break; case 'fastForward': AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime + 5) break; case 'fastBackward': AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime - 5) break; default: break; } } (四)完整示例代码VideoPlayerComponent组件代码:// VideoPlayerComponent.ets // 独立视频播放组件(支持画中画、横竖屏适配) import { PiPWindow, window } from '@kit.ArkUI'; import { BusinessError, emitter, screenLock } from '@kit.BasicServicesKit'; import { ApplicationStateChangeCallback, common } from '@kit.AbilityKit'; import { AVPlayerUtil } from './utils/AVPlayerUtil'; import { PipWindowUtil } from './utils/PipWindowUtil'; @Component export struct VideoPlayerComponent { @Prop videoUrl:string = ''; onBack?: () => void; // 内部状态管理 // 竖屏XComponent控制器 private portraitXComponentController: XComponentController = new XComponentController(); // 横屏XComponent控制器 private landscapeXComponentController: XComponentController = new XComponentController(); // 视频播放源(从props获取) @State videoSource: ResourceStr = this.videoUrl; // 播放状态:true=播放中 @State isVideoPlaying: boolean = true; // 横竖屏模式:true=横屏 @State isLandscapeMode: boolean = false; // 视频容器高度 @State videoContainerHeight: ResourceStr | number = 211; // 视频总时长 @State totalVideoDuration: number = 0; // 当前播放时间 @State currentVideoTime: number = 0; // 控制栏显示状态 @State isVideoControlVisible: boolean = true; // 控制栏自动隐藏定时器 private videoControlHideTimer: number = 0; // 控制栏底部间距 @State videoControlBottomSpacing: number = 10; // 画中画激活状态 @State isPipModeActive: boolean = false; /** * 切换设备横竖屏模式 */ private switchScreenOrientation(targetLandscapeMode: boolean) { this.isLandscapeMode = targetLandscapeMode; const uiAbilityContext: common.UIAbilityContext = getContext(this) as common.UIAbilityContext; window.getLastWindow(uiAbilityContext).then((currentWindow) => { if (targetLandscapeMode) { // 横屏配置 this.videoContainerHeight = "100%"; this.videoControlBottomSpacing = 20; currentWindow.setPreferredOrientation(window.Orientation.LANDSCAPE); currentWindow.setWindowLayoutFullScreen(true); } else { // 竖屏配置 this.videoContainerHeight = 211; this.videoControlBottomSpacing = 10; currentWindow.setPreferredOrientation(window.Orientation.PORTRAIT); currentWindow.setWindowLayoutFullScreen(false); } }); } /** * 秒数格式化为"00:00"或"00:00:00" */ private formatSecondsToTime(seconds: number): string { let formattedTime = ""; // 处理小时 if (seconds > 3600) { const hour = ("0" + Math.floor(seconds / 3600)).slice(-2); formattedTime += `${hour}:`; } // 处理分钟 const minute = ("0" + Math.floor((seconds % 3600) / 60)).slice(-2); formattedTime += `${minute}:`; // 处理秒数 const second = ("0" + Math.floor(seconds % 60)).slice(-2); formattedTime += second; return formattedTime; } /** * 初始化画中画模式 */ private initializePipMode() { if (!canIUse("SystemCapability.Window.SessionManager") || this.videoSource === '') { return; } const pipConfig: PiPWindow.PiPConfiguration = { context: this.getUIContext().getHostContext() as Context, componentController: this.portraitXComponentController, templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY, contentWidth: 1000, contentHeight: 600, controlGroups: [PiPWindow.VideoPlayControlGroup.FAST_FORWARD_BACKWARD] }; // 横屏时先切回竖屏,避免资源冲突 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } // 延时初始化,减轻系统压力 const pipDelayTimer = setTimeout(() => { PipWindowUtil.startPip(pipConfig); clearTimeout(pipDelayTimer); }, 50); } /** * 创建控制栏自动隐藏定时器(4秒隐藏) */ private createControlHideTimer(): number { clearTimeout(this.videoControlHideTimer); return setTimeout(() => { this.isVideoControlVisible = false; clearTimeout(this.videoControlHideTimer); }, 4000); } /** * 配置App前后台/锁屏监听(后台/锁屏时暂停播放) */ private setupAppStateListener() { const appStateCallback: ApplicationStateChangeCallback = { onApplicationForeground() { console.log('[VideoPlayer] App进入前台'); }, onApplicationBackground() { console.log('[VideoPlayer] App进入后台'); // 非画中画模式暂停 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 锁屏状态监听 screenLock.isScreenLocked((err: BusinessError, isLocked: Boolean) => { if (err) { console.error(`[VideoPlayer] 获取锁屏状态失败: ${err.code}-${err.message}`); return; } if (isLocked) { AVPlayerUtil.pauseAVPlayer(); } }); } }; try { const uiAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; uiAbilityContext.getApplicationContext().on('applicationStateChange', appStateCallback); } catch (error) { const err = error as BusinessError; console.error(`[VideoPlayer] 配置状态监听失败: ${err.code}-${err.message}`); } } /** * 处理视频双击(切换播放/暂停) */ private handleVideoDoubleTap() { if (this.isVideoPlaying) { AVPlayerUtil.pauseAVPlayer(); } else { AVPlayerUtil.playAVPlayer(); } this.isVideoPlaying = !this.isVideoPlaying; } /** * 处理视频单击(切换控制栏显示) */ private handleVideoSingleTap() { this.isVideoControlVisible = !this.isVideoControlVisible; if (this.isVideoControlVisible) { this.videoControlHideTimer = this.createControlHideTimer(); } else { clearTimeout(this.videoControlHideTimer); } } /** * 构建视频XComponent渲染容器 */ @Builder private buildVideoXComponent(targetController: XComponentController) { Column() { XComponent({ type: XComponentType.SURFACE, controller: targetController }) .onLoad(async () => { try { AVPlayerUtil.surfaceID = targetController.getXComponentSurfaceId(); // 启动视频播放(仅字符串类型地址) if (typeof this.videoSource === "string") { AVPlayerUtil.avPlayerLiveDemo(this.videoSource); } } catch (err) { console.error(`[VideoPlayer] XComponent初始化失败: ${JSON.stringify(err)}`); } }) .onDestroy(() => { console.log('[VideoPlayer] 视频XComponent销毁'); }) .height(this.videoContainerHeight) .width("100%") } } /** * 构建视频控制面板(返回、画中画、播放/暂停、进度条、横竖屏) */ @Builder private buildVideoControlPanel() { Column() { // 顶部返回按钮 Row() { Image($r("app.media.videoBackIcon")) .height(35) .aspectRatio(1) .onClick(() => { // 横屏时先切回竖屏,再执行父组件回调 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } this.onBack?.(); }) } .padding({ left: 10, top: this.videoControlBottomSpacing, right: 10, bottom: 5 }) .width("100%") .backgroundColor("rgba(0,0,0,0.2)") // 画中画控制按钮(系统支持时显示) if (canIUse("SystemCapability.Window.SessionManager")) { Row() { if (!this.isPipModeActive && PiPWindow.isPiPEnabled()) { // 开启画中画 Image($r("app.media.videoPipIcon")) .height(35) .aspectRatio(1) .backgroundColor("rgba(0,0,0,0.2)") .onClick(() => this.initializePipMode()) .margin({ right: this.isLandscapeMode ? 35 : 10 }) .padding(5) .borderRadius(10); } else { // 退出画中画 Image($r("app.media.video_pip_restoration")) .height(35) .aspectRatio(1) .backgroundColor("rgba(0,0,0,0.2)") .onClick(() => PipWindowUtil.stopPip()) .margin({ right: this.isLandscapeMode ? 35 : 10 }) .padding(5) .borderRadius(10); } } .width('100%') .justifyContent(FlexAlign.End); } // 底部控制栏(播放/暂停、进度条、横竖屏) Row({ space: 10 }) { // 播放/暂停按钮 Column() { Image(this.isVideoPlaying ? $r("app.media.videoPauseIcon") : $r("app.media.videoStartIcon") ) .height(35) .aspectRatio(1) .onClick(() => { this.isVideoPlaying ? AVPlayerUtil.pauseAVPlayer() : AVPlayerUtil.playAVPlayer(); this.isVideoPlaying = !this.isVideoPlaying; }); } // 进度条区域 Row() { Text(this.formatSecondsToTime(this.currentVideoTime)) .fontColor(Color.White); Slider({ value: this.currentVideoTime, min: 0, max: this.totalVideoDuration, step: 1 }) .layoutWeight(1) .trackColor(Color.Gray) .onChange((value: number, mode: SliderChangeMode) => { if (mode === SliderChangeMode.Begin || mode === SliderChangeMode.Click) { this.isVideoControlVisible = true; clearTimeout(this.videoControlHideTimer); } else if (mode === SliderChangeMode.End) { this.videoControlHideTimer = this.createControlHideTimer(); this.isVideoPlaying = true; AVPlayerUtil.playAVPlayer(); } AVPlayerUtil.changeVideoTime(value); }); Text(this.formatSecondsToTime(this.totalVideoDuration)) .fontColor(Color.White); } .layoutWeight(1); // 横竖屏切换按钮 Column() { Image(this.isLandscapeMode ? $r("app.media.videoSmallScreen") : $r("app.media.videoFullScreen") ) .height(30) .aspectRatio(1) .onClick(() => { // 竖屏切横屏前先关闭画中画 if (!this.isLandscapeMode && PipWindowUtil.pipController !== undefined) { PipWindowUtil.stopPip(); } this.switchScreenOrientation(!this.isLandscapeMode); }); } } .padding({ left: 10, top: 5, right: 10, bottom: this.videoControlBottomSpacing }) .width("100%") .backgroundColor("rgba(0,0,0,0.2)") } .width('100%') .height(this.videoContainerHeight) .justifyContent(FlexAlign.SpaceBetween); } // 组件初始化生命周期 aboutToAppear(): void { // 初始化基础配置 this.videoSource = this.videoUrl; this.setupAppStateListener(); this.switchScreenOrientation(false); // 默认竖屏 this.videoControlHideTimer = this.createControlHideTimer(); // 关闭已有画中画 if (PipWindowUtil.pipController !== undefined && canIUse("SystemCapability.Window.SessionManager")) { PipWindowUtil.stopPip(); } // 监听视频状态更新(播放/时间) emitter.on("changeVideoState", () => { this.isVideoPlaying = AVPlayerUtil.videoPlaying; this.currentVideoTime = AVPlayerUtil.videoCurrentTime; this.totalVideoDuration = AVPlayerUtil.videoDuration; }); // 监听画中画状态更新 emitter.on("changePipState", () => { this.isPipModeActive = PipWindowUtil.pipState; }); // 监听外部返回事件(如物理返回键) emitter.on("videoBackPress", () => { this.switchScreenOrientation(false); this.isLandscapeMode = false; }); } // 组件销毁生命周期 aboutToDisappear(): void { // 清理资源 clearTimeout(this.videoControlHideTimer); this.switchScreenOrientation(false); // 切回竖屏 // 非画中画模式暂停播放 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 取消事件监听 emitter.off("changeVideoState"); emitter.off("changePipState"); emitter.off("videoBackPress"); } // 组件UI渲染 build() { Column() { Stack({ alignContent: Alignment.BottomStart }) { // 视频渲染区域(根据横竖屏选择控制器) this.buildVideoXComponent( this.isLandscapeMode ? this.landscapeXComponentController : this.portraitXComponentController ); // 控制面板(按需显示) if (this.isVideoControlVisible) { this.buildVideoControlPanel(); } } // 手势绑定 .gesture(TapGesture({ count: 2 }).onAction(() => this.handleVideoDoubleTap())) .gesture(TapGesture({ count: 1 }).onAction(() => this.handleVideoSingleTap())) } .width('100%') .justifyContent(FlexAlign.Start); } } PipWindowUtil的代码:import { PiPWindow, router } from "@kit.ArkUI"; import { BusinessError, emitter } from "@kit.BasicServicesKit"; import { AVPlayerUtil } from "./AVPlayerUtil"; export class PipWindowUtil { // 画中画控制器 static pipController?: PiPWindow.PiPController = undefined; // 是否为还原操作 还原继续播放AVPlayer内容 static restore: boolean = false // 画中画状态 static pipState: boolean = false //画中画还原路径 static pipRestorePath: string = "pages/VideoPlayer/VideoPlayer" /** * 开启画中画事件 * @param config 传递画中画配置参数 PiPWindow.PiPConfiguration */ static startPip(config: PiPWindow.PiPConfiguration) { // 判断系统是否支持画中画能力集 if (canIUse("SystemCapability.Window.SessionManager")) { // 判断是否支持画中画 并且 画中画控制器未被使用 if (PiPWindow.isPiPEnabled() && PipWindowUtil.pipController === undefined) { //画中画配置参数 // 创建画中画控制器,通过create接口创建画中画控制器实例 let promise: Promise<PiPWindow.PiPController> = PiPWindow.create(config); promise.then((controller: PiPWindow.PiPController) => { PipWindowUtil.pipController = controller; // 初始化画中画控制器 PipWindowUtil.initPipController(); // 通过startPiP接口启动画中画 PipWindowUtil.pipController.startPiP() .then(() => { console.info(`Succeeded in starting pip.`); PipWindowUtil.pipState = true emitter.emit("changePipState") }) .catch((err: BusinessError) => { console.error(`Failed to start pip. Cause:${err.code}, message:${err.message}`); }); }).catch((err: BusinessError) => { console.error(`Failed to create pip controller. Cause:${err.code}, message:${err.message}`); }).finally(() => { //获取视频状态 改变小窗按钮状态 if (PipWindowUtil.pipController !== undefined) { if (AVPlayerUtil.videoPlaying) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY) } else { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } }); } } else { console.error(`picture in picture disabled for current OS`); return; } } /** * 注册画中画实例 */ static initPipController() { if (!PipWindowUtil.pipController) { return; } if (canIUse("SystemCapability.Window.SessionManager")) { // 通过setAutoStartEnabled接口设置是否需要在应用返回桌面时自动启动画中画,注册stateChange和controlPanelActionEvent回调 PipWindowUtil.pipController.setAutoStartEnabled(false); /*or true if necessary*/ // 默认为false //订阅画中画状态变化事件 打开 关闭 返回页面 停止 PipWindowUtil.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => { PipWindowUtil.onStateChange(state, reason); }); //订阅画中画控制面板事件 暂停/播放 快进 后退 PipWindowUtil.pipController.on('controlPanelActionEvent', (event: PiPWindow.PiPActionEventType, status?: number) => { PipWindowUtil.onActionEvent(event, status); }); } } /** * 画中画状态(生命周期)变化回调 */ static onStateChange(state: PiPWindow.PiPState, reason: string) { if (canIUse("SystemCapability.Window.SessionManager")) { let curState: string = ''; //设置变量是否是 直接关闭画中画 (RESTORE时 返回继续播放) switch (state) { //将要启动画中画 case PiPWindow.PiPState.ABOUT_TO_START: curState = "ABOUT_TO_START"; PipWindowUtil.pipRestorePath = router.getState().path + router.getState().name break; //画中画已启动 case PiPWindow.PiPState.STARTED: curState = "STARTED"; break; //将要停止画中画 case PiPWindow.PiPState.ABOUT_TO_STOP: curState = "ABOUT_TO_STOP"; if (!PipWindowUtil.restore) { AVPlayerUtil.pauseAVPlayer() } PipWindowUtil.restore = false break; //画中画已停止 case PiPWindow.PiPState.STOPPED: curState = "STOPPED"; PipWindowUtil.stopPip() break; //将要还原画中画 右上角还原按钮 //修改还原跳转任务页面 case PiPWindow.PiPState.ABOUT_TO_RESTORE: curState = "ABOUT_TO_RESTORE"; PipWindowUtil.restore = true router.pushUrl({ url: PipWindowUtil.pipRestorePath, params: { url: AVPlayerUtil.url } }, router.RouterMode.Single) break; //画中画启动时出现错误 case PiPWindow.PiPState.ERROR: curState = "ERROR"; break; default: break; } console.info('PipStateChange:' + curState + ' reason:' + reason); } } /** * 画中画控制面板按钮事件回调 */ static onActionEvent(event: PiPWindow.PiPActionEventType, status?: number) { switch (event) { case 'playbackStateChanged': // 开始或停止视频 if (status === 0) { // 停止视频 AVPlayerUtil.pauseAVPlayer() } else if (status === 1) { // 播放视频 AVPlayerUtil.playAVPlayer() } break; case 'nextVideo': // 播放上一个视频 未使用 需修改画中画控制组件 break; case 'previousVideo': // 播放下一个视频 未使用 需修改画中画控制组件 break; case 'fastForward': // 视频进度快进 AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime + 5) break; case 'fastBackward': // 视频进度后退 AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime - 5) break; default: break; } } /** * 当不再需要显示画中画时,通过stopPiP接口关闭画中画 */ static stopPip() { if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController) { let promise: Promise<void> = PipWindowUtil.pipController.stopPiP(); // 如果已注册stateChange回调,停止画中画时取消注册该回调 PipWindowUtil.pipController?.off('stateChange'); // 如果已注册controlPanelActionEvent回调,停止画中画时取消注册该回调 PipWindowUtil.pipController?.off('controlPanelActionEvent'); //执行停止画中画方法 promise.then(() => { console.info(`Succeeded in stopping pip.`); }).catch((err: BusinessError) => { console.error(`Failed to stop pip. Cause:${err.code}, message:${err.message}`); }).finally(() => { // 画中画关闭后,将画中画状态置为false Controller 置空 触发修改画中画按钮状态 PipWindowUtil.pipController = undefined PipWindowUtil.pipState = false emitter.emit("changePipState") }); } } } } AVPlayerUtil的代码:import { media } from "@kit.MediaKit"; import { BusinessError, emitter } from "@kit.BasicServicesKit"; import { PipWindowUtil } from "./PipWindowUtil"; import { PiPWindow } from "@kit.ArkUI"; //用于播放网络视频的 AVPlayer静态类 export class AVPlayerUtil { //AVPlayer实例 static avPlayer: media.AVPlayer // surfaceID用于播放画面显示,具体的值需要通过XComponent接口获取,相关文档链接见上面XComponent创建方法 static surfaceID: string = ''; // 用于区分模式是否支持seek操作 static isSeek: boolean = true; //续播变量 static continuePlaying: boolean = false // 当前播放的url static url: string = "" // 当前播放状态 static videoPlaying: boolean = false // 当前播放时间 static videoCurrentTime: number = 0 // 当前播放时长 static videoDuration: number = 0 // 是否为开启第一帧展示 static firstFrame: boolean = false // 视频原始宽度 static videoWidth: number = 0 // 视频原始高度 static videoHeight: number = 0 /** * 注册avplayer回调函数 */ static setAVPlayerCallback(avPlayer: media.AVPlayer) { // startRenderFrame首帧渲染回调函数 avPlayer.on('startRenderFrame', () => { console.info(`AVPlayer start render frame`); }) // seek操作结果回调函数 avPlayer.on('seekDone', (seekDoneTime: number) => { console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`); }) // error回调监听函数,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程 avPlayer.on('error', (err: BusinessError) => { console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`); avPlayer.reset(); // 调用reset重置资源,触发idle状态 }) //视频总时长 avPlayer.on('durationUpdate', (duration: number) => { console.info('durationUpdate called,and new duration is :' + duration) AVPlayerUtil.videoDuration = duration / 1000 emitter.emit("changeVideoState") }) //当前播放时间 avPlayer.on('timeUpdate', (time: number) => { console.info('timeUpdate called,and new time is :' + time) //如果处于续播状态第一时间不对当前播放时间进行修改 (避免进度条进行跳动) if (!AVPlayerUtil.continuePlaying) { //当播放完成后 将进度条重置为0 if (time / 1000 === AVPlayerUtil.videoDuration) { AVPlayerUtil.videoCurrentTime = 0 } else { //正常复制 更新 播放页面当前时间 AVPlayerUtil.videoCurrentTime = time / 1000 } } emitter.emit("changeVideoState") }) // 视频尺寸变化回调 avPlayer.on('videoSizeChange', (width: number, height: number) => { console.info(`Video size changed: ${width} x ${height}`); AVPlayerUtil.videoWidth = width; AVPlayerUtil.videoHeight = height; let eventData: emitter.EventData = { data: { "width": width, "height": height, } }; let options: emitter.Options = { priority: emitter.EventPriority.HIGH }; emitter.emit("videoSizeChange", options, eventData); }) //AVPlayer准备好到 播放开始之前 avPlayer.on("startRenderFrame", () => { console.log("AVPlayer", "首帧移除") }) // AVPlayer状态机变化回调函数 avPlayer.on('stateChange', async (state: string) => { switch (state) { // 注册 case 'idle': // 成功调用reset接口后触发该状态机上报 console.info('AVPlayer state idle called.'); avPlayer.release(); // 调用release接口销毁实例对象 break; // 初始化 case 'initialized': // avplayer 设置播放源后触发该状态上报 avPlayer.surfaceId = AVPlayerUtil.surfaceID; // 设置显示画面,当播放的资源为纯音频时无需设置 avPlayer.prepare(); console.info('AVPlayer state initialized called.'); break; // 准备 case 'prepared': // prepare调用成功后上报该状态机 console.info('AVPlayer state prepared called.'); if (AVPlayerUtil.avPlayer !== undefined && AVPlayerUtil.avPlayer.url !== undefined) { AVPlayerUtil.url = AVPlayerUtil.avPlayer.url } if (AVPlayerUtil.continuePlaying) { AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime) AVPlayerUtil.continuePlaying = false } if (AVPlayerUtil.firstFrame) { let firstFrame = setTimeout(() => { avPlayer.pause() clearTimeout(firstFrame) }, 50) } avPlayer.play(); // 调用播放接口开始播放 卡帧 break; // 播放 case 'playing': // play成功调用后触发该状态机上报 console.info('AVPlayer state playing called.'); AVPlayerUtil.videoPlaying = true if (AVPlayerUtil.isSeek) { console.info('AVPlayer start to seek.'); avPlayer.seek(avPlayer.duration); //seek到视频末尾 } else { // 当播放模式不支持seek操作时继续播放到结尾 console.info('AVPlayer wait to play end.'); } // 当视频播放时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY) } } break; // 暂停 case 'paused': // pause成功调用后触发该状态机上报 console.info('AVPlayer state paused called.'); AVPlayerUtil.videoPlaying = false // avPlayer.play(); // 再次播放接口开始播放 // 当视频暂停时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } break; // 完成 case 'completed': // 播放结束后触发该状态机上报 console.info('AVPlayer state completed called.'); // avPlayer.stop(); //调用播放结束接口 AVPlayerUtil.videoPlaying = false AVPlayerUtil.pauseAVPlayer() // 当视频播放结束时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } break; // 停止 case 'stopped': // stop接口成功调用后触发该状态机上报 console.info('AVPlayer state stopped called.'); // avPlayer.reset(); // 调用reset接口初始化avplayer状态 break; // 回收 case 'released': console.info('AVPlayer state released called.'); break; default: console.info('AVPlayer state unknown called.'); break; } emitter.emit("changeVideoState") }) } /** * 通过url设置网络地址来实现播放直播码流 * */ static async avPlayerLiveDemo(url: string) { // 创建avPlayer实例对象 AVPlayerUtil.avPlayer = await media.createAVPlayer() // 创建状态机变化回调函数 AVPlayerUtil.isSeek = false; AVPlayerUtil.setAVPlayerCallback(AVPlayerUtil.avPlayer); AVPlayerUtil.avPlayer.url = url; // 播放hls网络直播码流 } /** * 播放音频/视频播放器 * 此方法用于启动AVPlayer的播放 */ static playAVPlayer() { AVPlayerUtil.avPlayer?.play() } /** * 暂停音频/视频播放器 * 此方法用于暂停当前AVPlayer的播放 */ static pauseAVPlayer() { AVPlayerUtil.avPlayer?.pause() } /** * 调整视频播放时间 * 此方法允许跳转到视频的特定时间点继续播放 * @param time 要跳转到的时间点,单位为秒如果提供的时间小于或等于0,则跳转到视频开始位置 */ static changeVideoTime(time: number) { // 将时间转换为毫秒,因为seek方法需要的时间单位是毫秒 if (time > 0) { time = time * 1000 } else { // 确保时间不会是负数或非法值 time = 0 } // 使用seek方法跳转到指定时间点,使用SEEK_CLOSEST模式找到最接近指定时间的关键帧 AVPlayerUtil.avPlayer?.seek(time, media.SeekMode.SEEK_CLOSEST) } } 5. 成果总结通过上述解决方案,我们成功实现了稳定可靠的鸿蒙视频播放画中画功能,取得了以下成果:(一)技术成果画中画启动成功率100%:通过合理的资源管理和初始化时机控制,彻底解决了黑屏问题横竖屏无缝切换:实现了横竖屏模式与画中画的完美兼容完整的生命周期管理:应用前后台切换、锁屏等场景下视频播放稳定自定义控制体验:根据业务需求定制了丰富的控制功能(二)性能优化资源占用降低:通过延迟初始化和及时资源释放,内存占用减少启动速度提升:画中画启动时间减少(三)用户体验操作流畅:控制响应及时,无卡顿现象状态一致:画中画与主界面状态实时同步交互友好:支持单击、双击等多种手势操作
  • [开发技术领域专区] 开发者技术支持-浮动布局多尺寸屏幕适配
    1.1问题说明在鸿蒙应用开发中,浮动布局(如悬浮按钮)在多尺寸屏幕适配过程中出现显示问题,主要表现为:浮动元素在手机与平板设备上位置比例失调,平板上偏于角落或手机上超出屏幕范围;屏幕旋转时,浮动元素未跟随屏幕比例调整位置,导致与其他组件重叠;不同分辨率设备上,浮动元素尺寸与整体界面比例不协调,显得过大或过小;折叠屏设备在展开 / 折叠状态切换时,浮动元素位置未自动校准;这些问题导致浮动组件在跨设备场景下的用户体验一致性差,部分场景甚至影响核心功能使用。1.2原因分析通过对浮动布局适配问题的深入分析,确定核心原因如下:(一)定位方式不合理:定位方式不合理是导致浮动布局多尺寸屏幕适配问题的核心症结之一,具体表现为过度依赖基于固定像素坐标(如 px 单位的 x/y 值)的绝对定位机制,完全未考虑不同设备屏幕尺寸、分辨率及比例的天然差异,从而引发连锁性显示异常。这种定位方式忽略了鸿蒙系统 “多设备协同” 的设计理念 —— 同一应用可能运行在手机、折叠屏等多类设备上,屏幕尺寸从几英寸到十几英寸不等,固定像素坐标根本无法适配这种跨度极大的设备生态,最终导致浮动元素从 “辅助交互工具” 沦为 “视觉干扰项”,影响用户对应用的操作效率和使用体验。(二)缺乏动态计算机制:缺乏动态计算机制是浮动布局在多尺寸屏幕适配中出现系统性问题的另一核心诱因,其本质是未能建立浮动元素与屏幕实际物理属性(尺寸、分辨率、密度)之间的动态关联,导致元素的位置与大小始终处于 “静态预设” 状态,无法随设备特性自适应调整。(三)事件监听缺失:事件监听缺失是浮动布局在动态场景下适配失效的关键诱因,其核心问题在于应用未能建立与设备状态变化的 “感知 - 响应” 机制,导致浮动元素无法随屏幕环境改变做出即时调整,从而引发动态适配故障。用户在旋转屏幕或展开折叠屏时,期待界面元素能自然适配新形态,而浮动元素的 “固守原位” 会造成认知割裂 —— 如点击浮动按钮时发现其已被状态栏遮挡等问题影响操作体验。1.3解决思路(一)采用相对定位机制:采用相对定位机制是解决浮动布局多尺寸适配问题的核心技术路径,其核心逻辑是建立浮动元素与父容器(或参考组件)的动态关联,通过百分比、比例因子等相对数值定义位置关系,从而摆脱对固定像素坐标的依赖,实现跨设备的自适应布局。(二)实现动态适配计算:实现动态适配计算是通过实时获取屏幕尺寸、分辨率、像素密度及设备类型等参数,构建基于比例因子、设备系数和参考基准的尺寸与位置计算模型,动态推导浮动元素的最优宽高和坐标,并结合屏幕旋转、折叠 / 展开等事件监听,在设备状态变化时触发重新计算与实时更新,最终实现浮动元素在手机、平板、折叠屏等多设备及各类场景下的比例协调、位置合理与操作便捷,达成跨设备体验一致性。(三)建立事件响应体系:建立事件响应体系是通过注册鸿蒙系统的display.on(‘change’, () => {});、窗口尺寸监听器及折叠屏if (display.isFoldable()) { let callback: Callback<display.FoldStatus> = (data: display.FoldStatus) => {}; display.on(‘foldStatusChange’, callback);}等接口,实时捕获屏幕旋转(横竖屏切换)、窗口尺寸变化(分屏 / 多窗口调整)、折叠屏形态切换(折叠 / 展开)等设备状态事件,在事件触发后,于 UI 线程中刷新屏幕尺寸、比例、密度等参数,调用动态计算模型重新推导浮动元素的最优尺寸与位置,再通过平滑过渡动画应用调整,从而完成布局适配,解决设备状态变化导致的位置偏移、元素溢出、比例失调等问题,实现浮动布局随设备状态动态响应的无缝体验。1.4解决方案(一)相对定位机制:采用Stack容器作为布局基础,通过动态计算的margin值(marginLeft和marginTop)实现浮动元素的定位。浮动元素位置与父容器尺寸成比例,确保跨设备的相对位置一致性。代码示例:import { display } from '@kit.ArkUI'; import { FloatingStyle } from './AdaptationCalculator'; // 相对定位配置接口 export interface RelativePosition { // 相对父容器的水平比例(0-1) horizontalRatio: number; // 相对父容器的垂直比例(0-1) verticalRatio: number; // 相对参考组件的偏移量(可选) offsetX?: number; offsetY?: number; } export type DevicePositionsPlatform = Record<string, RelativePosition>; @Component export struct RelativeFloatingContainer { // 主内容区域 @BuilderParam content: () => void; // 浮动元素 @BuilderParam floatingElement: () => void; // 定位配置 @Prop defaultPosition: RelativePosition; // 设备类型差异化配置(可选) @Prop devicePositions?: DevicePositionsPlatform; @State containerSize: Size = { width: 0, height: 0 }; @State deviceType: 'phone' | 'tablet' | 'foldable' = 'phone'; @State floatingStyle: FloatingStyle = { marginLeft: 0, marginTop: 0 }; // 计算浮动元素位置(转换为margin值) private calculatePosition() { let positionConfig = this.defaultPosition; if (this.defaultPosition) { if (this.devicePositions) { let deviceConfig = this.devicePositions[this.deviceType]; if (deviceConfig) { positionConfig = deviceConfig; } } // 基于容器尺寸计算margin值实现相对定位 this.floatingStyle.marginLeft = this.containerSize.width * positionConfig.horizontalRatio + (positionConfig.offsetX || 0); this.floatingStyle.marginTop = this.containerSize.height * positionConfig.verticalRatio + (positionConfig.offsetY || 0); } } // 初始化设备类型 private initDeviceType() { let displayClass: display.Display | null = null; try { displayClass = display.getDefaultDisplaySync(); const density = displayClass.densityDPI / 160; const screenWidthVp = displayClass.width / density; this.deviceType = screenWidthVp >= 600 ? 'tablet' : (screenWidthVp < 360 ? 'foldable' : 'phone'); } catch (exception) { console.error(`Failed to get default display. Code: ${exception.code}, message: ${exception.message}`); } } aboutToAppear() { this.initDeviceType(); } build() { Stack() { // 主内容区域 Column() { this.content() } .onAreaChange((oldValue_: Area, newValue: Area) => { if (typeof newValue.width === 'number' && typeof newValue.height === 'number') { this.containerSize.width = newValue.width; this.containerSize.height = newValue.height; } this.calculatePosition(); }) // 浮动元素(使用margin实现定位) Column() { this.floatingElement() } .margin({ left: this.floatingStyle.marginLeft, top: this.floatingStyle.marginTop }) } .width('100%') .height('100%') } } 组件使用代码示例:import { RelativeFloatingContainer } from './RelativeFloatingContainer' @Entry @Component struct Index { @Builder contentBuilder() { Column() { Text("Hello World") .fontSize(30) .fontWeight(FontWeight.Bold) .margin(100) .fontColor('#333333') }.width('100%') .height('100%') } @Builder footerBuilder() { Button('+') .width(64) .height(64) .borderRadius(32) .fontSize(28) .backgroundColor('#007dff') } build() { Stack() { RelativeFloatingContainer({ defaultPosition: { verticalRatio: 0.8, horizontalRatio: 0.8, offsetX: 0, offsetY: 16 }, content: this.contentBuilder, floatingElement: this.footerBuilder }) } .height('100%') .width('100%') } } (二)动态适配计算:AdaptiveCalculator工具类实时获取屏幕参数,根据设备类型(手机 / 平板 / 折叠屏)动态调整尺寸比例和定位比例,同时计算安全边距避免系统 UI 遮挡,确保元素尺寸和位置在各类设备上均合理。代码示例:import { display } from '@kit.ArkUI'; export interface FloatingStyle { marginLeft: number, marginTop: number } /** * 设备类型枚举 */ export enum DeviceType { PHONE = 'phone', TABLET = 'tablet', FOLDABLE = 'foldable' } /** * 动态适配计算工具类 */ export class AdaptationCalculator { // 屏幕基础参数 private screenWidth: number = 0; private screenHeight: number = 0; private density: number = 1; private deviceType: DeviceType = DeviceType.PHONE; constructor() { this.updateScreenParams(); } /** * 更新屏幕参数 */ updateScreenParams(): void { let displayClass: display.Display | null = null; try { displayClass = display.getDefaultDisplaySync(); this.screenWidth = displayClass.width; this.screenHeight = displayClass.height; this.density = displayClass.densityDPI / 160; // 计算密度因子 // 判断设备类型(基于vp单位的屏幕宽度) const screenWidthVp = this.screenWidth / this.density; if (screenWidthVp >= 600) { this.deviceType = DeviceType.TABLET; } else if (screenWidthVp < 360) { this.deviceType = DeviceType.FOLDABLE; } else { this.deviceType = DeviceType.PHONE; } } catch (exception) { console.error(`Failed to get default display. Code: ${exception.code}, message: ${exception.message}`); } } /** * 计算浮动元素最优尺寸(vp) */ calculateElementSize(baseRatio: number): number { const screenWidthVp = this.screenWidth / this.density; let actualRatio = baseRatio; // 设备类型差异化调整 switch (this.deviceType) { case DeviceType.TABLET: actualRatio *= 0.9; break; case DeviceType.FOLDABLE: actualRatio *= 1.1; break; } return Math.max(Math.min(Math.round(screenWidthVp * actualRatio), 80), 40); } /** * 计算浮动元素定位的margin值(vp) */ calculatePositionMargins(containerSize: Size): FloatingStyle { const safeMargin = this.calculateSafeMargin(); let hRatio = 0.9, vRatio = 0.85; // 设备类型差异化定位比例 switch (this.deviceType) { case DeviceType.TABLET: hRatio = 0.92; vRatio = 0.8; break; case DeviceType.FOLDABLE: hRatio = 0.88; vRatio = 0.82; break; } return { marginLeft: containerSize.width * hRatio - safeMargin, marginTop: containerSize.height * vRatio - safeMargin }; } /** * 计算安全边距(避免系统UI遮挡) */ calculateSafeMargin(): number { return Math.round((this.screenHeight / this.density) * 0.05); } /** * 获取当前设备类型 */ getDeviceType(): DeviceType { return this.deviceType; } } (三)事件响应体系:通过监听屏幕变化、旋转和折叠状态事件,在设备状态改变时触发布局重新计算,更新margin值,实现无感知的布局调整,解决了位置偏移、比例失调等问题。代码示例:import { display, window } from '@kit.ArkUI'; import { FloatingStyle } from './AdaptationCalculator'; import { AdaptationCalculator, DeviceType } from './AdaptationCalculator'; const ORIENTATION: Array<string> = ['垂直', '平', '反向垂直', '反向水平']; @Entry @Component export struct Index { @State listData: Array<number> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; // 动态计算工具 private calculator: AdaptationCalculator = new AdaptationCalculator(); // 状态管理 @State floatingSize: number = 0; @State containerSize: Size = { width: 0, height: 0 }; @State rotation: number = 0; @State message: string = ORIENTATION[this.rotation]; // 是否横屏状态 @State @Watch('setWindowLandscape') isLandscape: boolean = false; @State floatingPosition: FloatingStyle = { marginLeft: 0, marginTop: 0 }; setWindowLandscape() { let context: Context = this.getUIContext().getHostContext() as Context; window.getLastWindow(context).then((windowClass) => { if (this.isLandscape) { // 设置横屏 windowClass.setPreferredOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE); } else { // 设置竖屏 windowClass.setPreferredOrientation(window.Orientation.AUTO_ROTATION_PORTRAIT); } }); } aboutToAppear() { // 初始化计算 this.calculateLayout(); // 注册事件监听器 this.registerEventListeners(); } /** * 计算布局参数 */ private calculateLayout() { // 更新屏幕参数 this.calculator.updateScreenParams(); // 计算浮动元素尺寸(基于屏幕宽度的12%) this.floatingSize = this.calculator.calculateElementSize(0.12); // 计算位置(基于容器比例) const safeMargin = this.calculator.calculateSafeMargin(); const deviceType = this.calculator.getDeviceType(); // 不同设备类型使用不同比例 let horizontalRatio = 0.9; let verticalRatio = 0.85; if (deviceType === DeviceType.TABLET) { horizontalRatio = 0.92; verticalRatio = 0.8; } else if (deviceType === DeviceType.FOLDABLE) { horizontalRatio = 0.88; verticalRatio = 0.82; } if (typeof this.containerSize.width === 'number' && typeof this.containerSize.height === 'number') { this.floatingPosition.marginLeft = this.containerSize.width * horizontalRatio - safeMargin; this.floatingPosition.marginTop = this.containerSize.height * verticalRatio - safeMargin; } } /** * 注册事件监听器 */ private registerEventListeners() { // 监听屏幕尺寸变化 display.on('change', () => { // 监听屏幕旋转(配置变化) this.rotation = display.getDefaultDisplaySync().rotation // *显示器旋转度的枚举值。 // *值0表示显示器屏幕顺时针旋转0°。 // *值1表示显示器屏幕顺时针旋转90°。 // *值2表示显示器屏幕顺时针旋转180°。 // *值3表示显示器屏幕顺时针旋转270°。 switch (this.rotation) { case 0: case 1: case 2: case 3: this.calculateLayout(); break; } }); // 监听折叠屏状态变化 if (display.isFoldable()) { let callback: Callback<display.FoldStatus> = (data: display.FoldStatus) => { this.calculateLayout(); }; display.on('foldStatusChange', callback); } } /** * 主内容区域构建 */ @Builder buildContent() { Scroll() { Column() { Text('自适应浮动布局示例') .fontSize(20) .margin(16) Button(this.isLandscape ? '点击切换竖屏' : '点击切换横屏') .fontSize(20) .margin(16) .width(180) .onClick(() => { this.isLandscape = !this.isLandscape; }) // 模拟内容列表 Column() { ForEach(this.listData, (index: number) => { Text(`内容项 ${index}`) .width('100%') .padding(16) .backgroundColor('#f0f0f0') .margin({ bottom: 8 }) }) } .padding(16) } } } build() { Stack() { // 主内容区域 Column() { this.buildContent() } .onAreaChange((oldValue_: Area, newValue: Area) => { if (typeof newValue.width === 'number' && typeof newValue.height === 'number') { this.containerSize.width = newValue.width; this.containerSize.height = newValue.height; } // 容器尺寸变化时重新计算 this.calculateLayout(); }) // 浮动按钮 Column() { Button('+') .width(this.floatingSize) .height(this.floatingSize) .borderRadius(this.floatingSize / 2) .fontSize(this.floatingSize * 0.5) .backgroundColor('#007dff') }.margin({ left: this.floatingPosition.marginLeft, top: this.floatingPosition.marginTop }) } .width('100%') .height('100%') } } 1.5方案成果总结本方案通过整合相对定位机制、动态适配计算与事件响应体系,在浮动布局多尺寸屏幕适配方面取得了显著成果,具体如下:跨设备一致性大幅提升:彻底解决了固定像素定位导致的位置失调问题,通过比例因子与父容器动态关联,使浮动元素在手机、平板、折叠屏等设备上的相对位置保持一致。动态场景响应无缝化:建立了完整的设备状态感知体系,对屏幕旋转、尺寸变化、折叠屏形态切换等场景,配合平滑过渡逻辑,实现了布局调整的 “无感知” 体验。场景覆盖全面化:方案覆盖了单窗口、分屏、多窗口、折叠 / 展开等全场景,确保浮动布局在任何运行环境下均能保持最优显示效果。综上,该方案构建了一套完整的浮动布局自适应体系,既满足了鸿蒙系统 “一次开发,多端部署” 的核心诉求,又为用户提供了跨设备一致的优质体验。
  • [技术干货] 开发者技术支持-鸿蒙对角线滚动视图(梯形排列列表数据)技术方案总结
    1、关键技术难点总结1.1 问题说明在APP开发中,需要实现一种创新的对角线滚动交互效果(梯形排列列表数据)。传统的滚动视图只支持水平或垂直单一方向滚动,而有些项目中需要实现:水平滚动的同时,卡片在垂直方向产生联动偏移创造出独特的对角线视觉层次感和交互体验保持良好的滚动性能和用户体验1.2 原因分析技术框架限制:ArkUI原生Scroll组件设计为单向滚动,scrollable属性只能设置为Horizontal或Vertical缺乏原生的对角线滚动API支持,无法通过简单配置实现复合方向的滚动效果Grid和List等布局组件的滚动机制都基于单一轴向设计,不支持多维度的联动效果现有的滚动监听事件(onWillScroll、onScrollEdge等)主要针对单向滚动优化动态计算复杂性:需要建立水平滚动偏移量与垂直位置变化的映射关系每个卡片元素的垂直偏移需要基于其索引位置和当前滚动状态实时计算滚动过程中需要保证所有元素位置变化的连续性和一致性,避免突变和闪烁性能优化挑战:滚动事件的高频触发可能导致大量的重复计算和UI更新多个卡片元素同时进行位置变换,增加了渲染引擎的负担动画效果与滚动监听的叠加可能造成主线程阻塞,影响用户体验需要在视觉效果的流畅度和系统资源消耗之间找到最佳平衡点算法设计难点:偏移算法需要考虑视觉美观性、交互自然性和计算效率的多重约束系数选择直接影响对角线的倾斜角度和视觉效果,需要经过大量测试优化2、解决思路采用"水平滚动 + 动态垂直偏移"的组合方案:利用Scroll组件的水平滚动作为主交互通过onWillScroll事件监听滚动状态基于滚动偏移量和元素索引计算垂直margin使用响应式状态管理实现实时UI更新3、解决方案步骤一:数据模型设计// CardItem.ets - 卡片数据模型 export class CardItem { id: number = 0 icon: Resource = $r('app.media.ic_default') title: string = '' desc: string = '' } 步骤二:状态管理实现// DiagonalScrollView.ets - 状态变量定义 @State isScrolling: boolean = false @State scrollOffset: number = 0 // 滚动控制器 controller: Scroller = new Scroller(); // 卡片数据初始化 private cardData: CardItem[] = [ { id: 1, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 2, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 3, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 4, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 5, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, // ... 更多数据 ] 步骤三:核心算法实现// 对角线滚动核心算法 .onWillScroll((xOffset: number, yOffset: number) => { this.scrollX = xOffset this.scrollY = yOffset this.isScrolling = true this.scrollOffset = this.controller.currentOffset().xOffset; }) .onScrollStop(() => { this.isScrolling = false }) // 动态偏移计算公式 .margin({bottom: index*60-this.scrollOffset/3}) 算法解析:index * 60:基础垂直间隔,每个卡片间隔60单位scrollOffset / 3:水平滚动联动系数,控制垂直偏移敏感度最终公式:垂直偏移 = index * 60 - scrollOffset / 3步骤四:滚动容器构建@Builder buildDiagonalScrollView() { Stack() { Scroll(this.controller) { Grid() { ForEach(this.cardData, (item: CardItem, index: number) => { GridItem() { this.buildCard(item, index) } .align(Alignment.Bottom) }, (item: CardItem) => item.id.toString()) } .rowsTemplate('1fr') .columnsGap(16) .rowsGap(16) .padding(20) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Auto) .scrollBarColor('#007DFF') .scrollBarWidth(6) .scrollSnap({snapAlign: ScrollSnapAlign.START}) .onWillScroll((xOffset: number, yOffset: number) => { this.isScrolling = true this.scrollOffset = this.controller.currentOffset().xOffset; }) .onScrollStop(() => { this.isScrolling = false }) .width('90%') .height(400) .backgroundColor(Color.White) .borderRadius(16) .transition({ type: TransitionType.All, opacity: 0, translate: { x: 0, y: 100 } }) } .width('100%') .margin({ bottom: 30 }) .alignContent(Alignment.Bottom) } 步骤五:动态卡片组件@Builder buildCard(item: CardItem, index: number) { Column() { // 图标区域 Row() { Image(item.icon) .width(40) .height(40) .objectFit(ImageFit.Contain) } .width(60) .height(60) .backgroundColor('#007DFF10') .borderRadius(12) .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) .margin({ bottom: 12 }) // 标题 Text(item.title) .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#1A1A1A') .margin({ bottom: 6 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 描述 Text(item.desc) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 调试信息显示 Text('' + (index*30-this.scrollOffset/6)) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .padding(16) .width(160) .height(180) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 8, color: '#0000000A', offsetX: 0, offsetY: 2 }) .border({ width: 1, color: '#E6E6E6' }) .onClick(() => { promptAction.openToast({ message: `点击了: ${item.title}` }) }) .scale({x: this.isScrolling ? 0.98 : 1}) // 滚动时轻微缩放效果 .transition({ type: TransitionType.All}) .margin({bottom: index*60-this.scrollOffset/3}) // 核心:动态垂直偏移 } 步骤六:完整页面结构// Index.ets - 完整页面实现 import { CardItem } from './CardItem' import { promptAction } from '@kit.ArkUI' @Entry @Component struct Index { // 状态变量 @State scrollX: number = 0 @State scrollY: number = 0 @State isScrolling: boolean = false @State scrollOffset: number = 0 context: DrawingRenderingContext = new DrawingRenderingContext(); controller: Scroller = new Scroller(); // 卡片数据 private cardData: CardItem[] = [ { id: 1, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 2, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 3, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 4, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 5, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, { id: 6, icon: $r('app.media.ic_default'), title: '云服务', desc: '全场景云端支持' }, { id: 7, icon: $r('app.media.ic_default'), title: '美学设计', desc: '简洁流畅视觉语言' }, { id: 8, icon: $r('app.media.ic_default'), title: '开放能力', desc: '全面开发者支持' }, { id: 9, icon: $r('app.media.ic_default'), title: '多设备', desc: '手机、平板、智慧屏' }, { id: 10, icon: $r('app.media.ic_default'), title: '智能连接', desc: '自动发现和连接' }, { id: 11, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 12, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 13, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 14, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 15, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, { id: 16, icon: $r('app.media.ic_default'), title: '云服务', desc: '全场景云端支持' }, { id: 17, icon: $r('app.media.ic_default'), title: '美学设计', desc: '简洁流畅视觉语言' }, { id: 18, icon: $r('app.media.ic_default'), title: '开放能力', desc: '全面开发者支持' }, { id: 19, icon: $r('app.media.ic_default'), title: '多设备', desc: '手机、平板、智慧屏' }, { id: 20, icon: $r('app.media.ic_default'), title: '智能连接', desc: '自动发现和连接' }, { id: 21, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 22, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 23, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 24, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 25, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, { id: 26, icon: $r('app.media.ic_default'), title: '云服务', desc: '全场景云端支持' }, { id: 27, icon: $r('app.media.ic_default'), title: '美学设计', desc: '简洁流畅视觉语言' }, { id: 28, icon: $r('app.media.ic_default'), title: '开放能力', desc: '全面开发者支持' }, { id: 29, icon: $r('app.media.ic_default'), title: '多设备', desc: '手机、平板、智慧屏' }, { id: 30, icon: $r('app.media.ic_default'), title: '智能连接', desc: '自动发现和连接' } ] build() { Column() { // 标题区域 this.buildHeader() // 对角线滚动容器 this.buildDiagonalScrollView() } .width('100%') .height('100%') .backgroundColor('#F7F8FA') } // 其他Builder方法... } 4、方案成果总结创新算法实现:核心偏移算法:垂直偏移 = index * 60 - scrollOffset / 3动态响应式计算,实时更新UI基于onWillScroll事件的高效滚动监听机制性能优化策略:使用@State响应式状态管理滚动状态判断机制,优化动画计算性能合理的系数设计,平衡视觉效果与性能用户体验增强:滚动时卡片缩放效果平滑的过渡动画,创造独特的视觉效果和交互体验
  • [技术交流] 开发者技术支持-鸿蒙联系人(通讯录)
    1.问题说明:鸿蒙原生通讯录访问,需要搭建联系人页面2.原因分析:需要联系人访问权限(个人级别),需要在应用市场(AppGallery Connect)上申请3.解决思路:通讯录权限:应用市场(AppGallery Connect)上申请权限联系人页面:联系人数据需要分组(A-Z-#),每个联系人有多个联系方式,需要遍历全部取出4.解决方案:一、代码权限:项目中entry\src\main\module.json5中"requestPermissions": [ { "name": "ohos.permission.READ_CONTACTS", "reason": "$string:permission_reason_read_contact", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }],项目中entry\src\main\resources\base\element\string.json中{ "string": [ { "name": "permission_reason_read_contact", "value": "获取本地联系人列表" } ]}二、联系人页面import { BookInfo, BookInfoList } from "../models/BookInfoList"import { TelephoneViewModel } from "../viewmodels/TelephoneViewModel"@ComponentV2export struct TelephonePage { @Local viewModel: TelephoneViewModel = new TelephoneViewModel() aboutToAppear(): void { this.viewModel.initData() } build() { Stack() { List({ scroller: this.viewModel.scroller }) { LazyForEach(this.viewModel.telephoneDataSource, (groupItem: BookInfoList, groupIndex: number) => { ListItemGroup({ header: this.groupBuilder(groupItem) }) { ForEach(groupItem.infos, (rowItem: BookInfo, rowIndex: number) => { this.rowBuilder(rowItem) }) } }) } .scrollBar(BarState.Off) .sticky(StickyStyle.Header) .width('100%') .layoutWeight(1) .onScrollIndex((start: number, end: number, center: number) => { this.viewModel.tabIndex = start }) /* 可以与容器组件联动用于按逻辑结构快速定位容器显示区域的组件,arrayValue为字母索引字符串数组,selected为初始选中项索引值。 * 1. 当用户滑动List组件,list组件onScrollIndex监听到firstIndex的改变,绑定赋值给AlphabetIndexer的selected属性,从而定位到字母索引。 * 2. 当点击AlphabetIndexer的字母索引时,通过scrollToIndex触发list组件滑动并指定firstIndex,从而实现List列表与AlphabetIndexer组件 * 首字母联动吸顶展示。 */ AlphabetIndexer({ arrayValue: this.viewModel.indexers, selected: this.viewModel.tabIndex }) .color('#6E6D72') .font({ size: 10, weight: FontWeight.Medium }) .selectedColor('#6E6D72') // 选中项文本颜色 .selectedFont({ size: 10, weight: FontWeight.Medium }) // 选中项字体样式 .usingPopup(false) // 是否显示弹出框 .itemSize(16) // 每一项的尺寸大小 .width(16) .height('80%') .margin({ right: 0 }) .onSelect((tabIndex: number) => { this.viewModel.scrollToIndex(tabIndex) }) } .alignContent(Alignment.End) .width('100%') .height('100%') } @Builder rowBuilder(rowItem: BookInfo) { Row() { Text(rowItem.name) .textAlign(TextAlign.Start) .fontSize(15) .fontColor('#1A1A1A') .margin({ left: 16 }) Text(rowItem.phone) .textAlign(TextAlign.Start) .fontSize(15) .fontColor('#666666') .margin({ left: 12 }) Blank() Text('选择') .textAlign(TextAlign.Center) .fontSize(12) .fontColor('#5D55FF') .borderColor('#5D55FF') .backgroundColor('#FFFFFF') .borderWidth(1) .borderRadius(4) .width(40) .height(24) .margin({ right: 16 }) .onClick(() => { }) } .justifyContent(FlexAlign.Start) .alignItems(VerticalAlign.Center) .width('100%') .constraintSize({ minHeight: 52, }) } @Builder groupBuilder(groupItem: BookInfoList) { Row() { Text(groupItem.group) .textAlign(TextAlign.Start) .fontSize('#666666') .margin({ left: 16 }) } .justifyContent(FlexAlign.Start) .alignItems(VerticalAlign.Center) .width('100%') .height(30) .backgroundColor('#F7F7F7') }}三、ViewModel处理数据、联系人Modelimport { CustomDataSource } from "shkit"import { TelephoneUtil } from "../utils/TelephoneUtil"import { BookInfoList } from "../models/BookInfoList"@ObservedV2export class TelephoneViewModel { scroller: Scroller = new Scroller() // 通讯录数据源 @Trace telephoneDataSource: CustomDataSource<BookInfoList> = new CustomDataSource<BookInfoList>() // 列表右边索引 @Trace indexers: string[] = [] @Trace tabIndex: number = 0 initData() { this.loadData() } releaseData(): void { } // 加载联系人数据 async loadData() { this.telephoneDataSource.pushArray(await TelephoneUtil.queryContacts()) this.indexers.splice(0, this.indexers.length, ...TelephoneUtil.getTndexers()) } scrollToIndex(tabIndex: number) { this.scroller.scrollToIndex(tabIndex) }} @ObservedV2export class BookInfoList { group: string = '' infos: BookInfo[] = []}@ObservedV2export class BookInfo { name: string = '' phone: string = ''}四、获取联系人数据import { BookInfo, BookInfoList } from "../models/BookInfoList";import { Permissions } from "@kit.AbilityKit";import { AppUtil, FormatUtil, PermissionUtil, StrUtil } from "@pura/harmony-utils";import { contact } from "@kit.ContactsKit";export class TelephoneUtil { static indexers: string[] = [] // 获取右边索引集合 static getTndexers(): string[] { return TelephoneUtil.indexers } static async queryContacts(): Promise<BookInfoList[]> { let groups: BookInfoList[] = [] // 允许应用读取联系人数据,权限 let p: Permissions = 'ohos.permission.READ_CONTACTS'; let grantStatus: boolean = await PermissionUtil.checkPermissions(p) // 校验当前是否已经授权 if (!grantStatus) { // 未授权 grantStatus = await PermissionUtil.requestPermissionsEasy(p) // 申请授权 } if (!grantStatus) { // 未授权 return groups } let datas: contact.Contact[] = await contact.queryContacts(AppUtil.getContext()) // 移除索引 TelephoneUtil.indexers.splice(0, TelephoneUtil.indexers.length) if (datas.length > 0) { datas.forEach((data: contact.Contact) => { // 联系人全名 let fullName: string = data.name?.fullName ?? "" // 联系人电话列表 let phoneNumbers: contact.PhoneNumber[] = data.phoneNumbers ?? [] let firstStr: string = "#" if (StrUtil.isNotEmpty(fullName)) { firstStr = FormatUtil.transliterator(fullName).charAt(0).toUpperCase() } // 添加索引 TelephoneUtil.indexers.push(firstStr) // 判断是否已经创建了联系人组 let hasGroup: boolean = false if (groups.length > 0) { groups.forEach((groupModel: BookInfoList) => { if (groupModel.group == firstStr) { hasGroup = true // 添加组的联系人 TelephoneUtil.createContacts(groupModel, fullName, phoneNumbers) return } }) } // 没有联系人组就创建 if (!hasGroup) { let groupModel: BookInfoList = { group: firstStr, infos: [] } // 添加组的联系人 TelephoneUtil.createContacts(groupModel, fullName, phoneNumbers) groups.push(groupModel) } }) } return groups } // 添加组的联系人 private static createContacts(groupModel: BookInfoList, fullName: string, phoneNumbers: contact.PhoneNumber[]) { phoneNumbers.forEach((phoneItem: contact.PhoneNumber) => { let phoneNumber: string = phoneItem.phoneNumber let rowModel: BookInfo = { name: fullName, phone: phoneNumber } groupModel.infos.push(rowModel) }) }}五、目前基本可以实现访问联系人页面、数据,后续有修改再做跟进
  • [技术干货] 开发者技术支持-鸿蒙多文件下载技术方案总结
    1、关键技术难点总结1.1 问题说明一、单文件下载方式效率低在文件管理场景中,用户经常需要同时下载多个大文件(如软件安装包、系统镜像、数据备份等),传统的单文件下载方式效率低下。在HarmonyOS平台上实现多文件的并发下载管理,既要保证下载效率,又要避免因过多并发任务导致的系统资源耗尽。特别是在移动设备上,需要精确控制同时进行的下载任务数量,确保系统稳定性和用户体验。二、复杂状态管理与UI同步难题下载器需要管理多种不同的任务状态(如初始化、等待、下载中、已暂停、已完成、失败、已取消),每种状态的转换都需要触发相应的UI更新和用户反馈。在实际应用中,用户可能会频繁进行暂停、恢复、取消等操作,这要求系统能够实时响应用户操作并准确反映当前状态。传统的手动UI更新方式容易导致界面与实际状态不同步,影响用户体验和操作准确性。1.2 原因分析一、缺少多任务管理控制在多文件下载场景中,用户可能需要同时处理数十个大文件,但HarmonyOS的download API在设计上需要开发者精确管理任务队列。由于移动设备的CPU、内存和网络带宽资源有限,无限制的并发下载会导致系统资源竞争加剧,出现下载速度下降、应用卡顿甚至崩溃的问题。因此必须实现智能的队列管理机制,通过FIFO队列调度和最大并发数控制,既保证下载效率又维护系统稳定性。二、状态管理与UI实时同步在用户频繁操作的下载场景中,多种状态之间的转换极其复杂,每次状态变化都需要触发UI更新。传统的手动UI刷新方式存在时序问题:当用户快速点击暂停/恢复按钮时,下载任务的实际状态可能已经改变,但UI显示仍停留在上一个状态,导致用户误操作。这种状态不一致问题在多任务并发执行时更加突出,需要采用响应式编程模式和数据绑定机制来确保UI与业务逻辑的实时同步。2、解决思路采用队列管理模式 - 设计任务队列,实现FIFO队列管理和并发控制使用观察者模式 - 通过@Observed和@ObjectLink实现状态自动同步权限预申请策略 - 在应用启动时预先申请所需网络权限进度回调机制 - 利用HarmonyOS的progress事件实现实时进度更新3、解决方案3.1 核心下载任务类设计@Observed class DownloadTask { taskId: string | null = null; // 任务唯一ID url: string | null = null; // 下载地址 fileName: string | null = null; // 文件名 filePath: string | null = null; // 文件存储路径 status: DownloadStatus = DownloadStatus.INIT; // 下载状态 progress: number = 0; // 下载进度(0-100) totalSize: number = 0; // 文件总大小(字节) downloadedSize: number = 0; // 已下载大小(字节) msg?: string = ''; // 消息 isRunning?: boolean = false // 是否正在下载 } 关键技术点:使用@Observed装饰器实现数据响应式更新完整的任务状态属性定义支持进度追踪和文件信息管理3.2 下载状态枚举定义enum DownloadStatus { INIT = 'init', // 未开始 PENDING = 'pending', // 等待中 DOWNLOADING = 'downloading', // 下载中 PAUSED = 'paused', // 已暂停 COMPLETED = 'completed', // 已完成 FAILED = 'failed', // 失败 CANCELLED = 'cancelled' // 已取消 } 关键技术点:定义了7种下载状态的完整枚举确保状态转换的准确性和一致性为UI组件提供清晰的状态判断依据3.3 并发队列管理机制// 任务队列 let taskQueue: Array<DownloadTask> = []; // 最多可以同时下载几个文件 let max = 2 function addTask(downloadTask: DownloadTask, context: common.Context) { if (!downloadTask) { return } // 加入下载队列 let length = taskQueue.push(downloadTask) if (length <= max ) { startDownload(downloadTask, context) } else { downloadTask.status = DownloadStatus.PENDING } } function startDownload(downloadTask: DownloadTask, context: common.Context) { if (!downloadTask) { return } request.agent.getTask(context, downloadTask.taskId).then((task: request.agent.Task) => { if (downloadTask.isRunning) { // 恢复下载 task.resume().then(() => { promptAction.showToast({ message: "继续下载" }) }).catch((err: BusinessError) => { promptAction.showToast({ message: "继续下载失败" }) }) } else { // 开始下载 downloadTask.isRunning = true task.start().then(() => { promptAction.showToast({ message: "开始下载" }) }).catch((err: BusinessError) => { promptAction.showToast({ message: "下载失败" }) }) } }) downloadTask.status = DownloadStatus.DOWNLOADING } 关键技术点:队列管理模式实现并发下载控制最大并发数默认为2个,超出部分自动进入等待队列支持任务的暂停和恢复操作完善的异常处理和用户提示3.4 下载任务创建和监听// 添加示例下载任务 async addTestTasks() { const downloadInfoList: Array<DownloadInfo> = [ { name: "license.txt", url: "http://example/files/xxx.txt", size: "2 KB" }, { name: "gitlab-jh-17.9.7-jh.0.el8.x86_64.rpm", url: "http://example/files/xxx.exe", size: "1474.56 MB" } ]; for (let i = 0; i < downloadInfoList.length; i++) { let downloadTask: DownloadTask = new DownloadTask(); let filePath = this.context.cacheDir + "/" + "downloads" + i; // 创建下载任务 let task = await request.agent.create(this.context, { action: request.agent.Action.DOWNLOAD, url: downloadInfoList[i].url, overwrite: true, // 支持复写 gauge: true, // 通知 priority: i, // 优先级 saveas: filePath, // 保存文件目录 }); // 监听下载进度 task.on("progress", (progress: request.agent.Progress) => { downloadTask.msg = progress.processed + "/" + progress.sizes[0]; downloadTask.downloadedSize = progress.processed; downloadTask.totalSize = progress.sizes[0]; }); // 监听下载完成 task.on("completed", () => { promptAction.showToast({ message: "下载完成" }) downloadTask.status = DownloadStatus.COMPLETED // 唤醒下一个任务 startDownload(taskQueue[max], this.context) // 移除当前文件 taskQueue.splice(0, 1) }); downloadTask.taskId = task.tid; downloadTask.msg = downloadInfoList[i].size; downloadTask.fileName = downloadInfoList[i].name; downloadTask.filePath = `${filePath}/${downloadTask.fileName}`; downloadTask.url = downloadInfoList[i].url; downloadTask.isRunning = false this.taskList.push(downloadTask) } } 关键技术点:使用HarmonyOS的request.agent API创建下载任务通过progress事件监听实现实时进度更新通过completed事件处理下载完成后的队列调度支持文件覆盖、优先级设置和通知显示3.5 任务控制逻辑function taskClick(task: DownloadTask, context: common.Context) { switch (task.status) { case DownloadStatus.INIT: addTask(task, context); break; case DownloadStatus.DOWNLOADING: // 暂停下载 request.agent.getTask(context, task.taskId).then((task: request.agent.Task) => { task.pause().then(() => { promptAction.showToast({ message: "暂停下载" }) }).catch((err: BusinessError) => { promptAction.showToast({ message: "暂停失败" }) }) }); // 修改状态为已暂停 task.status = DownloadStatus.PAUSED; // 唤醒新的任务 startDownload(taskQueue[max], context); // 从下载队列中移除 delQueueItem(task); break; case DownloadStatus.PENDING: delQueueItem(task); // 状态修改为暂停下载 task.status = DownloadStatus.PAUSED; break; case DownloadStatus.PAUSED: // 开始下载 addTask(task, context); break; } } function delQueueItem(task: DownloadTask) { let index = findQueueIndex(task) taskQueue.splice(index, 1) } function findQueueIndex(task: DownloadTask) { for (let i = 0; i < taskQueue.length; i++) { if (taskQueue[i].taskId === task.taskId) { return i } } return -1 } 关键技术点:基于状态机模式的任务控制逻辑支持下载、暂停、恢复等操作的无缝切换智能的队列调度,暂停任务后自动启动等待中的任务完善的队列操作函数,支持任务的查找和删除3.6 UI组件设计@Component struct TaskItem { @ObjectLink downloadTask: DownloadTask; private context: common.Context = this.getUIContext().getHostContext() as Context; build() { Column() { // 文件名和状态显示 Row() { Text(this.downloadTask.fileName).padding({left: 5}) Text(this.downloadTask.status).padding({left: 5}) } .width("100%") .height(50) // 进度信息显示 Row() { Text('进度:' + this.downloadTask.msg).margin({right: 20}) Text(`${(this.downloadTask.totalSize == 0 ? 0 : ((this.downloadTask.downloadedSize / this.downloadTask.totalSize) * 100).toFixed(2))}%`) .margin({right: 10}) } .width("100%") .height(50) // 进度条和控制按钮 Row() { Progress({ value: this.downloadTask.downloadedSize, total: this.downloadTask.totalSize, type: ProgressType.Capsule }).width('50%').height(40).margin({right: 10}) Button(this.downloadTask.status === DownloadStatus.COMPLETED ? '已完成' : this.downloadTask.status === DownloadStatus.DOWNLOADING ? "停止" : "下载") .onClick(() => { taskClick(this.downloadTask, this.context) }) .enabled(this.downloadTask.filePath != "" && this.downloadTask.status != DownloadStatus.COMPLETED) .width(100) .fontSize(14) .height(40) } .width("100%") .height(50) } .width("100%") } } 关键技术点:使用@ObjectLink实现下载状态与UI组件的自动同步实时显示下载进度百分比和进度条动态按钮文本,根据下载状态显示不同操作智能按钮启用状态控制,已完成任务不可操作4、方案成果总结智能队列管理 - 支持最大并发数控制,超出部分自动进入等待队列完善的状态管理 - 通过枚举定义了多种下载状态,确保状态转换的准确性实时进度显示 - 通过progress事件监听实现下载进度的实时更新和UI刷新用户交互优化 - 支持下载、暂停、恢复、取消等操作,提供良好的用户体验响应式UI设计 - 使用@Observed和@ObjectLink实现数据驱动的UI更新
  • [开发技术领域专区] 开发者技术支持-鸿蒙文件预览技术总结
    一、 关键技术难点总结1.问题说明在鸿蒙应用开发中,文件预览是一个常见的功能需求。开发者在实现文件预览功能时面临以下主要问题:API使用复杂:需要同时处理文件信息(PreviewInfo)和窗口配置(DisplayInfo)路径格式不统一:需要处理普通路径与URI格式的转换上下文依赖:预览功能强依赖正确的Context传递错误处理繁琐:需要处理文件不存在、格式不支持等多种异常情况配置管理困难:窗口位置、大小等参数需要合理设置2.原因分析API设计粒度较细:需要分别配置文件属性和窗口属性路径处理复杂:系统要求使用URI格式,但应用内文件通常使用普通路径生命周期管理:预览窗口状态需要手动管理配置参数众多:x、y、width、height等参数都需要合理设置3.解决思路针对上述问题,采用以下解决策略:封装统一接口:将复杂的API调用封装成简单的工具类方法,降低使用门槛。路径自动转换:实现智能路径识别和转换机制,支持多种路径格式。配置合并管理:提供默认配置并支持自定义覆盖,简化配置过程。状态统一管理:封装预览窗口的状态检查和生命周期管理。4.解决方案(一)配置管理/\*\* \* 合并预览配置 - 智能填充默认值 \*/ private mergePreviewConfig(config?: PreviewConfig): PreviewConfig { const defaultConfig: PreviewConfig = { title: \'文件预览\', x: 40, y: 100, width: 300, height: 500 }; if (!config) { return defaultConfig; } // 手动合并配置,确保类型安全 const mergedConfig: PreviewConfig = { title: config.title !== undefined ? config.title : defaultConfig.title, x: config.x !== undefined ? config.x : defaultConfig.x, y: config.y !== undefined ? config.y : defaultConfig.y, width: config.width !== undefined ? config.width : defaultConfig.width, height: config.height !== undefined ? config.height : defaultConfig.height }; return mergedConfig; } (二)路径智能处理/\*\* \* 智能路径处理 - 支持多种路径格式 \*/ private processFilePath(filePath: string): string { // 判断是否是URI格式 let uri: string; let isUri = filePath.startsWith(\'file://\') \|\| filePath.startsWith(\'content://\'); if (isUri) { uri = filePath; // 已经是URI,直接使用 } else { // 检查文件是否存在(仅对普通路径检查) try { const isExist = fs.accessSync(filePath); if (!isExist) { throw new Error(\`文件不存在: \${filePath}\`); } } catch (fsError) { throw new Error(\`文件访问失败: \${filePath}\`); } uri = fileUri.getUriFromPath(filePath); // 普通路径,转换为URI } return uri; } (三)预览核心方法/\*\* \* 打开文件预览 - 统一入口方法 \*/ async openPreview(filePath: string, mimeType: string, config?: PreviewConfig): Promise\<void\> { if (!this.context) { throw new Error(\'Context未设置,请先调用setContext方法\'); } try { // 1. 路径处理 const uri = this.processFilePath(filePath); // 2. 配置合并 const previewConfig = this.mergePreviewConfig(config); // 3. 构建预览参数 const previewFile: filePreview.PreviewInfo = { title: previewConfig.title!, uri: uri, mimeType: mimeType }; const previewWindowConfig: filePreview.DisplayInfo = { x: previewConfig.x!, y: previewConfig.y!, width: previewConfig.width!, height: previewConfig.height! }; // 4. 检查支持性 const isCanPreview = await this.canPreview(filePath); if (!isCanPreview) { throw new Error(\'文件不支持预览(格式不支持或文件不存在)\'); } // 5. 打开预览 await filePreview.openPreview(this.context, previewFile, previewWindowConfig); } catch (error) { const businessError = error as BusinessError; console.error(\`FilePreviewUtil openPreview error: \${JSON.stringify(businessError)}\`); throw new Error(\`预览失败:\${businessError.message \|\| businessError.code \|\| \'未知错误\'}\`); } } (四)演示页面集成/\*\* \* 演示页面 - 简化使用示例 \*/ \@Entry \@Component struct FilePreviewDemo { \@State previewStatus: string = \'点击下方按钮预览文件\'; private previewUtil: FilePreviewUtil = filePreviewUtil; // 初始化上下文 aboutToAppear(): void { const uiContext: UIContext = this.getUIContext(); const context = uiContext.getHostContext() as Context; this.previewUtil.setContext(context); } // 简化后的预览调用 async openFilePreview() { try { const testFilePath = await this.previewUtil.createTestFile(\'demo.txt\', \'测试内容\'); const previewConfig: PreviewConfig = { title: \'演示文档\', x: 50, y: 150, width: 350, height: 550 }; await this.previewUtil.openPreview(testFilePath, \'text/plain\', previewConfig); this.previewStatus = \'预览成功!\'; } catch (error) { this.previewStatus = error.message; } } } (五)功能时序图5.成果总结通过封装文件预览工具类,取得了以下成果:(一)使用简化代码量减少:从复杂的多参数调用简化为单一方法调用配置智能化:自动填充默认值,支持部分自定义错误统一处理:统一的异常捕获和用户友好提示(二)功能完善多格式支持:支持文本、图片等多种文件格式路径自适应:自动识别和处理不同路径格式状态管理:完整的预览窗口生命周期管理(三)健壮性提升上下文安全:强制上下文检查,避免空指针异常文件验证:自动检查文件存在性和支持性(四)开发效率快速集成预览功能提供一致的用户体验降低维护成本
  • [开发技术领域专区] 开发者技术支持 - 鸿蒙全屏登录页技术总结
    1. 问题说明(一)模态页切换无过渡,体验生硬点击 “其他登录方式” 切换页面时,无动画过渡,页面跳转突兀,用户感知割裂,不符合流畅交互预期,影响使用体验。(二)多登录方式返回键逻辑复杂一键登录与其他登录方式需分别实现返回功能,未统一管理,易出现返回逻辑冲突(如误关闭模态页),增加开发调试成本。(三)协议未勾选仍可触发登录未校验 “服务协议” 勾选状态,用户未同意协议时点击 “一键登录”,无提示直接执行登录逻辑,不符合合规要求与交互逻辑。(四)手机号未达标,按钮状态不变手机号输入框未输入 11 位数字时,“发送验证码” 按钮仍为灰色不可用状态,但无明确反馈,用户不知需输入完整手机号。2. 原因分析(一)过渡效果缺失未为登录方式切换的组件设置transition属性,组件显隐时无系统动画支持,导致切换过程生硬,缺乏视觉连贯性。(二)返回键未统一布局未使用Stack等容器组件统一包裹不同登录页面,返回键需在每个页面单独实现,无法复用逻辑,易出现跳转逻辑混乱。(三)协议校验逻辑缺失登录按钮的onClick事件未添加 “协议勾选状态” 判断条件,未区分isAgree为false的场景,直接执行登录操作,无异常提示。(四)输入监听未绑定未为手机号输入框添加内容长度监听,未将输入长度与按钮enabled状态关联,无法动态激活按钮,缺乏用户引导。3. 解决思路(一)模态页绑定与过渡优化用bindContentCover绑定全屏模态页,通过transition设置滑入滑出效果,让模态页显隐与登录方式切换更流畅。(二)统一返回键布局用Stack组件包裹所有登录页面,将返回键置于顶层,复用返回逻辑,避免重复实现,解决跳转冲突问题。(三)协议勾选校验在登录按钮点击事件中,先判断协议勾选状态,未勾选时提示用户,勾选后再执行登录逻辑,确保合规与交互正确。(四)输入框与按钮联动监听手机号输入框内容变化,当输入长度达 11 位时,动态激活 “发送验证码” 按钮,同时提示用户,提升操作引导性。4. 解决方案(一)模态页绑定与显隐控制通过bindContentCover绑定模态页,控制显隐状态,实现从下方滑出的全屏效果: import { LoginModel } from './LoginModel'; @Builder export function PageThreeBuilder() { ModalWindowComponent() } @Component export struct ModalWindowComponent { // 是否显示全屏模态页面 @State isPresent: boolean = false; pathStack: NavPathStack = new NavPathStack(); @Builder loginBuilder() { Column() { // 通过@State和@Link使isPresentInLoginView和isPresent产生关联 LoginModel({ isPresentInLoginView: this.isPresent }) } } build() { NavDestination() { Column() { // TODO:需求:增加其他登录方式,如半模态窗口 Button('点击跳转到全屏登录页') .fontColor(Color.White) .borderRadius(8) .type(ButtonType.Normal) .backgroundColor('#222222') .width('100%') .bindContentCover($$this.isPresent, this.loginBuilder) .onClick(() => { this.isPresent = true; // 当isPresent为true时显示模态页面,反之不显示 }) } .size({ width:'100%', height: '100%' }) .padding(12) .justifyContent(FlexAlign.Center) } .title('Login_Model') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } (二)登录方式切换(带过渡效果)用if-else条件渲染登录方式,通过transition添加滑入过渡:import promptAction from '@ohos.promptAction'; import { OtherWaysToLogin, ReadAgreement } from './OtherWaysToLogin'; const EFFECT_DURATION = 800; const EFFECT_OPACITY = 0.4; const SPACE_TEN = 10; @Component export struct LoginModel { @Link isPresentInLoginView: boolean; // 是否是默认一键登录方式 @State isDefaultLogin: boolean = true; // 用户名 userName: string = '18888888888'; // 判断是否同意协议 isConfirmed: boolean = false; private effect: TransitionEffect = TransitionEffect.OPACITY .animation({ duration: EFFECT_DURATION }) .combine(TransitionEffect.opacity(EFFECT_OPACITY)) // 默认一键登录方式 @Builder DefaultLoginPage() { Column({ space: SPACE_TEN }) { Row({ space: SPACE_TEN }) { Image('') .width(40) .height(40) Column({ space: SPACE_TEN }) { Text('Hi, 欢迎回来') .fontWeight(FontWeight.Bold) .fontSize(20) .fontColor(Color.Black) Text('登录后更精彩,美好生活即将开始') .fontColor('#333333') } .alignItems(HorizontalAlign.Start) } .alignItems(VerticalAlign.Center) .width('100%') Text(this.userName) .fontColor('#333333') .fontWeight(FontWeight.Bold) .padding({ left: 12 }) .height(40) .width('100%') .borderRadius(8) .backgroundColor('#eeeeeee') Text('认证服务由xxxx提供') .fontColor('#666666') .width('100%') .textAlign(TextAlign.Start) Row() { Checkbox({ name: 'checkbox1' }) .id('default_agreement') .select(this.isConfirmed) .onChange((value: boolean) => { this.isConfirmed = value }) ReadAgreement() } .width('100%') .alignItems(VerticalAlign.Center) Button('手机号码一键登录') .fontColor(Color.White) .borderRadius(8) .type(ButtonType.Normal) .backgroundColor('#222222') .onClick(() => { if (this.isConfirmed) { // 调用Toast显示登录成功提示 promptAction.showToast({ message: '登录成功' }); } else { // 调用Toast显示请先阅读并同意协议提示 promptAction.showToast({ message: '请先阅读并同意协议' }); } }) .width('100%') .height(50) Row() { Text('其他登录方式') .fontColor('#777777') .backgroundColor('#007777') .onClick(() => { this.isDefaultLogin = false; }) // 在容器主轴方向上自动填充容器空余部分 Blank() Text('遇到问题') .fontColor('#777777') .backgroundColor('#007777') .onClick(() => { // 调用Toast显示遇到问题提示 promptAction.showToast({ message:'遇到问题' }); }) } .width('100%') } .width('100%') .height('100%') .backgroundColor(Color.White) .justifyContent(FlexAlign.Center) } build() { Stack({ alignContent: Alignment.TopStart }) { // 登录方式有两种(默认一键登录方式和其他方式登录),需要在一个模态窗口中切换,使用if进行条件渲染 if (this.isDefaultLogin) { // 默认一键登录方式 this.DefaultLoginPage() } else { // 其他登录方式 OtherWaysToLogin() .transition(this.effect) // 此处涉及到组件的显示和消失,所以使用transition属性设置出现/消失转场 } Image($r('app.media.arrow_back'))// 通过Stack组件,两个页面只实现一个back .id('login_back') .width(25).height(25) .margin({ top: 20 }) .onClick(() => { if (this.isDefaultLogin) { this.isPresentInLoginView = false; } else { this.isDefaultLogin = true } }) } .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .size({ width:'100%', height: '100%' }) .padding({ top: 12, left:12, right:12 }) .backgroundColor(Color.White) // 将模态页面背景设置为白色,以避免模态页面内组件发生显隐变化时露出下层页面 } } (三)统一返回键布局(Stack 组件)用Stack包裹登录内容,复用返回键逻辑(完整代码在上方Step2): Stack({ alignContent: Alignment.TopStart }) { // 登录方式有两种(默认一键登录方式和其他方式登录),需要在一个模态窗口中切换,使用if进行条件渲染 if (this.isDefaultLogin) { this.DefaultLoginPage() // 默认一键登录方式 } else { OtherWaysToLogin()// 其他登录方式 .transition(this.effect) // 此处涉及到组件的显示和消失,所以使用transition属性设置出现/消失转场 } Image($r('app.media.arrow_back'))// 通过Stack组件,两个页面只实现一个back .id('login_back') .width(25).height(25) .margin({ top: 20 }) .onClick(() => { if (this.isDefaultLogin) { this.isPresentInLoginView = false; } else { this.isDefaultLogin = true } }) } .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .size({ width:'100%', height: '100%' }) .padding({ top: 12, left:12, right:12 }) .backgroundColor(Color.White) // 将模态页面背景设置为白色,以避免模态页面内组件发生显隐变化时露出下层页面 (四)协议勾选校验与登录控制添加协议勾选状态判断,未勾选时提示:@Component export struct ReadAgreement { build() { Text() { Span('阅读并同意') .fontColor('#999999') Span('服务协议及个人信息处理规则') .fontColor(Color.Orange) .onClick(() => { // 调用Toast显示用户点击服务协议及个人信息处理规则的提示 promptAction.showToast({ message: '服务协议及个人信息处理规则' }); }) } .textAlign(TextAlign.Start) } } (五)手机号输入与验证码按钮控制监听手机号输入长度,动态激活按钮:import { Prompt } from '@kit.ArkUI'; const PHONE_NUMBER_LENGTH = 11; const SPACE_TWENTY = 20; const SPACE_TEN = 10; const COUNTDOWN_SECONDS = 30; // 倒计时总秒数 const SEND_AGAIN_IN_SECONDS = "s后可再次发送"; @Component struct OtherLoginView { @State phoneNum: string = ''; // 手机号输入值 controller: TextInputController = new TextInputController(); // 发送验证码按钮的颜色 @State buttonColor: ResourceColor = Color.Grey; // 发送验证码按钮的内容 @State buttonContent: ResourceStr = '发送短信验证码'; // 手机号是否可用 phoneNumberAvailable: boolean = false; // 可发送验证码的倒计时秒数 build() { Column() { // 手机号输入框 TextInput({ placeholder:'请输入手机号' }) // 正则表达式,输入的是数字0-9则允许显示,不是则被过滤 .inputFilter('[0-9]') .backgroundColor(Color.Transparent) .caretColor(Color.Grey) .width('100%') .maxLength(PHONE_NUMBER_LENGTH)// 设置最大输入字符数 // 当输入字符数为11位时,发送验证码按钮变为蓝色,否则置灰 .onChange((value: string) => { if (value.length === PHONE_NUMBER_LENGTH) { this.phoneNumberAvailable = true; this.buttonColor = Color.Blue; } else { this.phoneNumberAvailable = false; this.buttonColor = Color.Grey; } }) } Button(this.buttonContent) .type(ButtonType.Normal) .border({ radius: 8 }) .width('100%') .backgroundColor(this.buttonColor) .id('send_button_id') .onClick(() => { if (this.countdownSeconds > 0) { // 处于可再次发送的读秒倒计时状态下,点击按钮不响应 return; } // 输入输入字符数为11位,并同意服务协议及个人信息处理规则,才能发送验证码 if (!this.phoneNumberAvailable) { promptAction.showToast({ message:'请输入正确的手机号' }); } else if (!this.isAgree) { promptAction.showToast({ message: '请先阅读并同意服务协议及个人信息处理规则' }); } else { // 点击发送短信验证码按钮后,按钮置灰,开始读秒倒计时,按钮内容改变 promptAction.showToast({ message: '验证码已发送') }); this.buttonColor = Color.Grey; this.countdownSeconds = COUNTDOWN_SECONDS; const timerId = setInterval(() => { this.countdownSeconds--; if (this.countdownSeconds <= 0) { // 计时结束,根据手机号位数是否正确,重置按钮状态 this.buttonContent = '发送短信验证码'; clearInterval(timerId); this.buttonColor = this.phoneNumberAvailable ? Color.Blue : Color.Grey; return; } this.buttonContent = this.countdownSeconds + SEND_AGAIN_IN_SECONDS; }, 1000) } }) } .padding(20) .width('100%') } } 5.方案成果总结   主页面点击触发下方滑出的全屏模态登录页,支持一键登录与其他登录方式切换。技术上通过 bindContentCover 绑定模态页,Stack 组件统一返回键逻辑避免跳转冲突,transition 添加页面切换过渡效果;还实现协议勾选校验(未勾选提示 “请先同意协议”)、手机号输入 11 位后激活 “发送验证码” 按钮等交互。案例复用组件减少冗余,功能合规且操作引导清晰,大幅提升登录交互流畅度,满足登录场景的使用需求与用户体验要求。
  • [技术交流] 开发者技术支持-软键盘展开时显示自定义Toast导致键盘关闭问题
    开发者技术支持-软键盘展开时显示自定义Toast导致键盘关闭问题问题说明:自定义样式 Toast 通常使用 getUIContext().getPromptAction().openCustomDialog() 封装(通过自定义弹窗实现自定义样式的Toast),在软键盘展开情况下显示显示自定义 Toast 时会出现软键盘收起的问题。原因分析:使用 openCustomDialog 打开自定义弹窗时使得当前 TextInput、TextArea 失去焦点,软键盘关闭。解决思路:使用 Overlay 方式实现自定义 Toast1、使用 OverlayManager 方式实现, 经验证在 Toast 关闭时软键盘也会被关闭,Pass。2、使用 CommonMethod<T>.overlay(value: string | CustomBuilder | ComponentContent, options?: OverlayOptions): T 通过控制 CustomBuilder 显示/隐藏 实现 自定义 Toast。 /** * Add mask text to the current component. The layout is the same as that of the current component. * * @param { string | CustomBuilder | ComponentContent } value * @param { OverlayOptions } options * @returns { T } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @form * @atomicservice * @since 12 */ overlay(value: string | CustomBuilder | ComponentContent, options?: OverlayOptions): T; 解决方案:定义 ToastOptions 参数interface ToastOptions { message: ResourceStr icon?: Resource margin: Margin | Length | LocalizedMargin } 实现 Toast 控制器 ToastController@ObservedV2 export class ToastController { @Trace isShow: boolean = false @Trace options: ToastOptions = { message: "", margin: { bottom: "20%" } } showToast( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, margin: margin }, duration) } showSuccess( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, icon: $r('app.media.ic_toast_success'), margin: margin }, duration) } showFailed( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, icon: $r('app.media.ic_toast_error'), margin: margin }, duration) } showWarning( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, icon: $r('app.media.ic_toast_warning'), margin: margin }, duration) } private show(options: ToastOptions, duration: number) { this.options = options this.isShow = true setTimeout(() => { this.isShow = false }, duration) } } 实现 CustomBuilder@Builder export function ToastBuilder(controller: ToastController) { Stack() { Text() { if (controller.options.icon) { ImageSpan(controller.options.icon) .width(20) .height(20) .margin({ right: 8 }) } Span(controller.options.message) .fontWeight(400) .fontColor($r("app.color.White_09")) .fontSize(13) } .padding({ top: 10, left: 24, bottom: 10, right: 24, }) .textAlign(TextAlign.Center) .border({ color: $r('app.color.White_008'), radius: 24, width: 0.5 }) .backgroundColor($r('app.color.grey_a_95')) .margin(controller.options.margin) } .alignContent(Alignment.Center) .zIndex(4) .width('100%') .height('100%') .visibility(controller.isShow ? Visibility.Visible : Visibility.None) } 使用@ComponentV2 export struct Component { @Local toastController: ToastController = new ToastController() build() { Column() { Button('toast') .onClick(() => { this.toastController.showWarning('我是Toast内容')) }) } .width('100%') .height('100%') .overlay(ToastBuilder(this.toastController!!)) } }
  • [干货汇总] state和local的无缝切换
    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 使用
  • [技术交流] 开发者技术支持-鸿蒙下拉二楼UI
    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)
    # 问题说明仓颉编程语言是一款面向全场景智能的新一代编程语言,主打原生智能化、天生全场景、高性能、强安全。主要应用于鸿蒙原生应用及服务应用等场景中,为开发者提供良好的编程体验。## 初识仓颉(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开发环境中测试和调整,实际实现时应根据具体需求增加适当的错误处理和日志记录。​
  • [技术交流] 开发者技术支持--录音机Bug修复
    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合并调用减少代码冗余,维护成本降低。
总条数:446 到第
上滑加载中