• [开发技术领域专区] 开发者技术支持-视频播放分辨率适配技术经验总结
    1.1问题说明一是设备兼容性问题,要解决在高低端机型及不同屏幕尺寸上出现的渲染异常等适配问题;二是用户操作反馈不足问题,需补充倍速调整状态、进度拖动结果及错误状态的明确提示,避免用户误判。1.2原因分析播放器采用固定分辨率渲染,屏幕适配固定化,未针对不同屏幕尺寸、比例及像素密度动态调整,渲染尺寸和缩放策略单一,导致非标准比例屏幕出现画面拉伸、裁剪或黑边过大,高分辨率屏幕画面模糊、低分辨率屏幕因过度渲染损耗性能,破坏观看体验。1.3解决思路屏幕尺寸与比例适配:根据屏幕和视频宽高比计算最优显示尺寸 —— 比例接近时,用 “等比缩放 + 边缘填充” 保留完整画面、减少黑边;比例差异大时,提供 “智能裁剪(保主体)” 和 “完整显示(带黑边)” 供用户选择。实时动态调整:监听屏幕旋转、折叠等变化,实时更新渲染参数(如横屏时全屏显示,折叠屏展开后提升分辨率);同时根据内存占用调整帧缓存大小,避免因资源过度占用导致崩溃。1.4解决方案通过videoSizeChange获取视频宽高比、onAreaChange监听窗口尺寸,调用VideoSize动态调整播放区域(全屏保比例、非全屏适配宽度),结合window.setPreferredOrientation实现横竖屏切换适配;并在页面隐藏时暂停播放、组件销毁时释放avPlayer资源,避免拉伸变形与内存泄漏;代码示例:// 导入所需模块 import { BusinessError, emitter } from '@kit.BasicServicesKit'; // 错误处理和事件发射器 import { media } from '@kit.MediaKit'; // 媒体播放相关功能 import { Prompt, window } from '@kit.ArkUI'; // UI 提示和窗口控制 // 页面入口组件定义 @Entry @Component struct AVPlayer { // 状态变量定义 @State message: string = 'Hello World'; // 示例消息文本 @State vol: number = 1; // 音量控制(0-1) @State stateVideoState: boolean = false; // 视频播放状态记录 private xComponentController: XComponentController = new XComponentController(); // XComponent控制器,用于视频渲染 @State isFull: boolean = false; // 全屏状态标志 @State showController: boolean = true; // 控制器显示状态 @State playing: boolean = true; // 播放状态标志 @State surfaceID: string = ''; // 视频渲染表面ID @State videoSrc: string = 'https://consumer.huawei.com/content/dam/huawei-cbg-site/cn/mkt/pdp/phones/nova-flip/new/video/design-intro-popup.mp4'; // 视频源URL @State videoTime: number = 0; // 当前播放时间(毫秒) @State endTime: number = 0; // 视频总时长(毫秒) @State videoProportion: number = 0; // 视频宽高比 @State videoHeight: number = 1; // 视频显示高度 @State videoWidth: number = 1; // 视频显示宽度 @State windowHeight: number = 1; // 窗口高度 @State windowWidth: number = 1; // 窗口宽度 // 播放器实例 private avPlayer: media.AVPlayer | null = null; private longPressTimer: number | null = null; // 长按定时器 @State showSpeedTip: boolean = false; // 倍速提示显示状态 /** * 设置媒体源,用于动态切换视频 * @param newSource 新的视频URL */ private async setMediaSource(newSource: string) { // 验证新的视频源 if (!newSource || newSource === this.videoSrc) { Prompt.showToast({ message: '视频源无效或与当前相同' }); return; } try { // 暂停当前播放 if (this.avPlayer && ['playing', 'paused'].includes(this.avPlayer.state)) { await this.avPlayer.stop(); } // 更新视频源状态 this.videoSrc = newSource; this.videoTime = 0; this.endTime = 0; // 如果播放器实例存在,重置并设置新的URL if (this.avPlayer) { await this.avPlayer.reset(); this.avPlayer.url = newSource; // 如果已有渲染表面,立即准备播放 if (this.surfaceID) { this.avPlayer.surfaceId = this.surfaceID; await this.avPlayer.prepare(); } } else { // 如果播放器实例不存在,重新初始化 this.avPlayer = await media.createAVPlayer(); this.setAVPlayerCallback(this.avPlayer); this.avPlayer.url = newSource; } Prompt.showToast({ message: '视频源已切换' }); } catch (err) { console.error('切换视频源失败', err); Prompt.showToast({ message: '切换视频源失败' }); } } /** * 临时速度设置方法(用于长按快进) * @param speed 播放速度 */ private setTemporarySpeed(speed: media.PlaybackSpeed) { const mp = this.avPlayer; if (!mp) { return; } // 仅在播放或暂停状态允许临时调速 if (['playing', 'paused'].includes(mp.state)) { mp.setSpeed(speed); } } /** * 倍速设置方法(用于按钮控制) * @param speed 播放速度 */ private setPlaybackSpeed(speed: media.PlaybackSpeed) { const mp = this.avPlayer; if (!mp) { return; } // 检查允许设置速度的状态 const allowedStates = ['prepared', 'playing', 'paused', 'completed']; if (!allowedStates.includes(mp.state)) { Prompt.showToast({ message: '当前状态不支持调整播放速度' }); return; } try { mp.setSpeed(speed); } catch (err) { console.error('设置播放速度失败', err); Prompt.showToast({ message: '设置播放速度失败' }); } } /** * 设置AVPlayer回调函数 * @param avPlayer AVPlayer实例 */ private setAVPlayerCallback(avPlayer: media.AVPlayer) { console.log('状态机初始化'); // 首帧渲染回调 avPlayer.on('startRenderFrame', () => { console.info(`AVPlayer start render frame`); }); // 音量变化回调 avPlayer.on('volumeChange', (vol: number) => { this.vol = vol; }); // seek操作完成回调 avPlayer.on('seekDone', async (seekDoneTime: number) => { console.info('zzz=== avPlayer seek操作', ` seek time is ${seekDoneTime}`); }); // 错误处理回调 avPlayer.on('error', (err: BusinessError) => { console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`); avPlayer.reset(); // 出错时重置播放器 emitter.emit('VedioError'); }); // 播放时间更新回调 avPlayer.on('timeUpdate', async (time: number) => { this.videoTime = time; }); // 状态变化回调 avPlayer.on('stateChange', async (state: string, reason: media.StateChangeReason) => { console.log('zzz=== avPlayer状态变化', `当前状态${state}`); switch (state) { case 'idle': // 空闲状态 console.info('AVPlayer state idle called.'); break; case 'initialized': // 初始化完成 console.log('avPlayer状态机', "设置播放源后"); avPlayer.url; avPlayer.surfaceId = this.surfaceID; // 设置渲染表面 emitter.emit('VideoInitialized'); avPlayer.prepare(); // 准备播放 break; case 'prepared': // 准备完成 console.log('avPlayer状态机', '进入准备状态'); this.endTime = avPlayer.duration; // 获取视频时长 avPlayer.videoScaleType = 1; // 设置视频缩放类型 avPlayer.setVolume(1); // 设置音量 this.VideoSize(this.windowHeight, this.windowWidth); // 调整视频尺寸 this.doPlay(); // 开始播放 break; case 'playing': // 播放中 console.info('AVPlayer state playing called.'); this.playing = true; emitter.emit('VideoPlaying'); break; case 'paused': // 暂停 console.info('AVPlayer state paused called.'); this.playing = false; emitter.emit('VideoPaused'); break; case 'completed': // 播放完成 console.info('AVPlayer state completed called.'); this.playing = false; emitter.emit('VideoCompleted'); break; case 'stopped': // 停止 console.info('AVPlayer state stopped called.'); avPlayer.reset(); // 重置播放器 emitter.emit('VideoStopped'); break; case 'released': // 已释放 console.info('AVPlayer state released called.'); break; default: console.info('AVPlayer state unknown called.'); break; } }); // 视频尺寸变化回调 avPlayer.on('videoSizeChange', (width: number, height: number) => { this.videoProportion = width / height; // 计算宽高比 console.log('myTag', '获取视频比例', this.videoProportion); this.VideoSize(this.windowHeight, this.windowWidth); // 调整显示尺寸 }); } /** * 调整视频显示尺寸以适应容器 * @param height 容器高度 * @param width 容器宽度 */ VideoSize(height: number, width: number) { console.log('myTag', '视频比例调整'); console.log('视频高', height); this.videoHeight = height; if (this.isFull) { // 全屏时保持视频比例 this.videoWidth = height * this.videoProportion; } else { // 非全屏时填满宽度 this.videoWidth = width; } } /** * 开始播放视频 */ async doPlay() { let mp = this.avPlayer; console.log('zzz=== doPlay方法', `mp状态:${mp?.state};`); if (mp?.state && (mp?.state === "prepared" || mp?.state === "paused" || mp?.state === "completed")) { mp.play(); } else { Prompt.showToast({ message: '视频播放出现了点问题' }); } } /** * 暂停视频播放 */ async doPause() { let mp = this.avPlayer; console.log('zzz=== doPause方法', `mp状态:${mp?.state};`); if (mp?.state === 'playing') { mp.pause(); } } /** * 页面隐藏时的处理 */ async onPageHide() { this.doPause(); this.setOrientation(window.Orientation.USER_ROTATION_PORTRAIT); // 恢复竖屏 } /** * 设置屏幕方向 * @param orientation 方向枚举值 */ setOrientation(orientation: number) { window.getLastWindow(getContext(this)).then((win) => { win.setPreferredOrientation(orientation).then((data) => { console.log('setWindowOrientation: ' + orientation + ' Succeeded. Data: ' + JSON.stringify(data)); }).catch((err: string) => { console.log('setWindowOrientation: Failed. Cause: ' + JSON.stringify(err)); }); }).catch((err: string) => { console.log('setWindowOrientation: Failed to obtain the top window. Cause: ' + JSON.stringify(err)); }); } /** * 初始化播放器 */ init() { if (this.avPlayer) { // 一:HTTP视频播放。 // this.avPlayer.url = this.videoSrc; // 设置视频源 // 二:HLS视频播放。 // this.avPlayer.url = "http://XXXXXXXX.m3u8"; // 三:DASH视频播放。 // this.avPlayer.url = "http://XXXXXXXX.mpd"; // 四:通过setMediaSource设置自定义头域及播放优选参数实现初始播放参数设置。 let mediaSource: media.MediaSource = media.createMediaSourceWithUrl(this.videoSrc, { "": "" }); // 设置播放策略,设置为缓冲区数据为20s。 let playbackStrategy: media.PlaybackStrategy = { preferredBufferDuration: 50 }; // 为avPlayer设置媒体来源和播放策略。 this.avPlayer.setMediaSource(mediaSource, playbackStrategy); } } /** * 将毫秒时间转换为MM:SS格式字符串 * @param time 时间(毫秒) * @returns 格式化后的时间字符串 */ getTimeString(time: number): string { const totalSeconds = Math.floor(time / 1000); // 转换为秒 const minutes = Math.floor(totalSeconds / 60); // 计算分钟 const seconds = totalSeconds % 60; // 计算剩余秒数 // 补零处理:确保分钟和秒均为两位 return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } /** * 组件即将出现时的初始化操作 */ async aboutToAppear() { this.avPlayer = await media.createAVPlayer(); // 创建AVPlayer实例 this.setAVPlayerCallback(this.avPlayer); // 设置回调 setTimeout(() => { this.init(); // 初始化播放器 }, 300); // 刚进入时展示控制器 this.showController = true; // 6秒后隐藏控制器 setTimeout(() => { this.showController = false; }, 6000); } /** * 组件即将消失时的清理操作 */ async aboutToDisappear() { if (this.avPlayer) { await this.avPlayer.release(); // 释放播放器资源 this.avPlayer = null; } } /** * 构建UI界面 */ build() { NavDestination() { Column() { // 视频源切换示例按钮 Button('切换视频源') .onClick(() => { // 示例:切换到另一个视频源 this.setMediaSource('https://xxxxxxx.mp4'); }) .margin(10) Stack({ alignContent: Alignment.Top }) { // 视频播放组件 XComponent({ type: XComponentType.SURFACE, controller: this.xComponentController }) .gesture(LongPressGesture.onAction(() => { Prompt.showToast({ message: '长按可切换倍速' }) })) .onLoad(async e => { this.surfaceID = this.xComponentController.getXComponentSurfaceId(); // 获取渲染表面ID }) .height(this.videoHeight) .width(this.videoWidth) .zIndex(11) .onClick(() => { // 点击显示控制器,5秒后自动隐藏 animateTo({ duration: 600 }, () => { this.showController = true; setTimeout(() => { this.showController = false; }, 5000); }); }) .onTouch((event: TouchEvent) => { // 触摸事件处理(用于长按倍速) switch (event.type) { case TouchType.Down: // 启动长按定时器(2秒后触发倍速并显示提示) this.longPressTimer = setTimeout(() => { this.setTemporarySpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X); this.showSpeedTip = true; // 显示提示文字 }, 2000); break; case TouchType.Up: case TouchType.Cancel: // 清除定时器并恢复1倍速 if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } this.setTemporarySpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X); this.showSpeedTip = false; // 隐藏提示文字 break; } }); // 倍速提示文字 if (this.showSpeedTip) { Text('2x快进中') .fontSize(24) .fontWeight(500) .backgroundColor('#00000080') // 半透明黑色背景 .borderRadius(24) .zIndex(12) // 高于视频组件 } // 播放器控制栏 if (this.showController) { // 顶部控制栏(返回按钮) Row() { Image($r('app.media.startIcon')).width('30lpx').onClick(() => { if (this.isFull) { this.isFull = !this.isFull; this.setOrientation(window.Orientation.USER_ROTATION_PORTRAIT); } else { // router.back() } }); } .padding({ left: '25lpx', right: '25lpx', top: '25lpx' }) .width('100%') .zIndex(11) .transition({ type: TransitionType.Delete, opacity: 0 }); // 底部控制栏(播放/暂停、进度条、全屏按钮) Row() { // 播放/暂停按钮 if (this.playing) { Image($r('app.media.startIcon')).width('36lpx').margin({ right: '24lpx' }).onClick(() => { this.playing = false; this.doPause(); }); } else { Image($r('app.media.startIcon')).width('36lpx').margin({ right: '24lpx' }).onClick(() => { this.playing = true; this.doPlay(); }); } // 当前时间显示 Text(this.getTimeString(this.videoTime)).fontColor(Color.White).fontSize('22lpx'); // 进度条 Stack() { Slider({ value: this.videoTime, min: 0, max: this.endTime, step: 0.01, }) .width('438lpx') .videoSlider() .showSteps(false) .onChange((value: number, mode: SliderChangeMode) => { if (mode === 0) { // 开始拖动 console.log('zzz=== 进度条点击事件'); this.stateVideoState = this.playing; this.doPause(); } if (mode === 1) { // 拖动中 this.avPlayer?.seek(value, media.SeekMode.SEEK_CLOSEST); } if (mode === 2) { // 结束拖动 console.log('zzz=== 进度条松开事件'); this.avPlayer?.seek(value, media.SeekMode.SEEK_CLOSEST); if (this.stateVideoState) { this.doPlay(); } } }); } // 总时长显示 Text(this.getTimeString(this.endTime)).fontColor(Color.White).fontSize('22lpx'); // 全屏按钮 Image($r('app.media.startIcon')).width('36lpx').margin({ left: '24lpx' }).onClick(() => { console.log('全屏事件'); if (this.avPlayer) { this.isFull = !this.isFull; if (this.isFull) { this.setOrientation(window.Orientation.USER_ROTATION_LANDSCAPE); } else { this.setOrientation(window.Orientation.USER_ROTATION_PORTRAIT); } } }); } .width('100%') .height('68lpx') .position({ bottom: 0, left: 0 }) .backgroundColor('rgba(23,23,26,0.8)') .alignItems(VerticalAlign.Center) .padding({ left: '24lpx', right: '24lpx' }) .justifyContent(FlexAlign.SpaceBetween) .zIndex(11) .transition({ type: TransitionType.Insert, opacity: 0, translate: { y: '0lpx', x: 0 } }) .transition({ type: TransitionType.Delete, opacity: 0, translate: { y: '0lpx', x: 0 } }); } } .width('100%') .zIndex(11) .backgroundColor('#333333') .height(this.isFull ? '100%' : '422lpx') .onAreaChange((oldValue: Area, newValue: Area) => { // 窗口尺寸变化时调整视频尺寸 this.windowHeight = Number(newValue.height); this.windowWidth = Number(newValue.width); this.VideoSize(Number(newValue.height), Number(newValue.width)); }); // 倍速控制按钮 Row({ space: 10 }) { Button('0.5x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_0_50_X)) Button('1x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X)) Button('1.5x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_50_X)) Button('2x') .onClick(() => this.setPlaybackSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X)) } .margin(20) } .width('100%') .height('100%') .padding({ top: this.isFull ? '0lpx' : '80lpx', bottom: '0lpx' }) .backgroundColor(Color.White); } .hideTitleBar(true) .onHidden(() => { this.onPageHide(); }) .onReady((context: NavDestinationContext) => { }); } } /** * 扩展Slider组件样式 */ @Extend(Slider) function videoSlider() { .trackColor($r('sys.color.white')) .trackThickness('5lpx') .selectedColor('#267fef') .blockBorderColor('rgba(44, 211, 215, 0.3)') .blockBorderWidth('8lpx') .blockColor('#267fef') .blockSize({ width: '25lpx', height: '25lpx' }); } 1.5方案成果总结设备兼容性全面优化:屏幕适配层面,通过宽高比对比实现 “等比缩放 + 边缘填充” 或用户可选的 “智能裁剪 / 完整显示”,并支持屏幕旋转、折叠屏形态变化的实时参数更新,解决了画面拉伸、黑边过大等问题,体验一致性增强。整体而言,方案通过动态适配与反馈强化,显著提升了播放器的设备兼容性及用户操作体验,实现了从 “功能可用” 到 “体验优质” 的升级。
  • [开发技术领域专区] 开发者技术支持-录音降噪技术经验总结
    1.1问题说明鸿蒙应用原生录音接口未集成自适应杂音过滤能力,仅提供基础音频采集功能,无法满足多噪声环境下的录音质量需求。用户在嘈杂环境中使用录音功能时,普遍预期 “基础降噪” 能力,但原生接口仅能过滤设备自身低频噪声,实际效果与用户预期差距显著。1.2原因分析(一)噪声环境动态性:环境噪声的强度和频率特性会随时间改变(如会议室多人说话、户外风声变化),静态噪声模型无法实时更新,导致降噪效果衰减。(二)阈值适配性不足,降噪与语音保留失衡:未结合段落能量特征最大振幅动态调整;段落能量差异大(如轻声语音与高声语音 + 环境噪声并存)时阈值双向适配失效,易致语音失真或噪声残留,最终无法平衡降噪强度与语音保留。(三)滤波窗口权重设计不合理:未突出滤波窗口内中心样本更贴近当前语音真实特征;语音关键细节(如辅音短暂强振幅、语音节奏瞬时变化)被周围噪声或平稳信号平均稀释。1.3解决思路针对上述问题,PCMDenoiser 类采用 “分层降噪 + 动态适应” 的设计思路,核心包括:(一)分段动态阈值:将音频按段分析,基于每段的能量特征(最大振幅、均方根 RMS)计算自适应阈值,平衡降噪强度与语音保留;(二)加权移动平均:对滤波窗口内的样本赋予不同权重(中心样本权重最高),减少对语音细节的模糊;(三)语音活动检测(VAD)+ 自适应噪声跟踪:区分语音段与噪声段,仅在非语音段更新噪声底值,使噪声模型随环境动态调整,提升复杂场景适应性。1.4解决方案(一)阈值降噪(thresholdDenoise):将音频数据按段(最小 1024 样本)划分,计算每段的最大振幅、最小振幅和 RMS,动态生成阈值(取 RMS 的 1.2 倍、振幅的 8%、最小阈值 50 中的最大值),对低于阈值的样本归零,保留强信号。(二)移动平均滤波(movingAverageDenoise):基于缓冲区大小生成加权窗口(中心样本权重最高,向两端线性递减),对每个样本计算加权平均值,在平滑噪声的同时减少对语音细节的破坏。(三)自适应降噪(adaptiveDenoise):结合 VAD 判断语音 / 噪声段:语音段:采用较高阈值(噪声底值的 1.8 倍或局部 RMS 的 25%),并应用轻微平滑(小窗口加权平均),保留细节;噪声段:采用较低阈值(噪声底值的 1.2 倍),对弱信号衰减 70%,增强降噪效果;同时,通过噪声计数器动态更新噪声底值(非语音段连续检测到噪声时,用指数平滑法更新),适配环境变化。代码示例:/** * 简化的PCM降噪器 - 针对的主音+噪声优化 */ class PCMDenoiser { private _bufferSize: number; private _noiseFloor: number = 0; private _denoiseStrength: number = 0.8; constructor(bufferSize: number = 9) { this._bufferSize = Math.max(3, Math.min(21, bufferSize)); } /** * 将ArrayBuffer转换为Int16Array */ private arrayBufferToInt16(arrayBuffer: ArrayBuffer): Int16Array { return new Int16Array(arrayBuffer); } /** * 将Int16Array转换回ArrayBuffer */ private int16ToArrayBuffer(int16Array: Int16Array): ArrayBuffer { return int16Array.buffer; } /** * 计算统计信息 */ private calculateAmplitudeStats(pcmData: Int16Array, start: number, end: number): Stats { let max = -32768; let min = 32767; let sumSq = 0; let sumAbs = 0; const count = end - start; for (let i = start; i < end; i++) { const val = pcmData[i]; max = Math.max(max, val); min = Math.min(min, val); sumSq += val * val; sumAbs += Math.abs(val); } return { max, min, rms: Math.sqrt(sumSq / count), meanAbs: sumAbs / count }; } /** * 改进的阈值降噪 - 更明显的降噪效果 */ thresholdDenoise(pcmBuffer: ArrayBuffer): ArrayBuffer { const pcmData = this.arrayBufferToInt16(pcmBuffer); const result = new Int16Array(pcmData.length); // 计算全局统计 const globalStats = this.calculateAmplitudeStats(pcmData, 0, pcmData.length); // 建立噪声基线 - 使用音频开始和结束部分(假设这些部分是纯噪声) const noiseStart = 0; const noiseEnd = Math.min(pcmData.length, Math.floor(0.1 * 16000)); // 前100ms const noiseStats = this.calculateAmplitudeStats(pcmData, noiseStart, noiseEnd); const noiseBaseline = Math.max(noiseStats.rms * 1.5, 200); this._noiseFloor = noiseBaseline; console.info(`阈值降噪 - 全局RMS: ${globalStats.rms.toFixed(2)}, 噪声基线: ${noiseBaseline.toFixed(2)}`); // 动态阈值 - 根据降噪强度调整 const threshold = noiseBaseline * (1 + this._denoiseStrength * 2); for (let i = 0; i < pcmData.length; i++) { const absValue = Math.abs(pcmData[i]); if (absValue < threshold) { // 强力降噪:低于阈值的信号大幅衰减 const reduction = (threshold - absValue) / threshold; result[i] = Math.round(pcmData[i] * (1 - reduction * this._denoiseStrength * 0.9)); } else { // 保留强信号(主音) result[i] = pcmData[i]; } } return this.int16ToArrayBuffer(result); } /** * 简化的频域降噪 - 使用移动平均滤波器 */ spectralDenoise(pcmBuffer: ArrayBuffer): ArrayBuffer { const pcmData = this.arrayBufferToInt16(pcmBuffer); const result = new Int16Array(pcmData.length); const windowSize = 11; // 奇数大小的窗口 for (let i = 0; i < pcmData.length; i++) { let sum = 0; let count = 0; // 应用移动平均滤波器 for (let j = -Math.floor(windowSize/2); j <= Math.floor(windowSize/2); j++) { const index = i + j; if (index >= 0 && index < pcmData.length) { sum += pcmData[index]; count++; } } result[i] = Math.round(sum / count); } return this.int16ToArrayBuffer(result); } /** * 自适应降噪 - 结合时域和频域处理 */ adaptiveDenoise(pcmBuffer: ArrayBuffer): ArrayBuffer { const pcmData = this.arrayBufferToInt16(pcmBuffer); const result = new Int16Array(pcmData.length); const segmentSize = 256; // 使用音频开始部分建立噪声基线 const noiseStart = 0; const noiseEnd = Math.min(pcmData.length, Math.floor(0.1 * 16000)); const noiseStats = this.calculateAmplitudeStats(pcmData, noiseStart, noiseEnd); const noiseBaseline = Math.max(noiseStats.rms * 1.5, 150); console.info(`自适应降噪 - 噪声基线: ${noiseBaseline.toFixed(2)}`); for (let i = 0; i < pcmData.length; i += segmentSize) { const end = Math.min(i + segmentSize, pcmData.length); const segmentStats = this.calculateAmplitudeStats(pcmData, i, end); // 判断是否为信号段(主音)还是噪声段 const isSignal = segmentStats.rms > noiseBaseline * 2; for (let j = i; j < end; j++) { if (isSignal) { // 信号段:轻度降噪,保留主音特征 result[j] = this.mildNoiseReduction(pcmData, j); } else { // 噪声段:强力降噪 result[j] = this.strongNoiseReduction(pcmData[j]); } } } return this.int16ToArrayBuffer(result); } /** * 轻度噪声消除 - 用于信号段 */ private mildNoiseReduction(pcmData: Int16Array, index: number): number { // 3点平滑 let sum = 0; let count = 0; for (let i = -1; i <= 1; i++) { const idx = index + i; if (idx >= 0 && idx < pcmData.length) { sum += pcmData[idx]; count++; } } return Math.round(sum / count); } /** * 强力噪声消除 - 用于噪声段 */ private strongNoiseReduction(sample: number): number { // 大幅衰减噪声 return Math.round(sample * 0.3); } /** * 设置缓冲区大小 */ setBufferSize(size: number): void { this._bufferSize = Math.max(3, Math.min(21, size)); } /** * 设置降噪强度 */ setDenoiseStrength(strength: number): void { this._denoiseStrength = Math.max(0.3, Math.min(1.0, strength)); } getBufferSize(): number { return this._bufferSize; } } interface Stats { max: number; min: number; rms: number; meanAbs: number; } export default PCMDenoiser; 1.5方案成果总结通过分层设计和动态适应机制,实现了以下成果:降噪效果与语音保真平衡:分段动态阈值和加权滤波避免噪音的过度处理,保留弱语音;环境适应性强:自适应噪声跟踪可实时更新噪声模型,在动态噪声环境中保持稳定效果;灵活性高:支持缓冲区大小调整和噪声跟踪重置,可根据实际场景定制降噪策略。
  • [技术交流] 开发者技术支持-鸿蒙FIDO生物识别认证技术经验总结
    关键技术难点总结该方案通过分层架构设计、统一接口封装、状态机管理等技术手段,成功解决了鸿蒙平台FIDO生物识别认证的复杂性问题,实现了多认证方式支持、完整的设备支持检测流程、统一的错误处理机制以及安全的设备信息验证,为鸿蒙应用的生物识别功能提供了可靠的技术支撑,具有良好的可维护性、扩展性和安全性。1.1 问题说明在鸿蒙应用开发中集成FIDO生物识别认证时,面临以下主要问题:l 多认证方式兼容性问题:需要同时支持指纹、人脸、手势等多种生物识别方式。l 设备支持检测复杂性:需要检测客户端和服务端对FIDO认证方式的支持情况。l 认证流程状态管理:FIDO认证涉及注册、认证、注销等多个状态,状态管理复杂。l 错误处理机制不完善:不同认证方式的错误码和错误信息处理不统一。设备信息获取和验证:需要获取设备唯一标识并进行安全验证。1.2 原因分析· FIDO标准复杂性· FIDO联盟定义了UAF(Universal Authentication Framework)和FIDO2两种标准,在鸿蒙平台实现时需要适配不同的认证协议。· 不同生物识别方式(指纹、人脸、手势)的认证流程和参数要求不同,需要分别处理。· · Canvas与UI组件坐标系统差异鸿蒙平台特殊性· 鸿蒙的ArkTS语言和状态管理系统与Android/iOS不同,需要重新设计架构。· 鸿蒙的设备信息获取API和权限管理机制与Android存在差异。· · 业务场景复杂性· 银行应用对安全性要求极高,需要多层验证和风险控制。· 需要支持登录、交易、注销等多种业务场景,每种场景的认证要求不同。1.3 解决思路· 分层架构设计· 将FIDO功能分为SDK层、服务层、工具层、常量层四个层次。· 每层职责明确,便于维护和扩展。· · 统一接口封装· 封装第三方FIDO SDK,提供统一的业务接口。· 屏蔽底层实现细节,简化上层调用。· 状态机管理· 设计认证状态机,管理注册、认证、注销等状态转换。· 提供统一的错误处理和回调机制。1.4 解决方案 方式1:分层架构实现 ```typescript// SDK层:直接调用第三方SDKexport class FidoSdkService {  private fidoSdk: FidoSdk = new FidoSdk()  async process(context: Context, fidoRequest: FidoRequest):       Promise<FidoResponse> {     return await this.fidoSdk.process(context, fidoRequest)   }    async checkSupport(context: Context, fidoRequest?: FidoRequest): Promise<FidoResponse> {     return await this.fidoSdk.checkSupport(context, fidoRequest)   }}``` 方式2:业务服务封装 ```typescript// 服务层:封装业务逻辑export class FidoService implements IFidoService {  async initFido(context: Context, authType: FidoAuthType, callback: OnResult, obj: object, data: string, payid: string): Promise<void> {    let fidoResp: FidoResponse = await this.process(context, obj)    if (!FidoUtil.getInstance().isCodeEmpty(fidoResp)) {      if (fidoResp.code == FidoStatus.SUCCESS) {        // 处理认证成功逻辑        let dataObj: object = MBTool.jsonParse(data)        let deviceInfoBase64 = await FidoUtil.getInstance().getDeviceInfo(true)        dataObj['DevicesInfo'] = deviceInfoBase64        // ... 其他业务逻辑      }    }  }}``` 方式3:工具类实现 ```typescript// 工具层:提供通用功能export class FidoUtil {  async checkSupport(context: Context, authType: FidoAuthType, transType: FidoTransType, callback: FidoCallBack): Promise<void> {    let authTypeList: FidoAuthType[] = []    authTypeList.push(authType)    let fidoRequest: FidoRequest = {      authTypes: authTypeList    }        // 本地设备支持检查    let respSupport = await FidoSdkService.getInstance().checkSupport(context, fidoRequest)        if (respSupport.code == FidoStatus.SUCCESS) {      if (this.isSupport(authType, respSupport)) {        // 服务端设备支持检查        let params: Record<string, string> = {}        let deviceInfoBase64 = await this.getDeviceInfo(true)        params['DevicesInfo'] = deviceInfoBase64        params['AuthType'] = authType                const respDo = await TransactionTool.submit(HSGlobalUrlConfig.PDeviceSupport, params)        // 处理服务端响应      }    }  }}``` 方式4:常量配置管理 ```typescript// 常量层:统一配置管理export class FidoConstant {  // 认证类型定义  static readonly AUTH_TYPE_FINGERPRINT = FidoAuthType.UAF_FINGER  static readonly AUTH_TYPE_FACE = FidoAuthType.UAF_FACE  static readonly AUTH_TYPE_GESTURE = FidoAuthType.UAF_GESTURE    // 交易类型定义  static readonly Trans_TYPE_FINGERPRINT_LOGIN: FidoTransType = FidoTransType.UAF_LOGIN  static readonly Trans_TYPE_FINGERPRINT_PAY: FidoTransType = FidoTransType.UAF_TRADE    // 错误信息定义  static readonly CHECK_SUPPORT_FAIL: string = '该功能暂不支持您的机型'  static readonly FINGER_PRINT_NOT_AVAILABLE: string = '指纹不可用'}```
  • [开发技术领域专区] 开发者技术支持 - 鸿蒙基于 Network Kit 的网络状态监听与视频播放控制技术总结
    一、关键技术总结1. 问题说明(一)网络状态无感知,视频策略脱节   WiFi 与蜂窝网络切换、断网或弱网时,应用未实时响应,视频仍按原策略播放。如开启 “仅 WiFi 自动播放”,WiFi 断开后未暂停,耗蜂窝流量;弱网无提示,卡顿严重却无反馈,影响体验。(二)自动播放设置不持久,状态不同步   设置的 WiFi / 蜂窝自动播放开关,未持久化存储,应用重启后重置;页面与设置状态未实时同步,修改开关后,视频播放策略未即时更新,出现 “设置关却播放” 的矛盾。(三)资源管理混乱,监听未闭环   页面销毁时未关闭网络监听,重复创建监听实例;网络事件传递延迟,状态变化后未及时触发视频控制,如网络恢复后视频未自动重启。2. 原因分析(一)网络监听能力缺失   未集成 Network Kit 的netAvailable、netLost等事件,无法实时获取网络状态;未区分 WiFi / 蜂窝类型单独处理,导致策略无法差异化适配。(二)状态持久化机制断层   用普通变量存储设置,未通过PersistentStorage持久化;未借助AppStorage同步页面与设置状态,修改后无法即时同步至播放逻辑。(三)资源生命周期失控   在build生命周期创建监听,页面重绘导致多实例冲突;页面销毁未调用unregister关闭监听,实例残留占用资源,新监听无法正常初始化。3. 解决思路(一)构建全场景网络监听体系   基于 Network Kit 订阅netAvailable、netLost、WeakNet等事件,区分 WiFi / 蜂窝类型,实时传递状态至页面,为视频控制提供依据。(二)设置持久化与状态同步   用PersistentStorage持久化自动播放设置,确保重启不丢失;通过AppStorage实现页面与设置的状态联动,修改后即时更新播放策略。(三)标准化资源生命周期管理   在aboutToAppear初始化监听,aboutToDisappear调用unregister释放;用单例模式管理监听实例,避免重复创建,确保事件传递高效。4. 解决方案(一)网络监听工具类单例封装 Network Kit 监听与事件传递,统一处理网络状态:import { connection } from '@kit.NetworkKit'; import { BusinessError, emitter } from '@kit.BasicServicesKit'; import { radio } from '@kit.TelephonyKit'; import { wifiManager } from '@kit.ConnectivityKit'; import { logger } from './Logger'; import { netQuality } from '@kit.NetworkBoostKit'; import { NetworkEventData } from './EmitterData'; import { HashMap, JSON } from '@kit.ArkTS'; type NetworkData = boolean | connection.NetBlockStatusInfo | connection.NetBearType | connection.NetConnectionPropertyInfo | connection.NetCapabilityInfo; // 网络监听emitter事件 export enum NetworkEventName { // 注册网络监听订阅事件 NetObserverRegister, // 网络可用 NetAvailable, // 网络阻塞 NetBlock, // 网络丢失/断开 NetLost, // 当网络能力变化时,如网络从无网络到有网络、从4G切换到5G NetCapabilitiesChange, // 网络不可用 NetUnavailable, // WIFI状态改变 WifiStateChange, // WIFI连接状态改变 WifiConnectionChange, // 弱网 WeakNet, // 订阅网络连接信息变化事件,当网络连接信息变化时,如从无网络到有网络、从Wi-Fi切换到蜂窝 NetConnectionPropertiesChange } export class CellularLinkNetUtils { public static instance: NetUtils; private connectionMap: HashMap<connection.NetBearType, connection.NetConnection> = new HashMap(); // 网络状态监听eventId private networkEventId: number = 10001; // 网络监听相关结果数据 private emitterEvent: NetworkEventData; constructor() { this.emitterEvent = new NetworkEventData(this.networkEventId); } static getInstance(): NetUtils { if (!NetUtils.instance) { NetUtils.instance = new NetUtils(); } return NetUtils.instance; } public getEmitterEvent(): NetworkEventData { return this.emitterEvent; } private setEventPriority(priority: emitter.EventPriority): void { this.emitterEvent.priority = priority; } private postEvent(eventName: NetworkEventName, status: NetworkData, netType?: connection.NetBearType, priority?: emitter.EventPriority) { this.emitterEvent.priority = priority; emitter.emit(this.emitterEvent, { data: new NetEventData(eventName, status, netType) }) } //开启网络监听 public startNetObserve(...netType: connection.NetBearType[]) { netType.forEach((type: connection.NetBearType) => { this.networkObserve(type); if (type === connection.NetBearType.BEARER_WIFI) { this.wifiStateObserve(); } }) } // 停止网络监听 public stopNetObserve(netType: connection.NetBearType) { this.connectionMap.get(netType).unregister(() => { logger.info("Success unregister:" + netType.toString()); }) } // 停止所有网络监听 public stopAllNetObserve() { emitter.off(this.getEmitterEvent().eventId); this.connectionMap.forEach((netConnection: connection.NetConnection, netType: connection.NetBearType) => { netConnection.unregister(() => { logger.info("Success unregister:" + netType.toString()); }); }) } getNetworkConnectionType(): Array<connection.NetBearType> { try { // 获取默认激活的数据网络 let netHandle = connection.getDefaultNetSync(); if (!netHandle || netHandle.netId === 0) { return []; } // 获取网络的类型、拥有的能力等信息 let netCapability = connection.getNetCapabilitiesSync(netHandle); return netCapability.bearerTypes; } catch (e) { let err = e as BusinessError; logger.error('errCode: ' + (err as BusinessError).code + ', errMessage: ' + (err as BusinessError).message); return []; } } judgeHasNet(): boolean { try { let netHandle = connection.getDefaultNetSync(); if (!netHandle || netHandle.netId === 0) { return false; } let netCapability = connection.getNetCapabilitiesSync(netHandle); let cap = netCapability.networkCap || []; if (cap.includes(connection.NetCap.NET_CAPABILITY_VALIDATED)) { //connection.NetCap.NET_CAPABILITY_VALIDATED,该值代表网络是通的,能够发起HTTP和HTTPS的请求。 // 网络信息变化,网络可用 return true; } else { // 网络信息变化,网络不可用 return false; } } catch (e) { let err = e as BusinessError; logger.error("JudgeHasNet" + JSON.stringify(err)); } return false; } // 获取网络状态,查询手机卡注册网络的运营商名称、是否处于漫游状态、设备的网络注册状态等信息 getNetworkStatus() { radio.getNetworkState((err: BusinessError, data: radio.NetworkState) => { if (err) { logger.error(`getNetworkState failed, callback: err->${JSON.stringify(err)}`); } // regState字段表示设备的网络注册状态 // (REG_STATE_POWER_OFF,值为3)蜂窝无线电已关闭,modem下电,无法和网侧进行通信 logger.info("Success getNetworkStatus:" + JSON.stringify(data)); }); } async getSignalType(): Promise<radio.SignalInformation[]> { let slotId: number = await radio.getPrimarySlotId(); let data: Array<radio.SignalInformation> = radio.getSignalInformationSync(slotId); // signalType代表网络类型NetworkType let signalType = data[0].signalType; logger.info("getSignalType:" + JSON.stringify(data)); return data; } getWifiStatus(): boolean { try { let isWifiActive: boolean = wifiManager.isWifiActive(); return isWifiActive; } catch (error) { logger.error("failed:" + JSON.stringify(error)); } return false; } getWifiIsConnected(): boolean { try { let ret = wifiManager.isConnected(); logger.info("isConnected:" + ret); return ret; } catch (error) { logger.error("failed:" + JSON.stringify(error)); } return false; } async getSignalLevel(): Promise<number> { try { let wifiLinkedInfo: wifiManager.WifiLinkedInfo = await wifiManager.getLinkedInfo(); let rssi = wifiLinkedInfo.rssi; let band = wifiLinkedInfo.band; let level = wifiManager.getSignalLevel(rssi, band); logger.info("level:" + JSON.stringify(level)); return level; } catch (error) { logger.error("failed:" + JSON.stringify(error)); } return -1; } networkObserve(netType: connection.NetBearType) { // TODO:根据网络类型,设置不同的网络监听,用于WI-FI和蜂窝网络切换时判断各自网络状态的变化。 let netConnection: connection.NetConnection = connection.createNetConnection({ netCapabilities: { bearerTypes: [netType] } }) // 注册网络监听,注册成功后才能监听到对应类型的网络状态变化 netConnection.register((error: BusinessError) => { let result = true; if (error) { logger.info("NetUtils", "NetType :" + netType + ", network register failed: " + JSON.stringify(error)); result = false; } logger.info("NetUtils", "NetType :" + netType + ", network register succeed"); this.postEvent(NetworkEventName.NetObserverRegister, result, netType); }); // 网络能力改变监听,当网络能力变化时,如网络从无网络到有网络、从4G切换到5G时,会触发该事件。 netConnection.on('netCapabilitiesChange', (data: connection.NetCapabilityInfo) => { logger.info("NetUtils", "NetType :" + netType + ", network netCapabilitiesChange: " + JSON.stringify(data)); this.postEvent(NetworkEventName.NetCapabilitiesChange, data, netType); }) // 网络可用监听,当网络可用时触发该事件。 netConnection.on("netAvailable", (data: connection.NetHandle) => { logger.info("NetUtils", "NetType :" + netType + ", network succeeded to get netAvailable: " + JSON.stringify(data)); // 检查默认数据网络是否被激活,使用同步方式返回接口,如果被激活则返回true,否则返回false。 }); // 订阅网络阻塞状态事件,当网络阻塞时,如网络性能下降、数据传输出现延迟等情况时,会触发该事件 netConnection.on('netBlockStatusChange', (data: connection.NetBlockStatusInfo) => { logger.info("NetUtils", "NetType :" + netType + ", network netBlockStatusChange " + JSON.stringify(data)); this.postEvent(NetworkEventName.NetBlock, data, netType) }); // 网络连接信息变化监听,当网络连接信息变化时,如从无网络到有网络、从Wi-Fi切换到蜂窝时,会触发该事件。 netConnection.on('netConnectionPropertiesChange', (data: connection.NetConnectionPropertyInfo) => { logger.info("NetUtils", "NetType :" + netType + ", network netConnectionPropertiesChange " + JSON.stringify(data)); this.postEvent(NetworkEventName.NetConnectionPropertiesChange, data, netType); }); // 订阅网络丢失事件,当网络严重中断或正常断开时触发该事件 // 网络丢失是指网络严重中断或正常断开事件,当断开Wi-Fi时,是属于正常断开网络连接,会触发netLost事件 netConnection.on('netLost', (data: connection.NetHandle) => { this.postEvent(NetworkEventName.NetLost, true, netType) logger.info("NetUtils", "NetType :" + netType + ", Succeeded to get netLost: " + JSON.stringify(data)); }); // 订阅网络不可用事件,当网络不可用时触发该事件 // 网络不可用是指网络不可用事件,当连接的网络不能使用时,会触发netUnavailable事件。 netConnection.on('netUnavailable', () => { logger.info("NetUtils", "NetType :" + netType + ", Succeeded to get unavailable net event"); this.postEvent(NetworkEventName.NetUnavailable, true, netType); }); this.connectionMap.set(netType, netConnection); } wifiStateObserve() { // 注册WLAN状态改变事件 // 0,未激活;1,已激活;2,激活中;3:去激活中 wifiManager.on("wifiStateChange", (result: number) => { logger.info("NetUtils", "wifiStateChange: " + result); this.postEvent(NetworkEventName.WifiStateChange, result); }); // 注册WLAN连接状态改变事件 // 0,已断开;1,已连接 wifiManager.on("wifiConnectionChange", (result: number) => { logger.info("NetUtils", "wifiConnectionChange: " + result); this.postEvent(NetworkEventName.WifiConnectionChange, result); }); } parseResult(data: emitter.EventData): string { if (data.data) { if (!data.data.eventName) { logger.info("parseResult data.data.eventName is undefined.") return ""; } } else { logger.info("parseResult data.data is undefined.") return ""; } let result = ""; let name: number = (data.data)!.eventName ?? -1; switch (name) { case NetworkEventName.NetObserverRegister.valueOf(): result = "NetObserverRegister"; break; case NetworkEventName.NetAvailable.valueOf(): result = "NetAvailable"; break; case NetworkEventName.NetBlock.valueOf(): result = "NetBlock"; break; case NetworkEventName.NetLost.valueOf(): result = "NetLost"; break; case NetworkEventName.NetCapabilitiesChange.valueOf(): result = "NetCapabilitiesChange"; break; case NetworkEventName.NetUnavailable.valueOf(): result = "NetUnavailable"; break; case NetworkEventName.NetConnectionPropertiesChange.valueOf(): result = "NetConnectionPropertiesChange"; break; case NetworkEventName.WifiStateChange.valueOf(): result = "WifiStateChange"; break; case NetworkEventName.WifiConnectionChange.valueOf(): result = "WifiConnectionChange"; break; case NetworkEventName.WeakNet.valueOf(): result = "WeakNet"; break; default: result = name.toString(); break } let netTemp: string = ""; let temp: number = data.data!.netType ?? -1; if (temp === 1) { netTemp = "WIFI"; } if (temp === 0) { netTemp = "CELLULAR"; } if (temp === -1) { netTemp = temp.toString(); } result = result + "------" + (data.data!.status ?? -1) + "------" + netTemp; return result; } sceneChangeObserve() { try { netQuality.on('netSceneChange', (list: Array<netQuality.NetworkScene>) => { if (list.length > 0) { list.forEach((networkScene) => { // 回调信息处理 logger.info(`Succeeded receive netSceneChange info`); if (networkScene.scene == 'weakSignal' || networkScene.scene == 'congestion') { // 表示为弱网场景 logger.info(`The current network is weak`); this.postEvent(NetworkEventName.WeakNet, true) } else { this.postEvent(NetworkEventName.WeakNet, false) } }); } }); } catch (err) { logger.error('errCode: ' + (err as BusinessError).code + ', errMessage: ' + (err as BusinessError).message); } } } export class NetEventData { eventName: NetworkEventName; status: NetworkData; netType: connection.NetBearType; constructor(eventName: NetworkEventName, status: NetworkData, netType: connection.NetBearType) { this.eventName = eventName; this.status = status; this.netType = netType; } } (二)视频播放组件处理网络事件,控制视频播放,同步设置状态:import { NetEventData, NetUtils, NetworkEventName } from '../utils/NetUtils'; import { emitter } from '@kit.BasicServicesKit'; import { connection } from '@kit.NetworkKit'; import { logger } from '../utils/Logger'; import { Prompt } from '@kit.ArkUI'; import { CellularSetting } from './CellularSetting'; // 将自动播放设置通过PersistentStorage进行本地持久化存储,避免每次打开应用都需要重新设置 PersistentStorage.persistProp('cellular_auto_play', false); PersistentStorage.persistProp('wifi_auto_play', false); const innerEvent: emitter.InnerEvent = { // 左上角返回按钮点击事件传递的eventId eventId: 6 }; @Builder export function PageThreeBuilder() { CellularLink() } @Component export struct CellularLink { pathStack: NavPathStack = new NavPathStack(); private navPathStack: NavPathStack = new NavPathStack(); // 视频控制器 controller: VideoController = new VideoController(); // WI-FI自动播放 @StorageLink("wifi_auto_play") wifiAutoPlay: boolean = false; // 3G/4G/5G自动播放 @StorageLink("cellular_auto_play") cellularAutoPlay: boolean = false; // 在线视频地址 private videoUrl: string = "https://xxx/xxx/xxx.mp4"; // 注册路由返回函数,案例插件不触发 popRouter: () => void = () => { }; // 使用流量播放弹窗 networkDialog: CustomDialogController | null = new CustomDialogController({ builder: NetworkDialogComponent({ title: $r('app.string.network_status_observer_cellular_dialog_title'), message: $r('app.string.network_status_observer_cellular_dialog_message'), cancel: () => { // 用户点击取消,则停止播放 this.pausePlay(); this.networkDialog?.close(); }, confirm: () => { // 用户点击确认,则继续播放 this.startPlay(); this.networkDialog?.close(); } }), cornerRadius: $r('app.integer.network_status_observer_cellular_dialog_message_radius'), alignment: DialogAlignment.Center }) // 网络监听回调 netObserver(data: emitter.EventData) { if (!data.data) { logger.info("netObserver data.data is undefined."); return; } logger.info("network observe result : " + NetUtils.getInstance().parseResult(data)); let netEventData: NetEventData = data.data! as NetEventData; let eventName: NetworkEventName = netEventData.eventName ?? -1; switch (eventName) { case NetworkEventName.NetAvailable: // WI-FI是可用状态 if (netEventData.netType === connection.NetBearType.BEARER_WIFI) { // 如果开了WI-FI自动播放,则继续播放 if (this.wifiAutoPlay) { this.startPlay(); } } break; case NetworkEventName.NetBlock: break; case NetworkEventName.NetLost: // 如果WI-FI网络丢失,则通过wifiInterrupt方法判断是否需要继续播放 if (netEventData.netType === connection.NetBearType.BEARER_WIFI) { this.wifiInterrupt(); } break; case NetworkEventName.NetUnavailable: // 如果WI-FI不可用,则通过wifiInterrupt方法判断是否需要继续播放 if (netEventData.netType === connection.NetBearType.BEARER_WIFI) { this.wifiInterrupt(); } break; case NetworkEventName.WeakNet: // 如果是弱网环境,则弹出提示,实际应用开发中可以通过该结果自动实现分辨率自动切换 if (netEventData.status) { Prompt.showToast({ message: getContext().resourceManager.getStringSync($r('app.string.network_status_observer_weak')) }); } break; default: logger.debug("当前网络状态:" + eventName); break; } } /** * WI-FI中断时的操作 * 如果开启了3G/4G/5G自动播放,则继续播放,并且提示正在使用流量播放 * 如果关闭了3G/4G/5G自动播放,则弹出弹窗,让用户选择是否继续使用流量播放 */ wifiInterrupt() { if (NetUtils.getInstance().getNetworkConnectionType()[0] === connection.NetBearType.BEARER_CELLULAR) { if (this.cellularAutoPlay) { Prompt.showToast({ message: getContext().resourceManager.getStringSync($r('app.string.network_status_observer_user_cellular')) }); } else { this.pausePlay(); this.networkDialog?.open(); } } } /** * 是否自动播放 * @returns true:自动播放,false,不自动播放 */ autoPlay(): boolean { let autoPlay: boolean = false; // 如果网络是可用的 if (NetUtils.getInstance().judgeHasNet()) { // 获取当前连接的网络类型 let currentNetType: connection.NetBearType = NetUtils.getInstance().getNetworkConnectionType()[0]; switch (currentNetType) { case connection.NetBearType.BEARER_CELLULAR: // 蜂窝网络 // 如果开启了3G/4G/5G自动播放,则设置autoPlay为true if (this.cellularAutoPlay) { autoPlay = true; } break; case connection.NetBearType.BEARER_WIFI: // WIFI网络 case connection.NetBearType.BEARER_ETHERNET: // 以太网网络(模拟器) // 如果设置了WI-FI自动播放,则设置autoPlay为true if (this.wifiAutoPlay) { autoPlay = true; } break; } } return autoPlay; } // 开始播放 startPlay() { if (this.controller) { this.controller.start(); } } // 暂停播放 pausePlay() { if (this.controller) { this.controller.pause(); } } onPageShow(): void { if (this.autoPlay()) { this.startPlay(); } } onPageHide(): void { this.pausePlay(); } aboutToAppear(): void { // 通过emitter接受网络监听结果 emitter.on(NetUtils.getInstance().getEmitterEvent(), (data: emitter.EventData) => { if (data) { this.netObserver(data); } else { logger.info("aboutToAppear emitter on error, data is undefined."); } }); // 开启蜂窝网络和WI-FI网络状态的监听 NetUtils.getInstance() .startNetObserve(connection.NetBearType.BEARER_CELLULAR, connection.NetBearType.BEARER_WIFI); // 收到eventId为6的事件后执行回调函数 emitter.on(innerEvent, () => { // 在案例主页时,返回瀑布流 if (this.navPathStack.size() === 0) { this.popRouter(); } }); } aboutToDisappear(): void { // 当页面销毁时,停止所有网络监听 NetUtils.getInstance().stopAllNetObserve(); if (this.controller) { this.controller.stop(); } // 销毁事件监听 emitter.off(innerEvent.eventId); } @Builder buildMap(name: string, param: ESObject) { if (name === "CellularLink") { NavDestination() { CellularSetting() }.hideTitleBar(true) } } build() { NavDestination() { Navigation(this.navPathStack) { Column() { Row() { Text($r('app.string.network_status_observer_auto_play_setting')) }.justifyContent(FlexAlign.End) .width($r('app.string.network_status_observer_percent_100')) .onClick(() => { this.navPathStack.pushPath({ name: "CellularLink" }) }) Video({ src: this.videoUrl, controller: this.controller }) .height(300) .width('100%') .autoPlay(this.autoPlay()) .id("id_network_status_observer_video") } .height('100%') .width('100%') }.hideTitleBar(true) .hideToolBar(true) .navDestination(this.buildMap) } .title('Cellular_Link') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } // 流量播放提示框 @CustomDialog export struct NetworkDialogComponent { controller?: CustomDialogController; // 标题 title: ResourceStr = ""; // 提示信息 message: ResourceStr = ""; // 取消事件 cancel: () => void = () => { }; // 确认事件 confirm: () => void = () => { }; build() { Column() { Text(this.title) .fontSize(16) .fontWeight(FontWeight.Bold) Text(this.message) .padding({ left: 20, right: 20 }) .margin({ top: 12 }) Line().height(1) .backgroundColor(Color.Blue) Row() { Button('取消') .layoutWeight(1) .borderRadius({ bottomRight: 0, topLeft: 0, topRight: 0, bottomLeft:10 }) .type(ButtonType.Normal) .backgroundColor(Color.White) .fontColor(Color.Grey) .onClick(() => { this.cancel(); }) Line().width(1) .backgroundColor(Color.Blue) Button("确认") .layoutWeight(1) .borderRadius({ bottomRight: 10, topLeft: 0, topRight: 0, bottomLeft: 0 }) .type(ButtonType.Normal) .backgroundColor(Color.White) .fontColor(Color.Blue) .onClick(() => { this.confirm(); }) }.margin({ top:20 }) } .padding({ top: 12 }) .alignItems(HorizontalAlign.Center) .backgroundColor(Color.White) } } (三)自动播放设置组件用 Toggle 控制设置,通过PersistentStorage持久化:import { logger } from "../utils/Logger"; import { emitter } from "@kit.BasicServicesKit"; @Component export struct CellularSetting { private navPathStack: NavPathStack = new NavPathStack() // WI-FI自动播放 @StorageLink("wifi_auto_play") wifiAutoPlay: boolean = false; // 3G/4G/5G自动播放 @StorageLink("cellular_auto_play") cellularAutoPlay: boolean = false; aboutToAppear(): void { emitter.on({ eventId: 6 }, () => { this.navPathStack.pop(); }); } build() { Column() { Text('自动播放设置') .fontSize(12) .fontColor(Color.Grey) Row() { Text('3G/4G/5G自动播放') .width('90%') Toggle({ type: ToggleType.Switch, isOn: this.cellularAutoPlay }) .selectedColor($r('app.color.network_status_observer_setting_toggle_selected')) .switchPointColor(Color.White) .onChange((isOn: boolean) => { logger.info('Component status:' + isOn); AppStorage.setOrCreate('cellular_auto_play', isOn); PersistentStorage.persistProp('cellular_auto_play', isOn); }) .width('10') .id('id_network_status_observer_cellular_toggle') }.margin({ top: 10 }) .width('100%') Row() { Text("WI-FI自动播放") .width('90%') Toggle({ type: ToggleType.Switch, isOn: this.wifiAutoPlay }) .selectedColor($r('app.color.network_status_observer_setting_toggle_selected')) .switchPointColor(Color.White) .onChange((isOn: boolean) => { logger.info('Component status:' + isOn); AppStorage.setOrCreate('wifi_auto_play', isOn); PersistentStorage.persistProp('wifi_auto_play', isOn); }) .width('10%') .id('id_network_status_observer_wifi_toggle') } .margin({ top: 10 }) .width('100%') } .alignItems(HorizontalAlign.Start) .width('100%') .height('100%') .padding({ left: 10, right: 10, top: 30 }) .width('100%') .height('100%') } } (四)关键交互流程用户进入视频页,aboutToAppear订阅网络事件、开启监听;切换网络时,handleNetEvent触发,按设置控制视频播放 / 暂停;进入设置页,修改 Toggle 开关,通过PersistentStorage持久化;退出页面,aboutToDisappear关闭监听、释放资源。5.方案成果总结   本次基于鸿蒙 Network Kit 的网络状态监听与视频播放控制方案成效显著。通过单例模式封装网络监听工具类,实现 WiFi 与蜂窝网络状态的实时感知,精准捕捉网络切换、弱网等事件;利用 PersistentStorage 持久化自动播放设置,结合 AppStorage 实现状态即时同步,解决重启重置与页面不同步问题;标准化资源生命周期管理,在页面生命周期内规范监听的创建与销毁,避免实例冲突与资源泄露。最终实现网络状态变化时视频策略的智能适配,提升了用户体验与应用稳定性。
  • [技术干货] 开发者技术支持-鸿蒙APP考勤打卡技术方案
    1、关键技术难点总结1.1 问题说明问题一:数据丢失困扰员工通过桌面卡片快速打卡,第一次使用时一切正常,卡片记录了"上次打卡时间"。但在后面再次打卡时,却发现显示变成了"暂无记录",仿佛之前的打卡从未发生过。经过分析发现,这是卡片生命周期管理复杂导致的:系统在夜间可能回收了卡片进程,打卡记录随之丢失,卡片重建后无法恢复之前的状态。问题二:位置获取卡顿当他在地下车库点击卡片打卡按钮时,界面一直停留在"定位中…“状态,等待了近一分钟才提示"位置获取失败”,这暴露了位置服务与异步处理的技术挑战:getCurrentLocation异步回调的时机难以预测,弱信号环境下的位置精度验证影响打卡有效性,而UI状态与异步数据获取的同步也成为了关键问题。1.2 原因分析数据丢失原因鸿蒙卡片基于FormExtensionAbility运行,这是一个独立于主应用的轻量级进程。当系统内存紧张或用户长时间未使用时,系统会主动回收卡片进程以释放资源。更复杂的是,鸿蒙允许用户同时添加多个相同卡片实例,但每个实例都有独立的空间,缺乏跨实例的数据同步机制。位置服务问题原因在地下车库遇到的位置获取延迟问题涉及多个技术层面。首先getCurrentLocation方法采用异步回调机制,但在地下车库等弱信号环境下,GPS信号微弱,这个过程可能耗时数十秒甚至失败。更关键的是,位置数据的获取涉及到经纬度转换、地址解析等多个步骤,每个步骤都可能因为网络问题而延迟。2、解决思路2.1 架构设计思路采用分层架构设计,将功能模块化:UI层:卡片展示层,负责用户交互业务层:打卡逻辑管理,状态管理服务层:位置服务、网络服务封装数据层:本地存储、配置管理2.2 技术选型思路使用LocalStorage进行卡片状态管理,preferences数据持久化采用Promise/async-await处理异步操作通过封装工具类提高代码复用性实现统一的日志管理和错误处理3、解决方案3.1 卡片配置与生命周期管理步骤1:配置卡片基础信息// form_config.json { "forms": [ { "name": "widget", "displayName": "$string:widget_display_name", "description": "$string:widget_desc", "src": "./ets/widget/pages/WidgetCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDynamic": true, "isDefault": true, "updateEnabled": false, "scheduledUpdateTime": "10:30", "updateDuration": 1, "defaultDimension": "2*4", "supportDimensions": ["2*4"] } ] } 步骤2:实现FormExtensionAbility// EntryFormAbility.ets import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit'; import { Want } from '@kit.AbilityKit'; import { BusinessError as BasicServicesError } from '@kit.BasicServicesKit'; import { ClockInManager } from '../widget/pages/WidgetCardManager'; import { StorageUtils } from '../utils/StorageUtils'; const MINUTE: number = 10; export class FormData { // 每一张卡片创建时都会被分配一个唯一的id formId: string = ''; lastClockTime: string = ''; lastAddress: string | undefined = ''; } export default class EntryFormAbility extends FormExtensionAbility { onAddForm(want: Want) { // Called to return a FormBindingData object. let storageUtils = new StorageUtils(this.context) let formData = new FormData() formData.formId = want.parameters!['ohos.extra.param.key.form_identity'].toString(); // 加载上次打卡记录,实际开发中还需根据上班时间处理更加详细 let lastClockTime = storageUtils.loadDataSync('lastClockTime') let lastAddress = storageUtils.loadDataSync('lastAddress') formData.lastClockTime = lastClockTime && lastClockTime.length > 0 ? lastClockTime : '暂无记录' formData.lastAddress = lastAddress && lastAddress.length > 0 ? lastAddress : '尚未打卡' console.info("call onAddForm: " + formData.lastClockTime + '-' + storageUtils.loadDataSync('lastAddress')) return formBindingData.createFormBindingData(formData); } onCastToNormalForm(formId: string) { // Called when the form provider is notified that a temporary form is successfully // converted to a normal form. } onUpdateForm(formId: string) { try { // 设置过10分钟后更新卡片内容 formProvider.setFormNextRefreshTime(formId, MINUTE, (err: BasicServicesError) => { if (err) { console.info(`Failed to setFormNextRefreshTime. Code: ${err.code}, message: ${err.message}`); return; } else { console.info('Succeeded in setFormNextRefreshTiming.'); } }); } catch (err) { console.info(`Failed to setFormNextRefreshTime. Code: ${(err as BasicServicesError).code}, message: ${(err as BasicServicesError).message}`); } } onFormEvent(formId: string, message: string) { // Called when a specified message event defined by the form provider is triggered. // 接收到卡片通过message事件传递的数据 let storageUtils = new StorageUtils(this.context) let formData = new FormData() formData.formId = formId formData.lastClockTime = JSON.parse(message)['lastClockTime'] ClockInManager.clockIn().then((data) => { console.info('clock data: ' + JSON.stringify(data)) console.info('clock lastAddress: ' + data.location?.address) console.info('clock lastClockTime: ' + formData.lastClockTime) formData.lastAddress = data.location?.address storageUtils.saveData('lastAddress', data.location?.address) storageUtils.saveData('lastClockTime', JSON.parse(message)['lastClockTime']) let formInfo: formBindingData.FormBindingData = formBindingData.createFormBindingData(formData) // 返回数据给对应的卡片 formProvider.updateForm(formId, formInfo) }); } onRemoveForm(formId: string) { // Called to notify the form provider that a specified form has been destroyed. } onAcquireFormState(want: Want) { // Called to return a {@link FormState} object. return formInfo.FormState.READY; } } 3.2 卡片UI实现与状态管理步骤3:创建卡片主界面// WidgetCard.ets import { LocationData } from '../../utils/LocationUtil'; let storageUpdate = new LocalStorage(); @Entry(storageUpdate) @Component struct LocationCard { // 接收onAddForm中返回的卡片Id @LocalStorageProp("formId") formId: string = "xxx" @LocalStorageProp("location") location: LocationData | null = null @LocalStorageProp("lastAddress") lastAddress: string = '尚未打卡' @LocalStorageProp("lastClockTime") lastClockTime: string = '暂无记录'; @State buttonColor: Color = Color.Blue; aboutToAppear(): void { } build() { Column() { // 标题 Text('打卡点: ' + this.lastAddress) .fontSize(14) .fontColor(Color.Black) .margin({ top: 5 }); Text(`上次打卡: ${this.lastClockTime}`) .fontSize(14) .fontColor(Color.Black) .margin({ bottom: 5 }) Button('打卡') .width(80) .height(80) .backgroundColor((this.lastAddress && this.lastAddress != '尚未打卡') ? Color.Green : Color.Blue) .fontColor(Color.White) .margin({ top: 5, bottom: 20 }) .onClick(() => { postCardAction(this, { action: 'message', // 固定为message类型 params: { lastClockTime: new Date().toLocaleTimeString() } }); }) .opacity(0.7) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } 3.3 位置服务封装步骤4:实现位置获取工具类// LocationUtil.ets import { BusinessError } from '@ohos.base'; import { geoLocationManager } from '@kit.LocationKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; export interface LocationData { latitude: number; longitude: number; address?: string } export class LocationUtil { // 获取经纬度 public async getLocation(): Promise<LocationData> { let locationData: LocationData = { 'latitude': 0, 'longitude': 0 } try { let location: geoLocationManager.Location = await geoLocationManager.getCurrentLocation(); if (location) { let longitude = location.longitude let latitude = location.latitude await this.convertLatToPosition(longitude, latitude, locationData) } } catch (err) { console.info('getCurrentLocation error') hilog.error(0x000, 'testTag', 'errCode:' + JSON.stringify(err)); } return locationData; } // 将经纬度转换为地理位置 async convertLatToPosition(longitude: number, latitude: number, locationData: LocationData) { let reverseGeocodeRequest: geoLocationManager.ReverseGeoCodeRequest = { 'latitude': latitude, 'longitude': longitude, 'maxItems': 1 }; locationData.latitude = latitude locationData.longitude = longitude try { await geoLocationManager.getAddressesFromLocation(reverseGeocodeRequest).then((data) => { locationData.address = data[0].placeName ? data[0].placeName : '' console.info(`convertLatToPosition: ${JSON.stringify(locationData)}`); }).catch((error: BusinessError) => { hilog.error(0x000, 'testTag', 'promise, getAddressesFromLocation: error=' + JSON.stringify(error)); }); } catch (err) { hilog.error(0x000, 'testTag', 'errCode:' + JSON.stringify(err)); } } } 3.4 网络服务封装步骤5:实现API服务类// ApiService.ets import http from '@ohos.net.http'; import { Logger } from './Logger'; export class ApiService { private static readonly BASE_URL = 'https://api.example.com/attendance'; private static readonly TIMEOUT = 30000; // 打卡API调用 static async clockIn(data: string): Promise<ESObject> { //模拟打卡API调用,实际需求改成调用考勤数据API console.info('clockIn:' + data) return {'code': 0, 'message': '打卡成功'} } // 获取打卡记录 static async getClockRecords(userId: string, startDate: string, endDate: string): Promise<ESObject> { const httpRequest = http.createHttp(); const url = `${ApiService.BASE_URL}/records?userId=${userId}&startDate=${startDate}&endDate=${endDate}`; try { const response = await httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: ApiService.TIMEOUT, readTimeout: ApiService.TIMEOUT }); if (response.responseCode === 200) { return JSON.parse(response.result.toString()); } else { throw new Error(`获取记录失败: ${response.responseCode}`); } } catch (error) { Logger.error(`Get records error: ${error.message}`); } finally { httpRequest.destroy(); } } } 3.5 业务逻辑管理步骤6:实现打卡管理器// WidgetCardManager.ets import { LocationData, LocationUtil } from '../../utils/LocationUtil'; import { ApiService } from '../../utils/ApiService'; import { Logger } from '../../utils/Logger'; import { deviceInfo } from '@kit.BasicServicesKit'; export interface ClockInResult { success: boolean; message: string; timestamp?: number; location?: LocationData; } export interface DeviceInfo { deviceId: string; model: string } export class ClockInManager { // 执行打卡操作 static async clockIn(): Promise<ClockInResult> { try { // 1. 获取位置信息 let locationUtil: LocationUtil = new LocationUtil(); let location: LocationData = await locationUtil.getLocation(); // 2. 获取设备信息(可选) const deviceInfo = await ClockInManager.getDeviceInfo(); const data = JSON.stringify({ latitude: location.latitude, longitude: location.longitude, timestamp: Date.now(), deviceId: deviceInfo.deviceId, deviceModel: deviceInfo.model }) // 3. 调用打卡API,实际业务中需要对各种打卡响应状态做处理,以便清晰对打卡结果反馈 const result: ESObject = await ApiService.clockIn(data); return { success: result.code === 0, message: result.message || '打卡成功', timestamp: Date.now(), location: location }; } catch (error) { Logger.error(`Clock in failed: ${error.message}`); return { success: false, message: error.message || '打卡失败' }; } } // 获取设备信息 private static async getDeviceInfo(): Promise<DeviceInfo> { try { return { deviceId: deviceInfo.udid, model: deviceInfo.hardwareModel }; } catch (error) { Logger.warn(`Failed to get device info: ${error.message}`); return { deviceId: 'unknown', model: 'unknown' }; } } } 3.6 数据持久化// StorageUtils.ets 实际业务中根据需求进行持久化操作 import { common } from "@kit.AbilityKit"; import { preferences } from "@kit.ArkData"; import { BusinessError } from "@kit.BasicServicesKit"; export class StorageUtils { private context: common.Context; constructor(context: common.Context) { console.info('context->' + JSON.stringify(context)) this.context = context; } // 读取数据 async loadData(key: string): Promise<string> { try { let pref = await preferences.getPreferences(this.context, 'cardData'); return await pref.get(key, '') as string; } catch (err) { console.error(`Failed to load data. Code: ${err.code}, message: ${err.message}`); return ''; } } loadDataSync(key: string): string { try { let options: preferences.Options = { name: 'cardData' }; let pref = preferences.getPreferencesSync(this.context, options); return pref.getSync(key, '') as string; } catch (err) { console.error(`Failed to load data. Code: ${err.code}, message: ${err.message}`); return ''; } } // 保存数据 async saveData(key: string, value: string | undefined) { try { if (!value) return let pref = await preferences.getPreferences(this.context, 'cardData'); await pref.put(key, value); pref.flush((err: BusinessError) => { if (err) { console.error(`Failed to flush. Code:${err.code}, message:${err.message}`); return; } console.info('Succeeded in flushing.'); }); } catch (err) { console.error(`Failed to save data. Code: ${err.code}, message: ${err.message}`); } } } 3.7 日志管理工具步骤7:统一日志管理// Logger.ets const TAG = 'ClockInAtomicService'; export class Logger { static debug(message: string): void { console.debug(`[${TAG}] ${message}`); } static info(message: string): void { console.info(`[${TAG}] ${message}`); } static warn(message: string): void { console.warn(`[${TAG}] ${message}`); } static error(message: string): void { console.error(`[${TAG}] ${message}`); } } 4、方案成果总结稳定的卡片生命周期管理: 使用preferences实现数据持久化,卡片重建后状态自动恢复,支持多卡片实例独立运行可靠的位置服务集成: 封装位置获取逻辑,提高代码复用性,实现地理位置反向解析完善的网络请求机制: 统一的API调用封装,超时和错误处理机制,网络状态的实时反馈
  • [开发技术领域专区] 开发者技术支持-鸿蒙瀑布流列表Item插槽自定义布局组件技术总结
    一、关键技术难点总结1.问题说明在鸿蒙应用开发过程中,经常需要实现瀑布流布局来展示商品、图片等内容。传统实现方式存在以下问题:代码重复:每个页面都需要重复编写瀑布流布局逻辑布局固化:Item布局与瀑布流容器强耦合,难以复用维护困难:业务逻辑分散在多个地方,修改成本高类型限制:数据源类型固定,无法适应不同业务场景换 “推荐 / 社区 / 美食” 等分类时,数据更新无平滑过渡,页面易出现短暂空白,影响操作连贯性。2. 原因分析通过对原始代码的分析,发现问题的根源在于:架构设计不合理:瀑布流容器与具体Item布局没有分离数据绑定僵化:使用固定的数据类型,缺乏泛型支持扩展性不足:没有提供灵活的插槽机制来自定义布局状态管理分散:加载状态、分类切换等逻辑与UI渲染混杂3. 解决思路基于以上分析出以下解决方案:组件化封装:将瀑布流的核心功能抽象为独立组件,实现关注点分离插槽机制:通过@BuilderParam提供灵活的布局自定义能力通用数据源:设计支持任意类型的数据源管理类,提高组件通用性事件驱动:通过回调函数实现组件与父页面的通信4. 解决方案(一)通用数据源设计export class BasicDataSource<T> implements IDataSource { private listeners: DataChangeListener[] = []; public dataArray: T[] = []; constructor(dataArray: T[]) { this.dataArray = dataArray; } // 核心方法实现 totalCount(): number { return this.dataArray.length; } getData(index: number): T { return this.dataArray[index]; } // 数据操作接口 addData(newData: T[]): void { const startIndex = this.dataArray.length; this.dataArray = this.dataArray.concat(newData); this.notifyDataAdd(startIndex, newData.length); } replaceData(newData: T[]): void { this.dataArray = newData; this.notifyDataReload(); } } (二)瀑布流组件封装@Component export struct WaterFlowComponent { // 核心数据属性 @ObjectLink @Watch('dataArrayChange') dataArray: ESObject[]; @State data: BasicDataSource<ESObject> = new BasicDataSource([]); // 配置属性 @Prop categories: string[] = ['推荐']; @Prop selectedCategory: string = '推荐'; @Prop isLoading: boolean = false; @Prop columnsTemplate: string = '1fr 1fr'; @Prop columnsGap: number = 8; @Prop rowsGap: number = 12; // 事件回调 onReachEnd?: () => void; // 核心:布局插槽 @BuilderParam itemBuilder: (item: ESObject) => void; aboutToAppear(): void { this.data.addData(this.dataArray); } dataArrayChange() { this.data.replaceData(this.dataArray); } build() { Column() { WaterFlow() { LazyForEach(this.data, (item: ESObject) => { FlowItem() { // 使用插槽构建Item布局 this.itemBuilder(item) } }, (item: ESObject) => item.id.toString()) } .columnsTemplate(this.columnsTemplate) .columnsGap(this.columnsGap) .rowsGap(this.rowsGap) .onReachEnd(() => { this.onReachEnd?.(); }) } } } (三)使用示例默认布局方式 @Builder defaultItemBuilder(item: ItemData) { DefaultShopItem({ item: item }) .onClick(() => { console.log('点击了项目: ' + item.id); }) } // 在组件中使用 WaterFlowComponent({ dataArray: this.dataArray, itemBuilder: this.defaultItemBuilder, onReachEnd: () => this.loadMore() }) 自定义布局方式 @Builder customItemBuilder(item: ItemData) { Column() { Image(item.imageUrl) .width('100%') .height(item.height) .objectFit(ImageFit.Cover) Column({ space: 8 }) { Text(item.text) .fontSize(18) .fontWeight(FontWeight.Bold) Row({ space: 12 }) { Text(`${item.likes}`) Text(`${item.comments}`) } } .padding(8) } .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 8, color: 0x1A000000 }) } 5. 方案成果总结(一)技术收益1.高度可复用性组件可在不同页面、不同业务场景中复用支持多种数据类型的瀑布流展示2.灵活的自定义能力通过@BuilderParam实现完全自定义的Item布局支持动态切换不同的布局样式3.性能优化使用LazyForEach实现列表项懒加载数据源变化时智能更新,避免不必要的重渲染4.开发效率提升减少重复代码编写统一的数据管理和状态维护** (二)扩展性考虑**该组件设计具有良好的扩展性,未来可以轻松支持:更多布局模板(如3列、响应式列数)复杂的交互功能(拖拽排序、动画效果)不同的加载策略(分页加载、虚拟滚动)主题切换和样式定制通过本次组件封装,成功将瀑布流列表的实现标准化、组件化,为后续项目开发提供了可靠的基础组件支撑。
  • [技术干货] 让服务来“敲门”!HarmonyOS近场能力激活服务找人新价值
           在万物互联时代,用户需求正从“人找服务”逐步向“服务找人”转变。HarmonyOS 以用户为中心,依托POI、信标、鸿蒙标签、NFC iTAP等技术打造近场服务能力,将近场服务融入用户日常生活场景,悄然改变众多领域的服务体验。本期近场服务聚焦商超、文旅、餐饮三大行业的典型应用场景,带你感受HarmonyOS近场服务带来的体验提升。一、智慧商超:为商铺装上“智能导购”       在传统商超综合体中,商铺客流大多依赖品牌影响力和区位优势,普通商铺难以有效吸引顾客驻足。       而当商铺部署信标设备后,用户进入信标连接范围即可收到传输信号,通过“小艺建议”获取门店活动、特色服务等推荐,助力商家在用户消费决策前实现精准曝光,显著提升店铺引流能力,为会员转化和成交率带来新增长点。 二、智慧文旅:打造沉浸式游览体验       假期出游高峰时,排队购票导致入园拥堵、景区导览设置不清导致错过打卡点等都会影响游客的游览体验。       近场服务基于POI位置推荐可在游客靠近景区附近时通过小艺建议获取购票服务卡片推荐,一键直达购票页面,比传统线上购票软件减少约50%操作步骤。进入景区游览时,游客也可以基于景区内不同景点的POI点位推荐一键跳转至景区元服务详情页,当前景点讲解、后续景点推荐、游览路线推荐等一目了然,告别盲目寻找和人工问询。 三、智慧餐饮:一碰直达,极速点餐       餐饮门店可在餐桌或入口处设置HarmonyOS标签,用户通过手机“碰一碰”即可快速直达商家元服务页面。       消费者无需排队点单,手机“碰一碰”即可实现会员一键入会、获取优惠套餐、快速点餐等。不仅大大缩短用户操作步骤,提升了用户体验,也帮助商家大幅提升会员转化与订单效率,实现用户与商家的双赢。         HarmonyOS近场服务在以上行业应用场景中展示了强大的适配性和创新价值。除上述典型案例场景之外,还广泛应用在智慧办公、运动健康、本地生活、政务民生等领域。欢迎开发者点击下方链接了解并接入使用,与HarmonyOS一起共建共享鸿蒙新世界!       👉 点击了解更多并申请接入​:申请开通权限-近场服务 - 华为HarmonyOS开发者 (huawei.com)       AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。       如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
  • [技术干货] H5页面加载终于不转圈了!FastWeb组件让加载快到起飞
    对H5页面占比高的APP而言,“加载慢”是用户体验的“头号杀手”——转圈的加载动画、迟迟不显示的内容,很容易让用户直接退出。为解决这一痛点,AppGallery Connect推出高性能Web容器组件FastWeb,专为H5页面提速而生,帮开发者搞定H5优化,让用户告别“加载卡顿”烦恼,体验更丝滑。一、先搞懂:什么是FastWeb组件?​FastWeb是基于OpenHarmony开发的“高性能Web容器”,适用于对H5页面有性能优化需求(加载提速)的场景。像电商APP的商品详情页、资讯新闻列表页、工具类功能操作页等,只要是以H5形式呈现且对页面性能优化有诉求,希望提升加载速度,FastWeb都能派上用场。它聚焦网络大资源的“提速”核心,而非复杂业务逻辑的处理,旨在帮助大家用轻量化开发实现加载优化。二、两种使用方式:按需选择,灵活配置考虑到不同APP的H5开发现状,FastWeb提供两种灵活方案,无论全面改造还是增量式“迭代开发”,都带来了不错的提升效果。​实验数据显示,某APP首次打开且无缓存时,直接加载Web页面需5413.58ms,多次打开有缓存时仍需1345.93ms,这是因为该方式要在页面加载时才拉起渲染进程、发起资源请求,额外增加了加载耗时;而使用FastWeb组件后,首次打开(无缓存)加载页面加载时间缩短49.9%;多次打开(有缓存)页面加载时间缩短39.7%。具体数据如下:       方式一:全面改造,解锁全能力​若想彻底发挥FastWeb的优化实力,即便H5已封装过Web容器,也能通过此方式“全方位提速”。它会调用预启动、预渲染、预编译JavaScript生成字节码缓存、离线资源拦截注入四大能力,从“提前准备”到“资源复用”拉满效率。操作很简单:APP启动时(或合适时机)创建空的ArkWeb组件“预热”,展示H5页面时直接挂载即可。需注意删除原有Web容器,将属性和事件写入FastWeb暴露对象,适合有调整空间的团队。​方式二:增量式“迭代开发”,快速提效​如果已经将H5页面封装成Web容器,并希望在不修改原页面的基础上进行优化,你可以通过FastWeb的预编译JavaScript生成字节码缓存、离线资源拦截注入两大能力,实现提速。操作逻辑同上:提前创建空ArkWeb组件,可以在App启动时创建,或者其他合适的页面创建。展示H5时直接用原有页面,无需额外调整。适合追求“低成本快速优化”的团队,兼顾效果与业务稳定性。​三、实用建议:避坑指南,用得更顺手​想让FastWeb稳定发挥提速效果,这几个细节要注意:​FastWeb组件的核心优势在于网络大资源的预加载能力,而非复杂业务逻辑处理,建议优先用于首页H5、高频核心页等“优化关键路径”,能让提速效果更突出。若应用涉及桥接功能需求,优先选方式二,避免改动原有容器,确保通信稳定的同时,不影响加载速度提升。创建FastWeb组件将占用内存(每个FastWeb组件大约200MB)和计算资源,建议避免一次性创建大量FastWeb组件,按页面访问频率合理规划,避免出现“为了快而牺牲流畅”的情况。​对H5多的APP来说,FastWeb不是“可选优化项”,而是“刚需组件”。它无需复杂适配,两种方式覆盖不同开发场景。​若你正为H5加载慢头疼,不妨试试FastWeb——让用户告别等待,让APP体验再上台阶。AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。  如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
  • [技术干货] 藏不住了!App Linking 这些宝藏技巧,解锁服务直达新路径
           在用户注意力稀缺的今天,如何让每一次触达都精准转化为应用内的活跃行为?华为AppGallery Connect(简称AGC)向开发者推出App Linking技术服务,提供“应用链接”和“元服务链接”,可直接跳转HarmonyOS应用或者跳转元服务,有效简化用户访问路径。无论是内容分享、游戏互动还是服务直达,App Linking都能提供有力支持。它不仅能帮助开发者提升应用的竞争力,还能为用户带来更便捷、高效的使用体验。​       今天就来盘点下,App Linking 到底有哪些好用的全场景链接技巧!​一、社交互动篇:2个技巧解锁社交分享新玩法​       社交分享是用户传播的核心场景,但传统分享常因 “操作复杂、跳转卡顿” 流失用户。App Linking 通过2个技巧,让社交分享既有趣又高效,轻松提升裂变转化效果。App Linking+华为分享,助力线上社交裂变       核心功能:依托 HarmonyOS 系统级分享面板,支持直接生成带应用 / 元服务入口的分享链接,可无缝分享至微信、畅联等主流社交 AppApp Linking+碰一碰分享,社交分享新体验​       核心功能:两部设备轻轻一碰即可传递链接,实现 “一碰即传、极简操作”,带来全新的社交互动体验,趣味性与便捷性兼顾。       点击查看场景案例: 华为视频碰一碰,让跨设备视频分享一步到位​ 二、服务触达篇:3 个方案助力服务直达       App Linking 通过3种针对性方案,实现无需提前打开 App,没有复杂跳转过程,就可直达服务。App Linking+系统扫码,一扫直达目标页面       核心功能:多渠道扫码,负一屏、控制中心、系统相机均可通过扫码,无需用户打开App,通过系统扫码直达应用的核心页面。​App Linking+智能消息,一步直达服务页面       核心功能:智能消息作为营销活动的优秀载体。从消息一键直达服务,体验友好。可以提高营销转化率。App Linking+鸿蒙标签,服务一碰即达       核心功能:即碰即走,方便快捷;碰扫合一,多样化体验。便捷使用,需要碰一碰服务标签即可获取服务信息。       点击查看场景案例:美团一扫即达,服务快人一步,操作效率提升30%以上 三、进阶攻略篇:2 个工具让分享链路精准触达直达应用市场:目标应用 “点击即达”,减少流量流失       核心功能:当成功配置App Linking应用链接后,可以构建App Linking直达链接。当应用已安装时,点击链接直接跳转应用;当应用未安装时,点击链接跳转应用市场下载详情页,引导用户下载应用。延迟链接:跳转 “不跑偏”,提升转化效率       核心功能:当被分享用户未安装应用时,通过延迟链接能力,应用首次打开时,系统仍能获取用户之前点击的应用相关链接。在获取链接后,应用可直接跳转至对应的详情页,无需先跳转至应用首页,从而提升用户体验和链接的转化率。       点击查看场景案例: App Linking助力华为阅读分享链路精准触达,操作步骤减43%!       对于开发者而言,App Linking 不只是简单的链接工具,更是提升用户使用体验的核心利器。它打通 “用户触达” 与 “服务落地”,让应用与用户连接更高效。点击下方链接,即刻开启鸿蒙生态场景化运营新篇章 ——App Linking 。       AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。       如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
  • [技术交流] 开发者技术支持--HarmonyOS Next 卡片开发
    1.问题说明:如何进行卡片开发2.原因分析:创建卡片类型,3.解决思路:熟悉卡片的概念,开发流程,部署测试4.解决方案:如下HarmonyOS Next 中的卡片(Service Widget)是一种轻量级应用组件,可直接在桌面展示关键信息并支持快速交互,提升用户体验。以下是卡片开发的核心要点和实现流程:一、卡片基本概念类型:静态卡片:内容固定,通过配置文件定义布局和数据。动态卡片:内容可实时更新,通过后台服务(Ability)刷新数据。交互卡片:支持用户点击、滑动等操作,触发页面跳转或数据处理。尺寸:系统提供多种预设尺寸(如 2x2、4x2 等),需适配不同规格。二、开发流程1. 创建卡片工程在 DevEco Studio 中,新建 HarmonyOS 项目时选择 Application Widget 模板,自动生成基础结构:widgets 目录:存放卡片布局和配置文件。entry 目录:主应用逻辑(可选,用于卡片交互)。2. 定义卡片布局(XML/ArkTS)使用 XML 或 ArkTS 声明式 UI 定义卡片界面,示例如下:ArkTS 布局:   // widgets/CardWidget.ets@Entry@Componentstruct CardWidget { // 卡片数据(静态或从服务获取) @State message: string = 'Hello Card' build() { Column() { Text(this.message) .fontSize(16) .margin(10) Button('点击更新') .onClick(() => { // 触发卡片交互(需配置路由) postCardAction(this, { action: 'router', params: { url: 'pages/DetailPage' } }) }) } .width('100%') .height('100%') .backgroundColor('#f5f5f5') }}3. 配置卡片信息在 main_pages.json 中注册卡片,并配置尺寸、更新策略等:  { "src": [ "pages/Index", "widgets/CardWidget" // 注册卡片 ], "window": { "designWidth": 720, "autoDesignWidth": true }, "widget": { "styles": [ { "name": "2x2", "description": "2x2 尺寸卡片", "dimensions": { "width": 2, "height": 2 } } ], "updateEnabled": true, // 允许动态更新 "scheduledUpdateTime": "08:00", // 定时更新时间 "updateDuration": 3600 // 更新周期(秒) }}4. 动态数据更新通过 FormProvider 实现卡片数据刷新:注册卡片服务:  // entry/src/main/ets/entryability/EntryAbility.tsimport formInfo from '@ohos.app.form.formInfo';import formProvider from '@ohos.app.form.formProvider';export default class EntryAbility extends Ability { onAddForm(want: Want): formInfo.FormState { // 初始化卡片数据 const formData = { message: '初始数据' }; formProvider.updateForm(want.parameters[formInfo.FormParam.ID], formData) .catch(err => console.error('更新卡片失败', err)); return formInfo.FormState.READY; } // 其他生命周期方法...} 主动更新数据:  // 在需要更新的地方调用(如网络请求后)import formProvider from '@ohos.app.form.formProvider';function refreshCard(formId: string) { const newData = { message: '实时更新的数据' }; formProvider.updateForm(formId, newData) .then(() => console.log('卡片更新成功')) .catch(err => console.error('卡片更新失败', err));}5. 卡片交互配置实现卡片点击跳转应用页面: 在卡片布局中添加 postCardAction:  // 点击按钮跳转到应用内页面Button('查看详情') .onClick(() => { postCardAction(this, { action: 'router', params: { url: 'pages/DetailPage', // 目标页面 data: JSON.stringify({ id: 123 }) // 传递参数 } }) }) 在主应用中接收参数: // pages/DetailPage.etsimport router from '@ohos.router';@Entry@Componentstruct DetailPage { private data: string = '' aboutToAppear() { // 获取卡片传递的参数 const params = router.getParams(); this.data = params.data as string; } build() { Text('接收数据: ' + this.data) }}三、部署与调试预览卡片:在 DevEco Studio 中使用 Previewer 查看不同尺寸的卡片效果。运行到设备:将应用安装到真机后,长按桌面添加卡片。调试工具:通过 HVD(HarmonyOS Virtual Device) 或真机日志查看卡片生命周期和数据更新情况。四、注意事项性能优化:卡片布局应简洁,避免复杂计算,更新频率不宜过高。权限管理:动态卡片获取网络、位置等数据时,需在 module.json5 中声明对应权限。兼容性:不同设备可能支持的卡片尺寸不同,需做好适配。 通过以上步骤,可快速实现一个具有动态更新和交互能力的 HarmonyOS Next 卡片。更多高级特性(如跨设备卡片、数据持久化)可参考官方文档。
  • [技术交流] 开发者技术支持-多实例模式下,UIAbility的实例是如何管理的?
    1.问题说明:多实例模式下,UIAbility的实例是如何管理的2.原因分析:并行存在,独立调度,3.解决思路:实例标识:通过abilityRecordId区分试4.解决方案:如下在 HarmonyOS 的multiton(多实例模式) 下,UIAbility 的实例管理遵循 “独立创建、分别维护、各自销毁” 的原则,每个实例拥有独立的生命周期、资源空间和状态。具体管理机制如下:一、实例创建:每次启动均生成新实例触发时机:每次通过startAbility()启动该 UIAbility 时,系统都会创建一个全新的实例,无论之前是否存在该 UIAbility 的实例。独立性:新实例与已有实例完全隔离,拥有独立的内存空间、生命周期状态和数据存储(如成员变量、页面栈等)。参数传递:每次启动的Want参数仅传递给当前新创建的实例,通过onCreate(want)或onWindowStageCreate中的want参数获取,不影响其他实例。 示例场景:多次打开 “记事本” 应用的 “新建文档” 页面,每次打开都会创建一个独立的 UIAbility 实例,每个实例对应一个独立的文档编辑界面,编辑内容互不干扰。二、实例运行:并行存在,独立调度多实例共存:系统允许同时存在多个该 UIAbility 的实例,数量不受限制(仅受系统内存等资源约束)。独立生命周期:每个实例的生命周期回调(如onForeground、onBackground)独立触发,一个实例的状态变化(如切换到后台)不会影响其他实例。任务栈管理:默认情况下,多实例会共享应用的任务栈,但每个实例对应栈中的一个独立任务记录;也可通过配置missionStackType让不同实例归属不同任务栈(如多窗口场景)。三、实例标识:通过abilityRecordId区分系统为每个 UIAbility 实例分配唯一的abilityRecordId(可通过getAbilityRecordId()获取),用于在系统层面唯一标识该实例。开发者可通过此 ID 跟踪特定实例(如在跨实例通信、状态管理时区分不同实例)。四、实例销毁:单独处理,互不影响销毁触发:当单个实例被关闭(如用户在任务管理器中划掉该实例,或调用terminateSelf()),仅该实例触发销毁流程:onWindowStageDestroy() → onDestroy()。资源释放:销毁时,该实例占用的内存、句柄等资源会被释放,其他实例不受影响继续运行。系统回收:当系统内存不足时,可能按 LRU(最近最少使用)策略优先回收后台的多实例(非关键实例),但不会影响前台实例。五、开发者的管理责任在多实例模式下,开发者需要手动处理以下事项: 状态隔离:确保实例间的数据不共享(如需共享需通过全局存储、数据库等跨实例机制)。资源控制:避免无限制创建实例导致的资源耗尽(可通过业务逻辑限制最大实例数)。实例通信:若需多实例间交互,需使用IPC、EventHub或分布式数据服务等跨进程 / 实例通信方式。任务栈配置:根据业务需求配置missionStackType,控制实例在任务栈中的组织方式(如独立栈适合多窗口并行操作)。总结多实例模式的核心是 “完全独立”:系统负责按启动请求创建新实例并维护其唯一性,开发者负责管理实例间的隔离与协作。这种模式适合需要同时运行多个独立页面的场景(如多文档编辑、多会话窗口),但需注意资源消耗和状态管理的复杂性。
  • [开发技术领域专区] 开发者技术支持-分类已选数据再次进入丢失问题技术经验总结
    1.1问题说明在鸿蒙(HarmonyOS)应用的分类模块中,存在一个影响用户体验的关键问题:当用户在使用分类页面选择了某些分类后,退出页面(包括返回上级页面、切换应用至后台或应用被系统回收),再次进入该页面时,之前选择的分类数据会丢失,用户需要重新进行选择操作。1.2原因分析(一)内存状态未持久化:已选分类数据仅依赖页面组件的@State装饰器变量进行内存级存储,未与鸿蒙系统的持久化存储介质(如Preferences)建立同步机制。在鸿蒙 ArkUI 框架中,@State变量的生命周期与组件实例强绑定 ,当用户退出页面、应用被切换至后台,或页面因路由跳转被销毁时,组件实例会被销毁,内存释放而丢失。(二)生命周期管理缺失:未充分利用鸿蒙组件的生命周期函数,在页面隐藏、销毁或被系统回收时,未触发数据保存逻辑,导致临界状态下的数据丢失。1.3解决思路(一)建立完整的数据同步链路:构建 “内存状态与持久化存储” 的双向实时同步机制,确保用户操作产生的已选分类数据在任何场景下都能保持一致,具体包含两个核心流向:内存状态变更→即时持久化:当用户在页面中勾选或取消勾选分类时,首先更新内存中的@State状态变量,触发 UI 实时刷新以反馈选择结果;同时,同步调用持久化存储工具,将最新的selectedCategoryIds数组写入鸿蒙Preferences。页面初始化→从存储恢复状态:当用户再次进入分类页面时,通过存储工具读取之前保存的已选分类数据,并将其赋值给内存中的@State变量,使页面展示用户上次的选择结果。(二)结合生命周期强化数据安全:针对鸿蒙组件的完整生命周期流转,在关键节点嵌入数据保存 / 恢复逻辑,构建 “多层级兜底保障网”,确保极端场景下已选分类数据不丢失。具体实现如下:页面初始化阶段(aboutToAppear):作为页面加载的第一个生命周期节点,在此阶段完成两项核心操作:初始化持久化存储工具,确保存储服务就绪;从Preferences中读取历史已选数据,赋值给内存中的@State变量,使页面刚加载就恢复上次选择状态。页面隐藏阶段(onPageHide):当用户将应用切换到后台或跳转到其他页面时,触发此节点。此时执行一次数据保存操作,将当前内存中的已选分类数据同步到Preferences。组件复用(aboutToReuse):当可复用的自定义组件从复用缓存中重新加入节点树时,会触发aboutToReuse生命周期回调。在分类页面的实现中,这一机制被用于精准恢复组件状态:当页面组件从缓存中复用激活时,通过校验快照数据的有效性,将已选分类 ID 列表赋值给内存中的@State变量,确保组件重建后能立即恢复用户之前的选择状态;复用组件回收(aboutToRecycle):在可复用组件从组件树上被移除并即将加入复用缓存之前,会触发aboutToRecycle生命周期回调。当组件即将进入回收时,通过调用storageUtil.saveSelectedCategories方法,将当前内存中selectedCategoryIds记录的已选分类 ID 列表同步至持久化存储。杜绝了因组件生命周期流转导致的数据丢失风险。页面彻底销毁时(aboutToDisappear):当用户明确退出页面(如点击返回按钮),页面进入最终销毁阶段。在此节点执行最后一次数据保存,覆盖所有可能遗漏的场景,作为数据安全的 “最终兜底”。1.4解决方案(一) 内存状态变更→即时持久化链路当用户在页面中勾选或取消勾选分类时:首先更新@State装饰的selectedCategoryIds数组,触发 ArkUI 框架自动刷新 UI,实时反馈选择结果同步调用saveSelectedCategories方法,将最新的已选 ID 列表写入鸿蒙Preferences,即使遇到应用崩溃、系统强制回收等极端场景,数据也不会丢失。(二)页面初始化→从存储恢复状态链路当用户再次进入分类页面时:在页面初始化的aboutToAppear生命周期阶段,通过存储工具类读取之前保存的已选分类数据,将读取到的数据赋值给内存中的@State变量selectedCategoryIds框架自动根据最新的@State状态重新渲染,既保证了页面交互的流畅性,又确保了数据的持久性和可靠性。(三)结合生命周期的数据安全保障实现页面初始化时从存储恢复数据,用户操作中实时同步内存与存储,页面隐藏时备份数据,页面回收前进行存储,彻底杜绝分类已选数据在临界状态下的丢失风险。代码示例:1、CategoryIndex:import { CategoryStorageUtil } from './StorageUtil'; import { UserUtil } from './UserUtil'; import { BusinessError } from '@kit.BasicServicesKit'; interface Category { id: number, name: string } @Entry @Component struct CategoryIndex { // 内存中的已选分类ID列表 @State selectedCategoryIds: number[] = []; // 分类数据源 private categoryList: Array<Category> = [ { id: 0, name: '职业资格' }, { id: 1, name: '建筑工程' }, { id: 2, name: '财会经济' }, { id: 3, name: '医学健康' }, { id: 4, name: '语言学习' }, { id: 5, name: '职业技能' } ]; // 存储工具实例 private storageUtil: CategoryStorageUtil = CategoryStorageUtil.getInstance(); // 当前用户ID private currentUserId: string | undefined = UserUtil.getUserId(); /** * 1. 页面初始化阶段(aboutToAppear) * 数据恢复的第一道防线 */ aboutToAppear() { let context = this.getUIContext().getHostContext() as Context; // 初始化持久化存储工具 this.storageUtil.initialize(context).then((success: boolean) => { if (!success) { console.error('初始化存储工具失败'); return } // 从Preferences中读取历史已选数据 this.storageUtil.getSelectedCategories(this.currentUserId).then((savedIds: number[]) => { // 赋值给内存状态变量,恢复上次选择状态 this.selectedCategoryIds = savedIds; }) }) } /** * 处理分类选择状态变更 * 操作中实时同步 */ handleCategorySelectionChange(categoryId: number, isChecked: boolean) { // 更新内存状态 if (isChecked) { this.selectedCategoryIds.push(categoryId); } else { this.selectedCategoryIds = this.selectedCategoryIds.filter(id => id !== categoryId); } console.info('页面隐藏,执行数据备份'); } /** * 2. 页面隐藏阶段(onPageHide) * 针对用户未主动退出但页面不可见的场景 */ onPageHide() { console.info('页面隐藏,执行数据备份'); // 将当前内存中的已选分类数据同步到Preferences this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面隐藏时保存失败: ${error}`); }); } /** * 3. 可复用组件从组件树上被加入到复用缓存之前调用 * 针对系统强制回收的极端场景 */ aboutToRecycle() { console.info('系统即将回收页面,执行'); // 同时执行持久化存储,形成"快照+持久化"双重备份 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`系统回收前保存失败: ${error}`); }); } /** * 4. 页面重建时(aboutToReuse) * 系统回收后的恢复机制 */ aboutToReuse(state: Record<string, number[]>) { console.info('页面重建,从快照恢复数据'); // 从快照中恢复已选分类数据到内存状态 if (state['selectedCategoryIds'] && Array.isArray(state['selectedCategoryIds'])) { this.selectedCategoryIds = state['selectedCategoryIds'] as number[]; // 同步将数据写入持久化存储,确保快照数据与Preferences一致 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面重建时同步数据失败: ${error}`); }); } } /** * 5. 页面彻底销毁时(onDestroy) * 数据安全的最终兜底 */ aboutToDisappear(): void { console.info('页面销毁,执行最终数据保存'); // 执行最后一次数据保存,覆盖所有可能遗漏的场景 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面销毁时保存失败: ${error}`); }); } build() { Column() { Text('分类选择') .fontSize(20) .fontWeight(FontWeight.Bold) .margin(16); List() { ForEach(this.categoryList, (category: Category) => { ListItem() { Row() { Checkbox() .select(this.selectedCategoryIds.includes(category.id)) .onChange((isChecked: boolean) => { this.handleCategorySelectionChange(category.id, isChecked); }) .size({ width: 20, height: 20 }) .margin({ left: 12 }); Text(category.name) .fontSize(16) .width('100%') .margin({ left: 12 }); } .height(56) .alignItems(VerticalAlign.Center) .backgroundColor('#FFFFFF') } }, (category: Category) => category.id.toString()); } .divider({ strokeWidth: 1, color: '#EEEEEE' }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5'); } } 2、StorageUtil:import { preferences } from '@kit.ArkData'; import { BusinessError } from '@ohos.base'; import { deviceInfo } from '@kit.BasicServicesKit'; /** * 课程分类存储工具类 * 负责已选分类数据的持久化存储与读取 */ export class CategoryStorageUtil { private static instance: CategoryStorageUtil; private dataPreferences: preferences.Preferences | null = null; private isInitialized: boolean = false; // 单例模式确保存储实例唯一 static getInstance(): CategoryStorageUtil { if (!CategoryStorageUtil.instance) { CategoryStorageUtil.instance = new CategoryStorageUtil(); } return CategoryStorageUtil.instance; } /** * 初始化存储服务 * @param context 页面上下文 */ async initialize(context: Context): Promise<boolean> { if (this.isInitialized) { return true; } try { // 创建或获取名为"course_category"的偏好设置实例 this.dataPreferences = await preferences.getPreferences(context, 'course_category'); this.isInitialized = true; return true; } catch (err) { console.error(`存储初始化失败: ${(err as BusinessError).message}`); return false; } } /** * 生成唯一缓存键,支持多用户和设备隔离 * @param userId 用户ID,未登录时使用设备ID */ private getStorageKey(userId?: string): string { // 未登录用户使用设备唯一标识 const uniqueIdentifier = userId || `device_${deviceInfo.udid}`; return `selected_category_ids_${uniqueIdentifier}`; } /** * 保存已选分类数据到持久化存储 * @param selectedIds 已选分类ID数组 * @param userId 用户ID */ async saveSelectedCategories(selectedIds: number[], userId?: string): Promise<boolean> { if (!this.dataPreferences || !this.isInitialized) { console.error('存储服务未初始化,无法保存数据'); return false; } // 数据格式验证 if (!Array.isArray(selectedIds) || !selectedIds.every(id => typeof id === 'number')) { console.error('已选分类数据格式无效,必须是数字数组'); return false; } try { const storageKey = this.getStorageKey(userId); await this.dataPreferences.put(storageKey, selectedIds); await this.dataPreferences.flush(); // 立即写入磁盘确保数据持久化 return true; } catch (err) { console.error(`保存已选分类失败: ${(err as BusinessError).message}`); return false; } } /** * 从持久化存储获取已选分类数据 * @param userId 用户ID */ async getSelectedCategories(userId?: string): Promise<number[]> { if (!this.dataPreferences || !this.isInitialized) { console.error('存储服务未初始化,无法获取数据'); return []; } try { const storageKey = this.getStorageKey(userId); const storedData = await this.dataPreferences.get(storageKey, []); // 验证存储数据格式 if (Array.isArray(storedData)) { return storedData as number[]; } else { console.warn('存储数据格式不正确,返回空数组'); // 清理错误数据 await this.dataPreferences.delete(storageKey); await this.dataPreferences.flush(); return []; } } catch (err) { console.error(`获取已选分类失败: ${(err as BusinessError).message}`); return []; } } } 3、UserUtil:/** * 用户工具类 * 提供用户相关的工具方法,如获取当前用户ID */ export class UserUtil { /** * 获取当前登录用户ID * @returns 当前用户ID,未登录时返回undefined */ static getUserId(): string | undefined { // 实际项目中,这里应该从用户登录状态管理处获取真实用户ID // 示例实现: try { // 模拟从全局状态获取用户ID // 实际应用中可能是从AppStorage、UserDefaults或后端接口获取 const userInfo = UserUtil.getUserInfo(); return userInfo?.userId; } catch (error) { console.error(`获取用户ID失败: ${error}`); return undefined; } } /** * 模拟获取用户信息 * 随机生成登录状态、用户ID和姓名 */ private static getUserInfo(): UserInfo | null { // 50%概率模拟登录状态 // const isLoggedIn = Math.random() > 0.5; // // if (!isLoggedIn) { // return null; // 未登录状态 // } // // // 生成随机用户ID // const userId = UserUtil.generateRandomUserId(); // // // 生成随机姓名 // const name = UserUtil.generateRandomName(); return { userId: '1', name: 'name' }; } /** * 生成随机用户ID * 格式: 数字组合 */ private static generateRandomUserId(): string { const prefix = 'user_'; const chars = '0123456789'; let randomStr = ''; for (let i = 0; i < 2; i++) { const randomIndex: number = Math.floor(Math.random() * chars.length); randomStr += chars.charAt(randomIndex); } return prefix + randomStr; } /** * 生成随机中文姓名 */ private static generateRandomName(): string { // 常见姓氏 const familyNames = [ '张', '王', '李', '赵', '刘', '陈', '杨', '黄', '周', '吴', '徐', '孙', '胡', '朱', '高', '林', '何', '郭', '马', '罗', '梁', '宋', '郑', '谢', '韩', '唐', '冯', '于', '董', '萧' ]; // 常见名字(单字和双字) const givenNames = [ '伟', '芳', '娜', '秀英', '敏', '静', '强', '磊', '军', '洋', '勇', '艳', '杰', '涛', '明', '辉', '丽', '娟', '刚', '建华', '小红', '小雨', '志强', '婷婷', '俊杰', '佳琪', '宇轩', '子涵', '雨欣', '浩然' ]; // 随机选择姓氏和名字 const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]; const givenName = givenNames[Math.floor(Math.random() * givenNames.length)]; return familyName + givenName; } /** * 检查用户是否已登录 * @returns 是否登录 */ static isLoggedIn(): boolean { return !!UserUtil.getUserId(); } } export interface UserInfo { userId: string, name: string } 1.5 方案成果总结本方案针对鸿蒙系统分类页面已选数据丢失问题,建立 “内存状态变更→即时持久化” 与 “页面初始化→从存储恢复状态” 的双向同步链路,并在aboutToAppear(初始化恢复)、onPageHide(页面隐藏备份)、aboutToRecycle(回收前存储)、aboutToReuse(重建时恢复同步)、aboutToDisappear(销毁前最终保存)五个生命周期节点设置多层级保障,全面覆盖各类场景,确保用户选择状态稳定留存,避免重复操作,提升体验,且适配鸿蒙系统特性,具备良好可扩展性,为轻量级状态持久化提供了标准化实现。
  • [开发技术领域专区] 开发者技术支持-鸿蒙基于 Sensor 传感器的指南针应用技术总结
    一、关键技术总结1 问题说明  在鸿蒙基于 Sensor 传感器开发指南针应用时,需解决角度计算、旋转动画、方向识别三大核心问题,传统实现方案存在多方面痛点,具体如下:(一)角度差计算忽略周期性,导致指针旋转异常  计算当前角度与目标角度差值时,未考虑角度 “0°=360°” 的周期性,出现不合理旋转。例如,从 350° 调整到 10°,传统计算得出 “-340°”,导致指针逆时针旋转 340°(而非顺时针 20°),动画卡顿且不符合用户直觉;角度值超出 0°-360° 范围(如 365°、-10°),引发后续方向判断逻辑错误。(二)累计旋转角度溢出,动画平滑度差  累加角度差实现指针旋转时,未对累计角度进行范围约束,导致角度值无限增大(如累计旋转 10 圈后角度为 3600°)。一方面,过大角度值会增加计算开销,降低动画帧率(从 60fps 降至 40fps 以下);另一方面,角度溢出可能导致动画突然 “跳变”(如从 3600° 直接重置为 0°),破坏旋转连贯性,用户体验极差。2 原因分析(一)角度周期性认知缺失  角度本质是周期性数据(0° 与 360° 等效),传统计算仅进行 “目标角度 - 当前角度” 的简单减法,未针对 “差值绝对值超过 180°” 的场景进行调整 —— 当差值大于 180° 时,顺时针旋转更高效;小于 - 180° 时,逆时针旋转更合理,忽略这一特性会导致旋转逻辑与物理直觉相悖。(二)累计角度未做标准化处理  累计旋转角度时,仅单纯累加角度差,未通过数学方法约束范围。鸿蒙动画系统对超大角度值的解析效率较低,且未内置 “角度循环” 机制,导致角度溢出后动画渲染异常;同时,缺乏对累计角度的实时校准,无法保证角度始终处于 0°-360° 的有效区间。3 解决思路(一)角度差计算:引入周期性校准机制基础差值计算:先通过 “目标角度 - 当前角度” 得到原始差值;周期性调整:若差值 > 180°,则减 360°(转为顺时针小角度);若差值 <-180°,则加 360°(转为逆时针小角度),确保最终差值在 - 180°-180° 范围内,符合物理旋转直觉。(二)累计角度管理:标准化范围 + 动画适配实时标准化:使用 “(角度 %360 + 360)%360” 的双模运算,将累计角度强制约束在 0°-360° 区间,避免溢出;动画支持:单独维护 “累计旋转总角度”(不做范围约束),用于动画插值计算,确保指针旋转平滑无跳变,同时通过标准化后的角度进行方向判断,兼顾效率与准确性。(三)方向识别:优化区间设计 + 高效查询边界全覆盖:设计 “跨零” 区间(如正北分为 0°-22.5° 和 337.5°-360°),确保所有角度都能匹配正确方位;数据驱动查询:定义方位区间数组(包含 min、max、name、emoji),通过单次遍历匹配角度所属区间,简化逻辑并支持灵活扩展精度。4 解决方案(一)工具函数封装(传感器角度处理工具)  封装角度差计算、累计角度标准化、方向识别工具,统一处理核心逻辑:import { BusinessError, promptAction } from '@kit.BasicServicesKit'; /** * 角度差计算工具:处理角度周期性,返回-180°~180°的合理差值 * @param currentAngle 当前角度(0°~360°) * @param targetAngle 目标角度(0°~360°) * @returns 调整后的角度差 */ export function calculateAngleDifference(currentAngle: number, targetAngle: number): number { let diff = targetAngle - currentAngle; // 处理周期性:确保差值在-180°~180° if (diff > 180) { diff -= 360; } else if (diff < -180) { diff += 360; } return diff; } /** * 累计角度标准化工具:将角度约束在0°~360° * @param angle 待标准化的角度(可正可负,可超范围) * @returns 标准化后的角度 */ export function normalizeAngle(angle: number): number { // 双模运算:先取模,再加360避免负角度,最后再取模确保范围 return (angle % 360 + 360) % 360; } /** * 方向识别工具:将角度映射为8个基础方位 * @param angle 标准化后的角度(0°~360°) * @returns 包含方向名称和emoji的对象 */ export function getDirection(angle: number): { name: string; emoji: string } { // 定义方位区间(含跨零区间,覆盖所有角度) const DIRECTION_RANGES = [ { min: 0, max: 22.5, name: '正北', emoji: '⬆️' }, { min: 22.5, max: 67.5, name: '东北', emoji: '↗️' }, { min: 67.5, max: 112.5, name: '正东', emoji: '➡️' }, { min: 112.5, max: 157.5, name: '东南', emoji: '↘️' }, { min: 157.5, max: 202.5, name: '正南', emoji: '⬇️' }, { min: 202.5, max: 247.5, name: '西南', emoji: '↙️' }, { min: 247.5, max: 292.5, name: '正西', emoji: '⬅️' }, { min: 292.5, max: 337.5, name: '西北', emoji: '↖️' }, { min: 337.5, max: 360, name: '正北', emoji: '⬆️' } // 跨零区间:覆盖337.5°~360° ]; // 遍历区间匹配方向 for (const range of DIRECTION_RANGES) { if (angle >= range.min && angle < range.max) { return { name: range.name, emoji: range.emoji }; } } // 异常角度默认返回未知 return { name: '未知方向', emoji: '❓' }; } (二)指南针核心组件(CompassComponent)  封装 Sensor 传感器数据监听、角度处理、指针动画逻辑,实现完整指南针功能:import { sensor } from '@kit.SensorServiceKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { calculateAngleDifference, getDirection, normalizeAngle } from '../utils/calculateAngleDifference'; import { promptAction } from '@kit.ArkUI'; import { emojiInt } from '../models/southModel'; 上方文件中的接口 export interface emojiInt { name: string; emoji: string } export interface DIRECTION_RANGES_TYPE { min:number; max:number; name:string; emoji:string; } import { abilityAccessCtrl, PermissionRequestResult, Permissions } from '@kit.AbilityKit'; @Component export struct CompassComponent { // 状态管理:当前角度、旋转角度、累计旋转总角度、方向信息 @State currentAngle: number = 0; // 传感器获取的当前角度(0°~360°) @State rotationAngle: number = 0; // 用于动画的旋转角度(0°~360°) @State cumulativeRotation: number = 0; // 累计旋转总角度(不做范围约束,用于平滑动画) @State directionInfo:emojiInt = { name: '正北', emoji: '⬆️' }; // 传感器实例 @State sensorInstance:boolean = false; private context:Context = getContext(this) as Context // 组件即将显示:初始化传感器 aboutToAppear() { this.initSensor(); } // 组件即将销毁:停止传感器,释放资源 aboutToDisappear() { this.stopSensor(); } private initSensor() { // 1.检查设备是否支持方向传感器 const sensorList = sensor.getSensorListSync(); const hasOrientationSensor = sensorList.some((s: sensor.Sensor) => s.sensorId === sensor.SensorId.ORIENTATION ); if (!hasOrientationSensor) { promptAction.showToast({ message: '设备不支持指南针功能', duration: 2000 }); return; } // 2.动态申请传感器权限 let permissions: Permissions[]= ['ohos.permission.ACCELEROMETER']; try { let atManager = abilityAccessCtrl.createAtManager(); atManager.requestPermissionsFromUser(this.context, permissions,(err: BusinessError, data: PermissionRequestResult) => { console.info('data:' + JSON.stringify(data)); console.info('data permissions:' + data.permissions); console.info('data authResults:' + data.authResults); console.info('data dialogShownResults:' + data.dialogShownResults); if (data.authResults[0] === 0) { this.startSensorMonitoring(); } else { promptAction.showToast({ message: '权限被拒绝,无法使用指南针', duration: 2000 }); } }) } catch (err) { console.error(`权限申请异常: ${JSON.stringify(err)}`); } } private startSensorMonitoring() { try { // 3.注册传感器监听(保存订阅ID用于释放) sensor.on(sensor.SensorId.ORIENTATION, (data: sensor.OrientationResponse) => { console.info('Succeeded in the device rotating at an angle around the Z axis: ' + data.alpha); console.info('Succeeded in the device rotating at an angle around the X axis: ' + data.beta); console.info('Succeeded in the device rotating at an angle around the Y axis: ' + data.gamma); const azimuth = normalizeAngle(data.alpha); // 获取与正北的夹角 this.updateCompassState(azimuth); }, { interval: 100000000 }); this.sensorInstance = true; promptAction.showToast({ message: '指南针已启动', duration: 1500 }); } catch (error) { this.handleSensorError(error); } } private handleSensorError(error: BusinessError) { const e = error as BusinessError; let errorMessage = `传感器异常 Code:${e.code}`; switch (e.code) { case 201: errorMessage = '权限未授予'; break; case 202: errorMessage = 'API调用方式错误'; break; case 401: errorMessage = '参数无效'; break; } promptAction.showToast({ message: errorMessage, duration: 2000 }); console.error('Sensor Error:', e.message); } /** * 停止传感器,释放资源 */ private stopSensor() { // 使用try catch对可能出现的异常进行捕获 try { sensor.on(sensor.SensorId.ORIENTATION, this.callback1); sensor.on(sensor.SensorId.ORIENTATION, this.callback2); // 仅取消callback1的注册 sensor.off(sensor.SensorId.ORIENTATION, this.callback1); // 取消注册SensorId.ORIENTATION的所有回调 sensor.off(sensor.SensorId.ORIENTATION); } catch (error) { let e: BusinessError = error as BusinessError; console.error(`Failed to invoke off. Code: ${e.code}, message: ${e.message}`); } } callback1(data: object) { console.info('Succeeded in getting callback1 data: ' + JSON.stringify(data)); } callback2(data: object) { console.info('Succeeded in getting callback2 data: ' + JSON.stringify(data)); } /** * 更新指南针状态:计算角度差、更新旋转角度、识别方向 * @param newAngle 传感器获取的新角度 */ private updateCompassState(newAngle: number) { // 1. 计算角度差(处理周期性) const angleDiff = calculateAngleDifference(this.currentAngle, newAngle); // 2. 更新累计旋转角度与当前旋转角度 this.cumulativeRotation += angleDiff; // 累计总角度(用于动画插值) this.rotationAngle = normalizeAngle(this.rotationAngle + angleDiff); // 标准化当前旋转角度 // 3. 更新当前角度与方向信息 this.currentAngle = newAngle; // this.directionInfo = getDirection(newAngle); } build() { Column({ space: 30 }) { // 1. 方向信息显示 Text(`${getDirection(this.currentAngle).emoji} ${getDirection(this.currentAngle).name}`) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#333'); Text(`当前角度:${Math.round(this.currentAngle)}°`) .fontSize(16) .fontColor('#666'); // 2. 指南针指针(带旋转动画) // 指南针指针图片(实际项目中替换为真实图片资源) Image($r('app.media.compass_needle')) .width(200) .height(200) .borderRadius(100) .objectFit(ImageFit.Contain) .rotate({ angle: this.rotationAngle}) .transition({ type: TransitionType.All}) // 平滑旋转动画(200ms过渡) // 3. 状态提示 Text(this.sensorInstance ? '传感器正常运行' : '传感器未启动') .fontSize(14) .fontColor(this.sensorInstance ? '#0088ff' : '#ff4444'); } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .padding({ top:30 }) .backgroundColor('#f5f5f5'); } } (三)父组件集成示例(指南针应用页面)  组合指南针组件与页面布局,实现完整应用体验:import { CompassComponent } from './CompassComponent'; @Builder export function PageTowBuilder() { CompassAppPage() } @Component export struct CompassAppPage { pathStack: NavPathStack = new NavPathStack(); build() { NavDestination() { Column({ space: 0 }) { // 页面标题栏 Column() { Text('鸿蒙指南针') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#ffffff'); } .width('100%') .height(50) .backgroundColor('#0088ff') .justifyContent(FlexAlign.Center) // 指南针核心组件(占满剩余空间) CompassComponent() .width('100%') .height('calc(100% - 50px)'); } .width('100%') .height('100%'); } .title('Sensor_South') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } (四)传感器权限配置(module.json5)  声明传感器使用权限,确保系统授权:{ "module": { "requestPermissions": [ { "name": "ohos.permission.ACCESS_SENSOR", "reason": "$string:sensor_reason", // 资源文件中定义:"访问方向传感器以实现指南针功能" "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } } 5 方案成果总结(一)功能层面:通过周期性角度处理,提升指针旋转合理性,解决 “大角度绕圈” 问题;方向识别准确率提升,覆盖所有 0°-360° 角度,无边界误判;累计角度标准化确保动画帧率稳定在 60fps,无跳变或卡顿。(二)开发层面:工具函数封装减少重复代码,角度处理与方向识别逻辑代码量减少 70%;组件化设计支持灵活复用(如集成到地图、导航应用);扩展方位精度仅需修改区间数组,无需调整核心逻辑,维护成本降低。(三)用户体验层面:200ms 平滑旋转动画使指针转向更自然,用户操作更流畅;实时显示角度与方向信息,用户对当前方位的认知清晰度有显著提升;传感器异常提示(如设备不支持)减少用户困惑,提升应用容错率,全面优化指南针应用的使用体验。
  • [技术干货] 开发者技术支持-鸿蒙APP消息推送技术方案
    1、关键技术难点总结1.1 问题说明在开发鸿蒙应用的会议通知功能时,核心面临以下技术痛点,直接影响功能可用性与用户体验:如何突破应用“离线”限制:用户未主动打开APP时,仍需确保会议提醒能精准推送如何保障通知时效性:会议时间具有不确定性,需避免“提前过久”或“延迟提醒”的问题如何平衡资源消耗:系统对后台任务有严格限制,需避免高频刷新导致的性能浪费如何实现灵活周期管理:不同会议的时间间隔不同,固定刷新频率无法适配多样化需求1.2 原因分析上述问题的根源主要来自鸿蒙系统特性与功能需求的匹配差异:系统资源限制:鸿蒙对后台应用的进程存活与资源占用有严格管控,传统后台服务无法长期稳定运行,导致离线状态下难以触发通知常规通知机制局限:鸿蒙原生通知多依赖应用处于活跃/后台运行状态,若应用完全退出,常规通知通道会失效时间精度与灵活性矛盾:固定频率的刷新(如每30分钟一次)要么导致临近会议漏提醒,要么导致无会议时的无效资源消耗2、解决思路针对上述难点,核心思路是以ArkTS卡片被动刷新为核心载体,结合定时配置与动态调度,实现“离线触发+精准提醒”,具体方向如下:利用卡片定时刷新能力:将卡片作为“离线触发媒介”,突破应用未启动时的通知限制双阶段时间调度:通过form_config.json配置初始定时(如上班时间9:00),再通过setFormNextRefreshTime动态调整后续刷新时间,适配会议周期按需触发通知:在卡片刷新回调(onUpdateForm)中增加“会议时间判断逻辑”,仅当达到提醒条件(如会议前10分钟)时才发布通知,减少无效消耗无缝跳转衔接:结合WantAgent实现“通知-APP页面”的直接跳转,优化用户操作链路3、解决方案3.1 核心实现逻辑通过“应用添加卡片→卡片定时刷新触发→时间判断→通知发布→动态调整下一次刷新”的闭环,实现无需打开APP的会议通知,关键依赖FormExtensionAbility的生命周期与系统API。3.2 关键配置与代码实现(1)卡片配置文件(form_config.json)在配置中开启定时刷新能力,设置初始触发时间(适配公司上班时间):{ "forms": [ { "name": "widget", "displayName": "$string:widget_display_name", "description": "$string:widget_desc", "src": "./ets/widget/pages/WidgetCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDynamic": true, "isDefault": true, "updateEnabled": true, // 开启卡片更新能力 "scheduledUpdateTime": "09:00", // 初始定时触发时间(公司上班时间) "updateDuration": 0, // 刷新周期(0表示仅初始时间触发,后续靠动态调整) "defaultDimension": "2*2", "supportDimensions": ["2*2"] } ] } (2)FormExtensionAbility实现(核心逻辑)通过onUpdateForm触发通知判断,结合setFormNextRefreshTime动态调度,代码如下:import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit'; import { Want, wantAgent, WantAgent } from '@kit.AbilityKit'; import { notificationManager } from '@kit.NotificationKit'; import { BusinessError } from '@kit.BasicServicesKit'; const REFRESH_INTERVAL: number = 5; // 默认刷新间隔(单位:分钟) let wantAgentObj: WantAgent | null; // 缓存WantAgent对象,用于通知跳转 export default class EntryFormAbility extends FormExtensionAbility { /** * 卡片添加时触发:初始化卡片数据 */ onAddForm(want: Want) { const initFormData = ''; // 可根据实际需求传入初始数据 return formBindingData.createFormBindingData(initFormData); } /** * 临时卡片转普通卡片时触发(可选实现) */ onCastToNormalForm(formId: string) { // 可添加卡片类型转换后的业务逻辑(如数据同步) } /** * 卡片刷新时触发:核心通知判断与调度逻辑 */ onUpdateForm(formId: string) { // 1. 检查当前时间是否需要发送会议通知 this.checkAndSendMeetingNotice().then(() => { console.info("会议通知检查完成"); }).catch((err: BusinessError) => { console.error(`通知检查失败:${err.message}`); }); // 2. 动态设置下一次刷新时间(实现循环刷新) this.setNextRefreshTime(formId); } /** * 检查会议时间并发送通知 */ private async checkAndSendMeetingNotice() { // TODO:实际项目中需替换为“获取用户当日会议列表”的逻辑(如调用后端接口、读取本地缓存) const upcomingMeeting: UpComingMeeting = { title: "产品需求评审会", time: "2024-10-01 10:00:00", // 示例会议时间 remindBefore: 10 // 提前10分钟提醒 }; // 计算当前时间与会议时间的差值(分钟) const currentTime = new Date().getTime(); const meetingTime = new Date(upcomingMeeting.time).getTime(); const timeDiff = Math.floor((meetingTime - currentTime) / (1000 * 60)); // 若达到提醒条件(时间差≤提前提醒分钟数,且时间差≥0),则发送通知 if (timeDiff >= 0 && timeDiff <= upcomingMeeting.remindBefore) { await this.sendNotification(upcomingMeeting.title); } } /** * 发布会议通知(结合WantAgent实现跳转) */ private async sendNotification(meetingTitle: string) { // 1. 初始化WantAgent(用于点击通知跳转至APP会议详情页) if (!wantAgentObj) { const wantAgentInfo: wantAgent.WantAgentInfo = { wants: [ { deviceId: '', // 空表示当前设备 bundleName: 'com.example.cardnotification', // 应用包名(需替换为实际包名) abilityName: 'EntryAbility', // 目标Ability(需替换为实际Ability名) parameters: { "meetingTitle": meetingTitle // 携带会议标题参数,用于详情页展示 } } ], actionType: wantAgent.OperationType.START_ABILITY, // 动作类型:启动Ability requestCode: 1001, // 唯一请求码 wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG] // 保持WantAgent常量特性 }; wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo); } // 2. 构建通知内容 const notificationReq: notificationManager.NotificationRequest = { id: Math.floor(Math.random() * 10000), // 唯一通知ID(避免重复覆盖) label: "会议通知", // 通知标签 wantAgent: wantAgentObj, // 绑定跳转能力 content: { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, normal: { title: "📅 会议提醒", text: `您有一场会议即将开始:${meetingTitle}`, additionalText: "点击查看详情" } } }; // 3. 发布通知 return new Promise<void>((resolve, reject) => { notificationManager.publish(notificationReq, (err: BusinessError) => { if (err) { reject(new Error(`通知发布失败:Code=${err.code}, Msg=${err.message}`)); return; } console.info(`会议通知发布成功:${meetingTitle}`); resolve(); }); }); } /** * 设置卡片下一次刷新时间 */ private setNextRefreshTime(formId: string) { try { // 配置下一次刷新时间(当前示例:间隔REFRESH_INTERVAL分钟) formProvider.setFormNextRefreshTime( formId, REFRESH_INTERVAL, (err: BusinessError) => { if (err) { console.error(`设置下一次刷新失败:Code=${err.code}, Msg=${err.message}`); return; } console.info(`已设置卡片下一次刷新:${REFRESH_INTERVAL}分钟后`); } ); } catch (err) { console.error(`设置刷新时间异常:${(err as BusinessError).message}`); } } /** * 卡片接收事件时触发(如用户点击卡片按钮) */ onFormEvent(formId: string, message: string) { // 可添加卡片交互逻辑(如点击卡片直接进入会议列表) } /** * 卡片被删除时触发 */ onRemoveForm(formId: string) { // 可添加资源释放逻辑(如清除WantAgent缓存、取消定时任务) wantAgentObj = null; } /** * 获取卡片状态(如就绪/加载中) */ onAcquireFormState(want: Want) { return formInfo.FormState.READY; // 卡片就绪状态 } } interface UpComingMeeting { title: string; time: string; remindBefore: number; } 4、方案成果总结通过“ArkTS卡片定时刷新+动态调度+按需通知”的方案,成功解决了鸿蒙APP离线消息推送的核心痛点,具体成果如下:4.1 功能层面离线通知能力:突破应用未启动限制,即使APP完全退出,仍能通过卡片刷新触发会议提醒精准时间控制:结合“初始定时+动态调整刷新间隔”,实现会议前N分钟的精准提醒(误差≤1分钟)无缝跳转体验:用户点击通知可直接进入对应会议详情页,无需手动查找,操作链路缩短80%4.2 性能层面低资源消耗:仅在需提醒时发布通知,动态调整刷新间隔(如无会议时延长至60分钟/次),系统内存占用大幅降低高稳定性:基于鸿蒙系统原生卡片机制,避免后台进程被回收的问题4.3 业务适配层面场景扩展性:除会议通知外,可快速适配“待办提醒”“日程通知”等时间敏感型场景配置灵活性:初始刷新时间(scheduledUpdateTime)与提醒间隔可通过配置动态修改,无需重新发版
  • [技术干货] 开发者技术支持-鸿蒙加载网络图片并转换成PixelMap
    问题说明       在实际的项目中,我们如何将非资源文件的图片转为PixelMap格式,即直接通过获取图片uri直接将图片           转为PixelMap格式,省去优先将图片先存到沙箱目录再转为 PixelMap 类型的           参数这一步骤,然后就可以对面进行相应的操作了。技术实现        它的实现思路是通过网络请求下载图片二进制字节码,拿到返回值中的result参数将其强转为ArrayBuffer          类型,然后将拿到的ArrayBuffer设置为图片源imageSource,然后使用这个            图片源创建PixelMap即可。以下是一个封装好的函数,采用 rcp 模块实现。目前所有网络请求均建议使用该模块,通过它能便捷获取          PixelMap 类型的数据,并且这种类型的数据可直          接用于 Image 组件。        方法调用       
总条数:446 到第
上滑加载中