• [问题求助] HUAWEI Pura 70 Ultra手机无网络下在富文本中插入本地图片会看不见图片
    HUAWEI Pura 70 Ultra手机无网络下在富文本中插入本地图片会看不见图片,呈现这种,其它手机正常显示图片。HUAWEI Pura 70 Ultra用的版本是4.2.0的。分析可能是系统兼容性问题:华为Pura 70 Ultra可能有一些特定的系统设置或优化,导致某些应用在无网络状态下无法正确显示本地图片请问怎么解决?
  • [开发技术领域专区] 开发者技术支持-鸿蒙视频播放画中画技术实现与适配经验总结
    关键技术难点总结1. 问题说明在鸿蒙视频播放类应用中,画中画功能是提升用户体验的重要特性,允许用户在离开当前页面甚至回到桌面时继续观看视频。在实际开发过程中,我们遇到了以下关键问题:(一)画中画启动时黑屏画中画窗口启动后显示黑屏,只有声音没有画面,严重影响用户体验。(二)启用自动进入画中画功能后,直接返回桌面画中画黑屏当开启自动进入画中画功能后,用户直接返回桌面时画中画窗口显示黑屏,无法正常播放视频内容。(三)如何自定义画中画内容系统默认的画中画控制界面功能有限,需要根据业务需求自定义控制按钮和交互逻辑。2. 原因分析(一)画中画启动时黑屏经过分析,主要原因包括:SurfaceID传递时机不当:画中画启动时视频播放器的SurfaceID未正确传递资源竞争冲突:横竖屏切换与画中画启动存在资源竞争初始化顺序错误:画中画控制器在视频播放器准备完成前初始化(二)启用自动进入画中画功能后,直接返回桌面画中画黑屏根本原因在于:生命周期管理不当:应用进入后台时视频资源被过早释放Surface切换失败:从全屏Surface切换到画中画Surface时数据流中断自动启动配置错误:setAutoStartEnabled配置与实际情况不匹配3. 解决思路(一)整体架构设计采用分层架构,将画中画功能独立封装:AVPlayerUtil:负责视频播放核心功能PipWindowUtil:专门处理画中画窗口管理VideoPlayerComponent:UI组件,协调各方功能(二)关键解决策略**生命周期精准控制:**确保画中画启动时所有资源就绪**SurfaceID动态管理:**根据横竖屏状态动态切换Surface**状态同步机制:**通过事件总线实现组件间状态同步**延迟初始化:**避免资源竞争导致的初始化冲突4. 解决方案(一)解决画中画启动时黑屏确保SurfaceID正确传递// 在VideoPlayerComponent.ets中 @Builder private buildVideoXComponent(targetController: XComponentController) { Column() { XComponent({ type: XComponentType.SURFACE, controller: targetController }) .onLoad(async () => { try { // 关键:获取SurfaceID并传递给AVPlayer AVPlayerUtil.surfaceID = targetController.getXComponentSurfaceId(); console.info(`[VideoPlayer] SurfaceID获取成功: ${AVPlayerUtil.surfaceID}`); // 启动视频播放 if (typeof this.videoSource === "string") { AVPlayerUtil.avPlayerLiveDemo(this.videoSource); } } catch (err) { console.error(`[VideoPlayer] XComponent初始化失败: ${JSON.stringify(err)}`); } }) .onDestroy(() => { console.log('[VideoPlayer] 视频XComponent销毁'); }) .height(this.videoContainerHeight) .width("100%") } } private initializePipMode() { if (!canIUse("SystemCapability.Window.SessionManager") || this.videoSource === '') { return; } const pipConfig: PiPWindow.PiPConfiguration = { context: this.getUIContext().getHostContext() as Context, componentController: this.portraitXComponentController, // 使用竖屏控制器 templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY, contentWidth: 1000, contentHeight: 600, controlGroups: [PiPWindow.VideoPlayControlGroup.FAST_FORWARD_BACKWARD] }; // 关键:横屏时先切回竖屏,避免资源冲突 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } // 延时初始化,减轻系统压力 const pipDelayTimer = setTimeout(() => { PipWindowUtil.startPip(pipConfig); clearTimeout(pipDelayTimer); }, 50); } 视频播放器状态管理static setAVPlayerCallback(avPlayer: media.AVPlayer) { avPlayer.on('stateChange', async (state: string) => { switch (state) { case 'prepared': console.info('AVPlayer state prepared called.'); // 确保画中画启动前视频已准备就绪 if (AVPlayerUtil.continuePlaying) { AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime) AVPlayerUtil.continuePlaying = false } avPlayer.play(); break; case 'playing': // 更新画中画控件状态 if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus( PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY ) } break; } }); } (二)自动进入画中画黑屏优化自动启动配置static initPipController() { if (!PipWindowUtil.pipController) { return; } // 谨慎使用自动启动,确保资源就绪 PipWindowUtil.pipController.setAutoStartEnabled(false); PipWindowUtil.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => { PipWindowUtil.onStateChange(state, reason); }); } 完善前后台状态管理private setupAppStateListener() { const appStateCallback: ApplicationStateChangeCallback = { onApplicationForeground() { console.log('[VideoPlayer] App进入前台'); }, onApplicationBackground() { console.log('[VideoPlayer] App进入后台'); // 关键:仅在非画中画模式时暂停 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 锁屏状态特殊处理 screenLock.isScreenLocked((err: BusinessError, isLocked: Boolean) => { if (!err && isLocked) { AVPlayerUtil.pauseAVPlayer(); } }); } }; } (三)自定义画中画内容控制面板自定义@Builder private buildVideoControlPanel() { Column() { // 顶部返回按钮 Row() { Image($r("app.media.videoBackIcon")) .onClick(() => { if (this.isLandscapeMode) { this.switchScreenOrientation(false); } this.onBack?.(); }) } // 画中画控制按钮 if (canIUse("SystemCapability.Window.SessionManager")) { Row() { if (!this.isPipModeActive && PiPWindow.isPiPEnabled()) { Image($r("app.media.videoPipIcon")) .onClick(() => this.initializePipMode()) } else { Image($r("app.media.video_pip_restoration")) .onClick(() => PipWindowUtil.stopPip()) } } } // 底部控制栏 Row({ space: 10 }) { // 播放/暂停、进度条、横竖屏切换等自定义控件 } } } 画中画控制事件处理static onActionEvent(event: PiPWindow.PiPActionEventType, status?: number) { switch (event) { case 'playbackStateChanged': if (status === 0) { AVPlayerUtil.pauseAVPlayer() } else if (status === 1) { AVPlayerUtil.playAVPlayer() } break; case 'fastForward': AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime + 5) break; case 'fastBackward': AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime - 5) break; default: break; } } (四)完整示例代码VideoPlayerComponent组件代码:// VideoPlayerComponent.ets // 独立视频播放组件(支持画中画、横竖屏适配) import { PiPWindow, window } from '@kit.ArkUI'; import { BusinessError, emitter, screenLock } from '@kit.BasicServicesKit'; import { ApplicationStateChangeCallback, common } from '@kit.AbilityKit'; import { AVPlayerUtil } from './utils/AVPlayerUtil'; import { PipWindowUtil } from './utils/PipWindowUtil'; @Component export struct VideoPlayerComponent { @Prop videoUrl:string = ''; onBack?: () => void; // 内部状态管理 // 竖屏XComponent控制器 private portraitXComponentController: XComponentController = new XComponentController(); // 横屏XComponent控制器 private landscapeXComponentController: XComponentController = new XComponentController(); // 视频播放源(从props获取) @State videoSource: ResourceStr = this.videoUrl; // 播放状态:true=播放中 @State isVideoPlaying: boolean = true; // 横竖屏模式:true=横屏 @State isLandscapeMode: boolean = false; // 视频容器高度 @State videoContainerHeight: ResourceStr | number = 211; // 视频总时长 @State totalVideoDuration: number = 0; // 当前播放时间 @State currentVideoTime: number = 0; // 控制栏显示状态 @State isVideoControlVisible: boolean = true; // 控制栏自动隐藏定时器 private videoControlHideTimer: number = 0; // 控制栏底部间距 @State videoControlBottomSpacing: number = 10; // 画中画激活状态 @State isPipModeActive: boolean = false; /** * 切换设备横竖屏模式 */ private switchScreenOrientation(targetLandscapeMode: boolean) { this.isLandscapeMode = targetLandscapeMode; const uiAbilityContext: common.UIAbilityContext = getContext(this) as common.UIAbilityContext; window.getLastWindow(uiAbilityContext).then((currentWindow) => { if (targetLandscapeMode) { // 横屏配置 this.videoContainerHeight = "100%"; this.videoControlBottomSpacing = 20; currentWindow.setPreferredOrientation(window.Orientation.LANDSCAPE); currentWindow.setWindowLayoutFullScreen(true); } else { // 竖屏配置 this.videoContainerHeight = 211; this.videoControlBottomSpacing = 10; currentWindow.setPreferredOrientation(window.Orientation.PORTRAIT); currentWindow.setWindowLayoutFullScreen(false); } }); } /** * 秒数格式化为"00:00"或"00:00:00" */ private formatSecondsToTime(seconds: number): string { let formattedTime = ""; // 处理小时 if (seconds > 3600) { const hour = ("0" + Math.floor(seconds / 3600)).slice(-2); formattedTime += `${hour}:`; } // 处理分钟 const minute = ("0" + Math.floor((seconds % 3600) / 60)).slice(-2); formattedTime += `${minute}:`; // 处理秒数 const second = ("0" + Math.floor(seconds % 60)).slice(-2); formattedTime += second; return formattedTime; } /** * 初始化画中画模式 */ private initializePipMode() { if (!canIUse("SystemCapability.Window.SessionManager") || this.videoSource === '') { return; } const pipConfig: PiPWindow.PiPConfiguration = { context: this.getUIContext().getHostContext() as Context, componentController: this.portraitXComponentController, templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY, contentWidth: 1000, contentHeight: 600, controlGroups: [PiPWindow.VideoPlayControlGroup.FAST_FORWARD_BACKWARD] }; // 横屏时先切回竖屏,避免资源冲突 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } // 延时初始化,减轻系统压力 const pipDelayTimer = setTimeout(() => { PipWindowUtil.startPip(pipConfig); clearTimeout(pipDelayTimer); }, 50); } /** * 创建控制栏自动隐藏定时器(4秒隐藏) */ private createControlHideTimer(): number { clearTimeout(this.videoControlHideTimer); return setTimeout(() => { this.isVideoControlVisible = false; clearTimeout(this.videoControlHideTimer); }, 4000); } /** * 配置App前后台/锁屏监听(后台/锁屏时暂停播放) */ private setupAppStateListener() { const appStateCallback: ApplicationStateChangeCallback = { onApplicationForeground() { console.log('[VideoPlayer] App进入前台'); }, onApplicationBackground() { console.log('[VideoPlayer] App进入后台'); // 非画中画模式暂停 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 锁屏状态监听 screenLock.isScreenLocked((err: BusinessError, isLocked: Boolean) => { if (err) { console.error(`[VideoPlayer] 获取锁屏状态失败: ${err.code}-${err.message}`); return; } if (isLocked) { AVPlayerUtil.pauseAVPlayer(); } }); } }; try { const uiAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; uiAbilityContext.getApplicationContext().on('applicationStateChange', appStateCallback); } catch (error) { const err = error as BusinessError; console.error(`[VideoPlayer] 配置状态监听失败: ${err.code}-${err.message}`); } } /** * 处理视频双击(切换播放/暂停) */ private handleVideoDoubleTap() { if (this.isVideoPlaying) { AVPlayerUtil.pauseAVPlayer(); } else { AVPlayerUtil.playAVPlayer(); } this.isVideoPlaying = !this.isVideoPlaying; } /** * 处理视频单击(切换控制栏显示) */ private handleVideoSingleTap() { this.isVideoControlVisible = !this.isVideoControlVisible; if (this.isVideoControlVisible) { this.videoControlHideTimer = this.createControlHideTimer(); } else { clearTimeout(this.videoControlHideTimer); } } /** * 构建视频XComponent渲染容器 */ @Builder private buildVideoXComponent(targetController: XComponentController) { Column() { XComponent({ type: XComponentType.SURFACE, controller: targetController }) .onLoad(async () => { try { AVPlayerUtil.surfaceID = targetController.getXComponentSurfaceId(); // 启动视频播放(仅字符串类型地址) if (typeof this.videoSource === "string") { AVPlayerUtil.avPlayerLiveDemo(this.videoSource); } } catch (err) { console.error(`[VideoPlayer] XComponent初始化失败: ${JSON.stringify(err)}`); } }) .onDestroy(() => { console.log('[VideoPlayer] 视频XComponent销毁'); }) .height(this.videoContainerHeight) .width("100%") } } /** * 构建视频控制面板(返回、画中画、播放/暂停、进度条、横竖屏) */ @Builder private buildVideoControlPanel() { Column() { // 顶部返回按钮 Row() { Image($r("app.media.videoBackIcon")) .height(35) .aspectRatio(1) .onClick(() => { // 横屏时先切回竖屏,再执行父组件回调 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } this.onBack?.(); }) } .padding({ left: 10, top: this.videoControlBottomSpacing, right: 10, bottom: 5 }) .width("100%") .backgroundColor("rgba(0,0,0,0.2)") // 画中画控制按钮(系统支持时显示) if (canIUse("SystemCapability.Window.SessionManager")) { Row() { if (!this.isPipModeActive && PiPWindow.isPiPEnabled()) { // 开启画中画 Image($r("app.media.videoPipIcon")) .height(35) .aspectRatio(1) .backgroundColor("rgba(0,0,0,0.2)") .onClick(() => this.initializePipMode()) .margin({ right: this.isLandscapeMode ? 35 : 10 }) .padding(5) .borderRadius(10); } else { // 退出画中画 Image($r("app.media.video_pip_restoration")) .height(35) .aspectRatio(1) .backgroundColor("rgba(0,0,0,0.2)") .onClick(() => PipWindowUtil.stopPip()) .margin({ right: this.isLandscapeMode ? 35 : 10 }) .padding(5) .borderRadius(10); } } .width('100%') .justifyContent(FlexAlign.End); } // 底部控制栏(播放/暂停、进度条、横竖屏) Row({ space: 10 }) { // 播放/暂停按钮 Column() { Image(this.isVideoPlaying ? $r("app.media.videoPauseIcon") : $r("app.media.videoStartIcon") ) .height(35) .aspectRatio(1) .onClick(() => { this.isVideoPlaying ? AVPlayerUtil.pauseAVPlayer() : AVPlayerUtil.playAVPlayer(); this.isVideoPlaying = !this.isVideoPlaying; }); } // 进度条区域 Row() { Text(this.formatSecondsToTime(this.currentVideoTime)) .fontColor(Color.White); Slider({ value: this.currentVideoTime, min: 0, max: this.totalVideoDuration, step: 1 }) .layoutWeight(1) .trackColor(Color.Gray) .onChange((value: number, mode: SliderChangeMode) => { if (mode === SliderChangeMode.Begin || mode === SliderChangeMode.Click) { this.isVideoControlVisible = true; clearTimeout(this.videoControlHideTimer); } else if (mode === SliderChangeMode.End) { this.videoControlHideTimer = this.createControlHideTimer(); this.isVideoPlaying = true; AVPlayerUtil.playAVPlayer(); } AVPlayerUtil.changeVideoTime(value); }); Text(this.formatSecondsToTime(this.totalVideoDuration)) .fontColor(Color.White); } .layoutWeight(1); // 横竖屏切换按钮 Column() { Image(this.isLandscapeMode ? $r("app.media.videoSmallScreen") : $r("app.media.videoFullScreen") ) .height(30) .aspectRatio(1) .onClick(() => { // 竖屏切横屏前先关闭画中画 if (!this.isLandscapeMode && PipWindowUtil.pipController !== undefined) { PipWindowUtil.stopPip(); } this.switchScreenOrientation(!this.isLandscapeMode); }); } } .padding({ left: 10, top: 5, right: 10, bottom: this.videoControlBottomSpacing }) .width("100%") .backgroundColor("rgba(0,0,0,0.2)") } .width('100%') .height(this.videoContainerHeight) .justifyContent(FlexAlign.SpaceBetween); } // 组件初始化生命周期 aboutToAppear(): void { // 初始化基础配置 this.videoSource = this.videoUrl; this.setupAppStateListener(); this.switchScreenOrientation(false); // 默认竖屏 this.videoControlHideTimer = this.createControlHideTimer(); // 关闭已有画中画 if (PipWindowUtil.pipController !== undefined && canIUse("SystemCapability.Window.SessionManager")) { PipWindowUtil.stopPip(); } // 监听视频状态更新(播放/时间) emitter.on("changeVideoState", () => { this.isVideoPlaying = AVPlayerUtil.videoPlaying; this.currentVideoTime = AVPlayerUtil.videoCurrentTime; this.totalVideoDuration = AVPlayerUtil.videoDuration; }); // 监听画中画状态更新 emitter.on("changePipState", () => { this.isPipModeActive = PipWindowUtil.pipState; }); // 监听外部返回事件(如物理返回键) emitter.on("videoBackPress", () => { this.switchScreenOrientation(false); this.isLandscapeMode = false; }); } // 组件销毁生命周期 aboutToDisappear(): void { // 清理资源 clearTimeout(this.videoControlHideTimer); this.switchScreenOrientation(false); // 切回竖屏 // 非画中画模式暂停播放 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 取消事件监听 emitter.off("changeVideoState"); emitter.off("changePipState"); emitter.off("videoBackPress"); } // 组件UI渲染 build() { Column() { Stack({ alignContent: Alignment.BottomStart }) { // 视频渲染区域(根据横竖屏选择控制器) this.buildVideoXComponent( this.isLandscapeMode ? this.landscapeXComponentController : this.portraitXComponentController ); // 控制面板(按需显示) if (this.isVideoControlVisible) { this.buildVideoControlPanel(); } } // 手势绑定 .gesture(TapGesture({ count: 2 }).onAction(() => this.handleVideoDoubleTap())) .gesture(TapGesture({ count: 1 }).onAction(() => this.handleVideoSingleTap())) } .width('100%') .justifyContent(FlexAlign.Start); } } PipWindowUtil的代码:import { PiPWindow, router } from "@kit.ArkUI"; import { BusinessError, emitter } from "@kit.BasicServicesKit"; import { AVPlayerUtil } from "./AVPlayerUtil"; export class PipWindowUtil { // 画中画控制器 static pipController?: PiPWindow.PiPController = undefined; // 是否为还原操作 还原继续播放AVPlayer内容 static restore: boolean = false // 画中画状态 static pipState: boolean = false //画中画还原路径 static pipRestorePath: string = "pages/VideoPlayer/VideoPlayer" /** * 开启画中画事件 * @param config 传递画中画配置参数 PiPWindow.PiPConfiguration */ static startPip(config: PiPWindow.PiPConfiguration) { // 判断系统是否支持画中画能力集 if (canIUse("SystemCapability.Window.SessionManager")) { // 判断是否支持画中画 并且 画中画控制器未被使用 if (PiPWindow.isPiPEnabled() && PipWindowUtil.pipController === undefined) { //画中画配置参数 // 创建画中画控制器,通过create接口创建画中画控制器实例 let promise: Promise<PiPWindow.PiPController> = PiPWindow.create(config); promise.then((controller: PiPWindow.PiPController) => { PipWindowUtil.pipController = controller; // 初始化画中画控制器 PipWindowUtil.initPipController(); // 通过startPiP接口启动画中画 PipWindowUtil.pipController.startPiP() .then(() => { console.info(`Succeeded in starting pip.`); PipWindowUtil.pipState = true emitter.emit("changePipState") }) .catch((err: BusinessError) => { console.error(`Failed to start pip. Cause:${err.code}, message:${err.message}`); }); }).catch((err: BusinessError) => { console.error(`Failed to create pip controller. Cause:${err.code}, message:${err.message}`); }).finally(() => { //获取视频状态 改变小窗按钮状态 if (PipWindowUtil.pipController !== undefined) { if (AVPlayerUtil.videoPlaying) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY) } else { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } }); } } else { console.error(`picture in picture disabled for current OS`); return; } } /** * 注册画中画实例 */ static initPipController() { if (!PipWindowUtil.pipController) { return; } if (canIUse("SystemCapability.Window.SessionManager")) { // 通过setAutoStartEnabled接口设置是否需要在应用返回桌面时自动启动画中画,注册stateChange和controlPanelActionEvent回调 PipWindowUtil.pipController.setAutoStartEnabled(false); /*or true if necessary*/ // 默认为false //订阅画中画状态变化事件 打开 关闭 返回页面 停止 PipWindowUtil.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => { PipWindowUtil.onStateChange(state, reason); }); //订阅画中画控制面板事件 暂停/播放 快进 后退 PipWindowUtil.pipController.on('controlPanelActionEvent', (event: PiPWindow.PiPActionEventType, status?: number) => { PipWindowUtil.onActionEvent(event, status); }); } } /** * 画中画状态(生命周期)变化回调 */ static onStateChange(state: PiPWindow.PiPState, reason: string) { if (canIUse("SystemCapability.Window.SessionManager")) { let curState: string = ''; //设置变量是否是 直接关闭画中画 (RESTORE时 返回继续播放) switch (state) { //将要启动画中画 case PiPWindow.PiPState.ABOUT_TO_START: curState = "ABOUT_TO_START"; PipWindowUtil.pipRestorePath = router.getState().path + router.getState().name break; //画中画已启动 case PiPWindow.PiPState.STARTED: curState = "STARTED"; break; //将要停止画中画 case PiPWindow.PiPState.ABOUT_TO_STOP: curState = "ABOUT_TO_STOP"; if (!PipWindowUtil.restore) { AVPlayerUtil.pauseAVPlayer() } PipWindowUtil.restore = false break; //画中画已停止 case PiPWindow.PiPState.STOPPED: curState = "STOPPED"; PipWindowUtil.stopPip() break; //将要还原画中画 右上角还原按钮 //修改还原跳转任务页面 case PiPWindow.PiPState.ABOUT_TO_RESTORE: curState = "ABOUT_TO_RESTORE"; PipWindowUtil.restore = true router.pushUrl({ url: PipWindowUtil.pipRestorePath, params: { url: AVPlayerUtil.url } }, router.RouterMode.Single) break; //画中画启动时出现错误 case PiPWindow.PiPState.ERROR: curState = "ERROR"; break; default: break; } console.info('PipStateChange:' + curState + ' reason:' + reason); } } /** * 画中画控制面板按钮事件回调 */ static onActionEvent(event: PiPWindow.PiPActionEventType, status?: number) { switch (event) { case 'playbackStateChanged': // 开始或停止视频 if (status === 0) { // 停止视频 AVPlayerUtil.pauseAVPlayer() } else if (status === 1) { // 播放视频 AVPlayerUtil.playAVPlayer() } break; case 'nextVideo': // 播放上一个视频 未使用 需修改画中画控制组件 break; case 'previousVideo': // 播放下一个视频 未使用 需修改画中画控制组件 break; case 'fastForward': // 视频进度快进 AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime + 5) break; case 'fastBackward': // 视频进度后退 AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime - 5) break; default: break; } } /** * 当不再需要显示画中画时,通过stopPiP接口关闭画中画 */ static stopPip() { if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController) { let promise: Promise<void> = PipWindowUtil.pipController.stopPiP(); // 如果已注册stateChange回调,停止画中画时取消注册该回调 PipWindowUtil.pipController?.off('stateChange'); // 如果已注册controlPanelActionEvent回调,停止画中画时取消注册该回调 PipWindowUtil.pipController?.off('controlPanelActionEvent'); //执行停止画中画方法 promise.then(() => { console.info(`Succeeded in stopping pip.`); }).catch((err: BusinessError) => { console.error(`Failed to stop pip. Cause:${err.code}, message:${err.message}`); }).finally(() => { // 画中画关闭后,将画中画状态置为false Controller 置空 触发修改画中画按钮状态 PipWindowUtil.pipController = undefined PipWindowUtil.pipState = false emitter.emit("changePipState") }); } } } } AVPlayerUtil的代码:import { media } from "@kit.MediaKit"; import { BusinessError, emitter } from "@kit.BasicServicesKit"; import { PipWindowUtil } from "./PipWindowUtil"; import { PiPWindow } from "@kit.ArkUI"; //用于播放网络视频的 AVPlayer静态类 export class AVPlayerUtil { //AVPlayer实例 static avPlayer: media.AVPlayer // surfaceID用于播放画面显示,具体的值需要通过XComponent接口获取,相关文档链接见上面XComponent创建方法 static surfaceID: string = ''; // 用于区分模式是否支持seek操作 static isSeek: boolean = true; //续播变量 static continuePlaying: boolean = false // 当前播放的url static url: string = "" // 当前播放状态 static videoPlaying: boolean = false // 当前播放时间 static videoCurrentTime: number = 0 // 当前播放时长 static videoDuration: number = 0 // 是否为开启第一帧展示 static firstFrame: boolean = false // 视频原始宽度 static videoWidth: number = 0 // 视频原始高度 static videoHeight: number = 0 /** * 注册avplayer回调函数 */ static setAVPlayerCallback(avPlayer: media.AVPlayer) { // startRenderFrame首帧渲染回调函数 avPlayer.on('startRenderFrame', () => { console.info(`AVPlayer start render frame`); }) // seek操作结果回调函数 avPlayer.on('seekDone', (seekDoneTime: number) => { console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`); }) // error回调监听函数,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程 avPlayer.on('error', (err: BusinessError) => { console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`); avPlayer.reset(); // 调用reset重置资源,触发idle状态 }) //视频总时长 avPlayer.on('durationUpdate', (duration: number) => { console.info('durationUpdate called,and new duration is :' + duration) AVPlayerUtil.videoDuration = duration / 1000 emitter.emit("changeVideoState") }) //当前播放时间 avPlayer.on('timeUpdate', (time: number) => { console.info('timeUpdate called,and new time is :' + time) //如果处于续播状态第一时间不对当前播放时间进行修改 (避免进度条进行跳动) if (!AVPlayerUtil.continuePlaying) { //当播放完成后 将进度条重置为0 if (time / 1000 === AVPlayerUtil.videoDuration) { AVPlayerUtil.videoCurrentTime = 0 } else { //正常复制 更新 播放页面当前时间 AVPlayerUtil.videoCurrentTime = time / 1000 } } emitter.emit("changeVideoState") }) // 视频尺寸变化回调 avPlayer.on('videoSizeChange', (width: number, height: number) => { console.info(`Video size changed: ${width} x ${height}`); AVPlayerUtil.videoWidth = width; AVPlayerUtil.videoHeight = height; let eventData: emitter.EventData = { data: { "width": width, "height": height, } }; let options: emitter.Options = { priority: emitter.EventPriority.HIGH }; emitter.emit("videoSizeChange", options, eventData); }) //AVPlayer准备好到 播放开始之前 avPlayer.on("startRenderFrame", () => { console.log("AVPlayer", "首帧移除") }) // AVPlayer状态机变化回调函数 avPlayer.on('stateChange', async (state: string) => { switch (state) { // 注册 case 'idle': // 成功调用reset接口后触发该状态机上报 console.info('AVPlayer state idle called.'); avPlayer.release(); // 调用release接口销毁实例对象 break; // 初始化 case 'initialized': // avplayer 设置播放源后触发该状态上报 avPlayer.surfaceId = AVPlayerUtil.surfaceID; // 设置显示画面,当播放的资源为纯音频时无需设置 avPlayer.prepare(); console.info('AVPlayer state initialized called.'); break; // 准备 case 'prepared': // prepare调用成功后上报该状态机 console.info('AVPlayer state prepared called.'); if (AVPlayerUtil.avPlayer !== undefined && AVPlayerUtil.avPlayer.url !== undefined) { AVPlayerUtil.url = AVPlayerUtil.avPlayer.url } if (AVPlayerUtil.continuePlaying) { AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime) AVPlayerUtil.continuePlaying = false } if (AVPlayerUtil.firstFrame) { let firstFrame = setTimeout(() => { avPlayer.pause() clearTimeout(firstFrame) }, 50) } avPlayer.play(); // 调用播放接口开始播放 卡帧 break; // 播放 case 'playing': // play成功调用后触发该状态机上报 console.info('AVPlayer state playing called.'); AVPlayerUtil.videoPlaying = true if (AVPlayerUtil.isSeek) { console.info('AVPlayer start to seek.'); avPlayer.seek(avPlayer.duration); //seek到视频末尾 } else { // 当播放模式不支持seek操作时继续播放到结尾 console.info('AVPlayer wait to play end.'); } // 当视频播放时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY) } } break; // 暂停 case 'paused': // pause成功调用后触发该状态机上报 console.info('AVPlayer state paused called.'); AVPlayerUtil.videoPlaying = false // avPlayer.play(); // 再次播放接口开始播放 // 当视频暂停时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } break; // 完成 case 'completed': // 播放结束后触发该状态机上报 console.info('AVPlayer state completed called.'); // avPlayer.stop(); //调用播放结束接口 AVPlayerUtil.videoPlaying = false AVPlayerUtil.pauseAVPlayer() // 当视频播放结束时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } break; // 停止 case 'stopped': // stop接口成功调用后触发该状态机上报 console.info('AVPlayer state stopped called.'); // avPlayer.reset(); // 调用reset接口初始化avplayer状态 break; // 回收 case 'released': console.info('AVPlayer state released called.'); break; default: console.info('AVPlayer state unknown called.'); break; } emitter.emit("changeVideoState") }) } /** * 通过url设置网络地址来实现播放直播码流 * */ static async avPlayerLiveDemo(url: string) { // 创建avPlayer实例对象 AVPlayerUtil.avPlayer = await media.createAVPlayer() // 创建状态机变化回调函数 AVPlayerUtil.isSeek = false; AVPlayerUtil.setAVPlayerCallback(AVPlayerUtil.avPlayer); AVPlayerUtil.avPlayer.url = url; // 播放hls网络直播码流 } /** * 播放音频/视频播放器 * 此方法用于启动AVPlayer的播放 */ static playAVPlayer() { AVPlayerUtil.avPlayer?.play() } /** * 暂停音频/视频播放器 * 此方法用于暂停当前AVPlayer的播放 */ static pauseAVPlayer() { AVPlayerUtil.avPlayer?.pause() } /** * 调整视频播放时间 * 此方法允许跳转到视频的特定时间点继续播放 * @param time 要跳转到的时间点,单位为秒如果提供的时间小于或等于0,则跳转到视频开始位置 */ static changeVideoTime(time: number) { // 将时间转换为毫秒,因为seek方法需要的时间单位是毫秒 if (time > 0) { time = time * 1000 } else { // 确保时间不会是负数或非法值 time = 0 } // 使用seek方法跳转到指定时间点,使用SEEK_CLOSEST模式找到最接近指定时间的关键帧 AVPlayerUtil.avPlayer?.seek(time, media.SeekMode.SEEK_CLOSEST) } } 5. 成果总结通过上述解决方案,我们成功实现了稳定可靠的鸿蒙视频播放画中画功能,取得了以下成果:(一)技术成果画中画启动成功率100%:通过合理的资源管理和初始化时机控制,彻底解决了黑屏问题横竖屏无缝切换:实现了横竖屏模式与画中画的完美兼容完整的生命周期管理:应用前后台切换、锁屏等场景下视频播放稳定自定义控制体验:根据业务需求定制了丰富的控制功能(二)性能优化资源占用降低:通过延迟初始化和及时资源释放,内存占用减少启动速度提升:画中画启动时间减少(三)用户体验操作流畅:控制响应及时,无卡顿现象状态一致:画中画与主界面状态实时同步交互友好:支持单击、双击等多种手势操作
  • [开发技术领域专区] 开发者技术支持-鸿蒙文件预览技术总结
    一、 关键技术难点总结1.问题说明在鸿蒙应用开发中,文件预览是一个常见的功能需求。开发者在实现文件预览功能时面临以下主要问题:API使用复杂:需要同时处理文件信息(PreviewInfo)和窗口配置(DisplayInfo)路径格式不统一:需要处理普通路径与URI格式的转换上下文依赖:预览功能强依赖正确的Context传递错误处理繁琐:需要处理文件不存在、格式不支持等多种异常情况配置管理困难:窗口位置、大小等参数需要合理设置2.原因分析API设计粒度较细:需要分别配置文件属性和窗口属性路径处理复杂:系统要求使用URI格式,但应用内文件通常使用普通路径生命周期管理:预览窗口状态需要手动管理配置参数众多:x、y、width、height等参数都需要合理设置3.解决思路针对上述问题,采用以下解决策略:封装统一接口:将复杂的API调用封装成简单的工具类方法,降低使用门槛。路径自动转换:实现智能路径识别和转换机制,支持多种路径格式。配置合并管理:提供默认配置并支持自定义覆盖,简化配置过程。状态统一管理:封装预览窗口的状态检查和生命周期管理。4.解决方案(一)配置管理/\*\* \* 合并预览配置 - 智能填充默认值 \*/ private mergePreviewConfig(config?: PreviewConfig): PreviewConfig { const defaultConfig: PreviewConfig = { title: \'文件预览\', x: 40, y: 100, width: 300, height: 500 }; if (!config) { return defaultConfig; } // 手动合并配置,确保类型安全 const mergedConfig: PreviewConfig = { title: config.title !== undefined ? config.title : defaultConfig.title, x: config.x !== undefined ? config.x : defaultConfig.x, y: config.y !== undefined ? config.y : defaultConfig.y, width: config.width !== undefined ? config.width : defaultConfig.width, height: config.height !== undefined ? config.height : defaultConfig.height }; return mergedConfig; } (二)路径智能处理/\*\* \* 智能路径处理 - 支持多种路径格式 \*/ private processFilePath(filePath: string): string { // 判断是否是URI格式 let uri: string; let isUri = filePath.startsWith(\'file://\') \|\| filePath.startsWith(\'content://\'); if (isUri) { uri = filePath; // 已经是URI,直接使用 } else { // 检查文件是否存在(仅对普通路径检查) try { const isExist = fs.accessSync(filePath); if (!isExist) { throw new Error(\`文件不存在: \${filePath}\`); } } catch (fsError) { throw new Error(\`文件访问失败: \${filePath}\`); } uri = fileUri.getUriFromPath(filePath); // 普通路径,转换为URI } return uri; } (三)预览核心方法/\*\* \* 打开文件预览 - 统一入口方法 \*/ async openPreview(filePath: string, mimeType: string, config?: PreviewConfig): Promise\<void\> { if (!this.context) { throw new Error(\'Context未设置,请先调用setContext方法\'); } try { // 1. 路径处理 const uri = this.processFilePath(filePath); // 2. 配置合并 const previewConfig = this.mergePreviewConfig(config); // 3. 构建预览参数 const previewFile: filePreview.PreviewInfo = { title: previewConfig.title!, uri: uri, mimeType: mimeType }; const previewWindowConfig: filePreview.DisplayInfo = { x: previewConfig.x!, y: previewConfig.y!, width: previewConfig.width!, height: previewConfig.height! }; // 4. 检查支持性 const isCanPreview = await this.canPreview(filePath); if (!isCanPreview) { throw new Error(\'文件不支持预览(格式不支持或文件不存在)\'); } // 5. 打开预览 await filePreview.openPreview(this.context, previewFile, previewWindowConfig); } catch (error) { const businessError = error as BusinessError; console.error(\`FilePreviewUtil openPreview error: \${JSON.stringify(businessError)}\`); throw new Error(\`预览失败:\${businessError.message \|\| businessError.code \|\| \'未知错误\'}\`); } } (四)演示页面集成/\*\* \* 演示页面 - 简化使用示例 \*/ \@Entry \@Component struct FilePreviewDemo { \@State previewStatus: string = \'点击下方按钮预览文件\'; private previewUtil: FilePreviewUtil = filePreviewUtil; // 初始化上下文 aboutToAppear(): void { const uiContext: UIContext = this.getUIContext(); const context = uiContext.getHostContext() as Context; this.previewUtil.setContext(context); } // 简化后的预览调用 async openFilePreview() { try { const testFilePath = await this.previewUtil.createTestFile(\'demo.txt\', \'测试内容\'); const previewConfig: PreviewConfig = { title: \'演示文档\', x: 50, y: 150, width: 350, height: 550 }; await this.previewUtil.openPreview(testFilePath, \'text/plain\', previewConfig); this.previewStatus = \'预览成功!\'; } catch (error) { this.previewStatus = error.message; } } } (五)功能时序图5.成果总结通过封装文件预览工具类,取得了以下成果:(一)使用简化代码量减少:从复杂的多参数调用简化为单一方法调用配置智能化:自动填充默认值,支持部分自定义错误统一处理:统一的异常捕获和用户友好提示(二)功能完善多格式支持:支持文本、图片等多种文件格式路径自适应:自动识别和处理不同路径格式状态管理:完整的预览窗口生命周期管理(三)健壮性提升上下文安全:强制上下文检查,避免空指针异常文件验证:自动检查文件存在性和支持性(四)开发效率快速集成预览功能提供一致的用户体验降低维护成本
  • [开发技术领域专区] 开发者技术支持-鸿蒙动画使用经验技术总结
    一、 关键技术难点总结1. 问题说明在鸿蒙应用开发中,动效是提升用户交互体验的核心元素(如组件显隐、尺寸变化、状态切换等场景),但不合理的动效实现方式会导致性能开销激增,暴露出一系列痛点,具体可从以下维度展开:(一)转场逻辑复杂且易出错鸿蒙原生动效开发中,若误用组件动画(animateTo)实现转场效果(如组件显隐),需在动画结束回调中处理组件状态(如isDisplayed切换),不仅需维护clickTimes等额外变量避免回调冲突,还需手动控制 “透明度变化→组件隐藏” 的联动逻辑,易因回调时序问题导致动画断层或组件状态异常(如多次点击后组件无法正常显示)。(二)代码冗余度高animateTo调用分散:当多个组件属性(如宽度、颜色)需同步动画时,若拆分多个animateTo调用,需维护相同的动画参数(如duration、curve),易出现参数不一致导致的动画不同步,且代码冗余度高。2. 原因分析(一)动效实现方式定位偏差鸿蒙提供transition(转场动画)与animateTo(组件动画)两种核心动效能力,但开发者易混淆二者适用场景:animateTo侧重组件属性的动态变化,transition侧重组件 “出现 / 消失” 的状态切换。若用animateTo实现转场,需手动衔接 “属性变化→状态切换”,违背animateTo的设计初衷,导致逻辑复杂。(二)animateTo调用逻辑分散animateTo的设计逻辑是 “单次调用处理一组关联状态变更”,但开发者常因代码组织习惯(如按属性拆分函数)将相同参数的动画拆分调用,未利用 “同一闭包内同步更新多属性” 的特性,导致动画引擎重复执行状态对比与帧计算,增加冗余操作。3. 解决思路核心思路:基于鸿蒙动效引擎特性,匹配场景选择最优实现方式,平衡视觉体验,具体方向如下:转场场景:优先使用transition利用transition对 “组件显隐” 的原生支持,仅需切换isVisible等状态即可触发动画,无需手动处理回调逻辑,减少属性更新次数(从 2 次降至 1 次)。多属性动画:合并animateTo调用当多个组件属性(如宽度、颜色)需同步动画且参数(duration、curve)相同时,将所有属性更新合并到同一个animateTo闭包中,减少动画引擎的对比与计算次数。多次animateTo:统一状态更新若需多次触发animateTo,先统一计算所有目标状态(如先确定最终width、color值),再传入animateTo执行,避免频繁变更状态导致的多次刷新。4. 解决方案(一)转场动画优化:使用transition替代animateTo通过transition绑定组件显隐状态,自动处理 “出现 / 消失” 动画,减少逻辑复杂度与性能开销。animateTo实现转场(需回调处理)@Entry @Component struct TextToggleView { @State textTransparency: number = 1; @State isDisplayed: boolean = true; clickTimes: number = 0; build() { Column() { // 可切换显示状态的文本区域 Row() { if (this.isDisplayed) { Text('content') .opacity(this.textTransparency) } } .width('100%') .height(100) .justifyContent(FlexAlign.Center) // 控制按钮 Text('switch visibility') .onClick(() => { this.clickTimes++; const currentClick: number = this.clickTimes; this.isDisplayed = true; // 执行透明度动画,需在回调中隐藏组件 animateTo({ duration: 1000, onFinish: () => { if (currentClick === this.clickTimes && this.textTransparency === 0) { this.isDisplayed = false; } } }, () => { this.textTransparency = this.textTransparency === 1 ? 0 : 1; }) }) } } } transition实现转场(无需回调)@Entry @Component struct TextTransitionView { @State isVisible: boolean = true; build() { Column() { Row() { if (this.isVisible) { Text('content') .id('textElement') // 唯一标识,确保动画可打断 // 绑定透明度过渡动画 .transition(TransitionEffect.OPACITY.animation({ duration: 1000 })) } } .width('100%') .height(100) .justifyContent(FlexAlign.Center) Text('switch display') .onClick(() => { // 仅需切换状态,自动触发转场动画 this.isVisible = !this.isVisible; }) } } } (二)使用图形变换属性通过scale(缩放)、translate(平移)等属性实现组件尺寸 / 位置变化。修改width/height实现缩放@Entry @Component struct ResizeableTextView { @State boxWidth: number = 10; @State boxHeight: number = 10; build() { Column() { Text() .backgroundColor(Color.Blue) .width(this.boxWidth) .height(this.boxHeight) Button('调整尺寸') .margin({ top: 30 }) .onClick(() => { animateTo({ duration: 1000 }, () => { this.boxWidth = 100; this.boxHeight = 100; }) }) } } } 使用scale实现缩放@Entry @Component struct ScaleTransformView { @State boxScaleX: number = 1; @State boxScaleY: number = 1; build() { Column() { Text() .backgroundColor(Color.Blue) .width(10) .height(10) .scale({ x: this.boxScaleX, y: this.boxScaleY }) // 图形变换 Button('缩放变换') .margin({ top: 60 }) .onClick(() => { animateTo({ duration: 1000 }, () => { this.boxScaleX = 10; // 仅改变换属性 this.boxScaleY = 10; }) }) } } } (三)animateTo合并优化:相同参数合并调用将相同动画参数的多属性更新合并到一个animateTo闭包,减少调用次数与性能开销。@Entry @Component struct AnimatedBarController { @State barWidth: number = 200; @State barColor: Color = Color.Red; // 合并多属性动画 toggleBarProperties() { animateTo({ curve: Curve.Sharp, duration: 1000 }, () => { this.barWidth = this.barWidth === 100 ? 200 : 100; this.barColor = this.barColor === Color.Yellow ? Color.Red : Color.Yellow; }); } build() { Column() { Row().width(this.barWidth).height(10).backgroundColor(this.barColor) Text('点击触发').onClick(() => { this.toggleBarProperties(); // 单次调用,减少开销 }) } } } (四)多次animateTo状态统一更新当需多次触发animateTo时,先统一计算所有目标状态,再传入动画闭包,避免频繁状态变更。@Entry @Component struct MultiAnimateView { @State BOXwidth: number = 100; @State BOXheight: number = 100; updateSize() { // 1. 统一计算目标状态 const targetWidth = this.BOXwidth + 50; const targetHeight = this.BOXheight + 50; // 2. 单次animateTo更新所有状态 animateTo({ duration: 500 }, () => { this.BOXwidth = targetWidth; this.BOXheight = targetHeight; }); } build() { Column() { Text().width(this.BOXwidth).height(this.BOXheight).backgroundColor(Color.Green) Button('连续放大').onClick(() => { this.updateSize(); // 多次点击也仅单次状态更新 }) } } } 5. 成果总结(一)开销显著降低转场动画:使用transition后,属性更新次数减少,避免回调逻辑引发的性能波动;animateTo优化:合并调用后耗时降低,应用整体卡顿率下降。(二)效率大幅提升图形变换方案避免布局适配调试,开发周期缩短;animateTo合并调用减少代码冗余,维护成本降低。
  • [开发技术领域专区] 开发者技术支持-鸿蒙瀑布流列表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列、响应式列数)复杂的交互功能(拖拽排序、动画效果)不同的加载策略(分页加载、虚拟滚动)主题切换和样式定制通过本次组件封装,成功将瀑布流列表的实现标准化、组件化,为后续项目开发提供了可靠的基础组件支撑。
  • [技术交流] 如何解决DevEco Studio低版本导入高版本项目失败方案
    基于DevEco Studio 4.0 Beta2,hvigorVersion为3.0.2,开发了一个项目,上传到了远程仓库,当同事下载后,却始终无法运行,频繁报错,由于API都是使用的9,第一感觉就是开发环境不同,于是,让其发来了他的开发环境,DevEco Studio 3.1.1 Release,hvigorVersion为2.4.2,果然是环境不同,那解决方式就简单了,要么升级IDE,要么hvigorVersion改为当前设备;升级IDE,确实可以解决,但不是最优之解,毕竟为了解决问题,就来一次升级,一是不方便,二是大多数开发者都喜欢稳定版,升级为Beta版,着实不愿;那就只能走第二种方式了,但偏偏第二种方式,始终解决不了,查官网,查社区,文档匮乏到简直没有。在Android Studio中,出现开发环境不同,无非就是更改build.gradle下gradle版本号和gradle-wrapper.properties文件里的版本号,便可轻松解决问题,但在DevEco Studio中,这种方式似乎难以走通,但是又不得不去解决,毕竟在很多的场合下,开发环境是很难达到统一的,比如你去下载一个三方库,想运行看看效果,有可能就会遇到开发环境不同,无法运行情况,所以,遇到问题,就要去解决问题,而不是逃避问题,只有这样方可柳暗花明,方可造福大众。经过半天的摸索,问题已经修复,虽然现在回首,感觉问题的解决方式很是简单,但是在无资料,错误提示不明的情况下,解决起来真的无从下手,好在最后没有白忙活一场,于是就总结了一下,如果你在开发HarmonyOS开发中也需要到了此类问题,希望能给你带来一丝帮助。一、低版本IDE导入高版本项目报错信息当你开发环境低于目标项目开发环境时,在导入项目后就会遇到如下的错误信息。给出了两种解决途径,一种是更改为本地的hvigorVersion,一种是升级IDE,当我们点击蓝色文字时,就会主动触发其解决方式,我们直接点击第一种。可以发现,在hvigor-config.json5文件中,已改为你设备的版本号。编译项目之后,你会发现,毛用没有,虽然没错误了,但是无法运行。二、文档匮乏,如何迎难而上遇到如上的问题,肯定是先去官网,社区摸索一通,但是,丝毫没有找到解决的方式,没办法,只能自己调研其解决方式,仿照着Android Studio类似的错误,来解决DevEco Studio,由于文件以及构建工具都发生了变化,最后也难以排查出根本问题所在,但是灵感却来了,既然IDE可以类比,那么项目同样可以类比。高版本和低版本,项目结构基本一样,既然无法运行,除了版本号不一致之外,肯定还有部分文件不一致,才导致了项目无法编译,基于这种猜想,针对高低版本两个项目所有的文件,采取了一一类比,经过验证,这种方式是可行的,但是是十分耗时的。
  • [开发技术领域专区] 开发者技术支持-鸿蒙VideoPreview视频播放组件技术总结
    一、关键技术难点总结1.问题说明在实际应用开发中,用户对于视频预览播放(如会话聊天中的视频消息播放、图片视频空间的视频预览等场景)是非常常见的需求。然而,鸿蒙原生的Video组件ui效果无法满足用户需求。Ui的播放暂停按钮需要自定义:Video组件只是单纯的加载播放的组件,播放暂停等常用功能按钮需要自己定义:开发人员在使用video的时候如果每次都需要去实现一套ui以及各种基础功能的api会导致整体效率不高且效果各异播放器的动画效果等统一封装后可以在后期需要改动产品效果等时,统一修改更加高效2.原因分析(1) 原生播放组件无法满足需求VideoPreview组件的核心定位是单一维度的视频预览播放工具,其设计初衷是满足用户预览视频的需求。这种定位决定了组件在功能规划上更侧重整体播放的效果以及ui的统一性,原生的video组件无法满足这个需求。(2) 开发逻辑的独立性VideoPreview组件的底层实现逻辑具有较强的独立性与封闭性。每个组件实例仅负责处理自身对应的视频数据(如本地视频数据以及网络视频数据)。(3) 开发冗余使用多个不同开发者开发的 video组件进行视频播放时,不同的开发者对于最终ui效果以及动画效果的理解差异,会导致最终呈现给用户的最终预览效果的差异,这样不仅开发人员各自增加了开发工作量,也无法很好的给用户提供统一、优质的视频预览效果,最终影响开发效率和使用体验。3.解决思路(1) 组件整合:打造统一标准的视频播放器组件针对鸿蒙原生 video组件没有统一样式的播放按钮的痛点(核心思路是基于组件化思想对原生组件进行封装扩展,通过复用原生能力、状态管理与动态适配,实现播放、暂停、重播、未加载完成时的预览图功能。具体包括:自定义video组件基础能力,组合播放、暂停、重播功能的统一ui按钮,播放进度条样式,解决ui标准不统一问题;采用鸿蒙装饰器实现视频数据必传入的方式,让开发人员很容易理解应该如何传值,可减少开发成本,提升开发效率;封装组件进度播放时、暂停时的动画,根据当前播放状态展示不同按钮(如播放中、播放完成、播放进度条平滑隐藏等)。​(2) 交互增强:提升播放暂停完成时的动画效果播放的时候,用户点击可以显示或者隐藏播放进度条,同时平滑处理显示与隐藏动画,提高用户体验。​4.解决方案(1) Ui实现:通过自定义按钮资源已经布局,封装VideoPreview组件。示例代码:@Observed export class VideoPreviewViewModel { // 视频控制器 controller: VideoController = new VideoController(); // 设置当前播放时间 setCurrentTime(time: number): void { this.controller.setCurrentTime(time) } } @Component export struct VideoPreview { // 视频源地址(必传) @Prop videoUri: Resource | string = '' // 预览图片地址 @Prop imgUri: Resource | string = '' // 是否自动播放 @Prop autoPlay: boolean = true // 播放速度 @Prop speed: PlaybackSpeed = PlaybackSpeed.Speed_Forward_1_00_X // 关闭事件回调 onClose?: () => void // 组件内部状态 @State state: VideoState = new VideoState() @State animationProperty: AnimationOption = new AnimationOption() // 视图模型 @State viewModel: VideoPreviewViewModel = new VideoPreviewViewModel() aboutToAppear() { this.animationProperty.duration = 300 this.animationProperty.curve = Curve.EaseInOut } build() { Stack() { this.VideoBuilder() this.buildControls() // 加载状态显示 if (this.state.isLoading) { Image(this.imgUri) .width('100%') .height('100%') .objectFit(ImageFit.Cover) LoadingProgress() .width(40) .height(40) .color(Color.White) } } .width('100%') .height('100%') .backgroundColor(Color.Black) } // 视频播放器构建器 @Builder VideoBuilder() { Stack() { // 重播按钮(播放完成时显示) if (this.state.isFinish) { Column() { Image($r('app.media.replay_video')) .width(50) .height(50) .onClick(() => { this.viewModel.controller.start() }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .zIndex(33) } // 视频组件 Video({ controller: this.viewModel.controller, currentProgressRate: this.state.speed, src: this.videoUri }) .muted(this.state.isVoiceOff) .objectFit(ImageFit.Contain) .autoPlay(this.autoPlay) .controls(false) .width('100%') .height('100%') .backgroundColor(Color.Black) .onPrepared((event: PreparedInfo) => { this.state.duration = event.duration this.state.isControlsVisible = 1 this.state.isLoading = false console.info('Video prepared, duration: ' + event.duration) }) .onUpdate((event: PlaybackInfo) => { this.state.currentTime = event.time }) .onStop(() => { this.state.isPlaying = false }) .onPause(() => { this.state.isPlaying = false }) .onStart(() => { this.state.isPlaying = true this.state.isLoading = false this.state.isFinish = false }) .onFinish(() => { this.state.isPlaying = false this.state.isFinish = true this.state.isLoading = false }) .onError(() => { console.error('Video playback error') this.state.isLoading = false }) } } // 控制栏构建器 @Builder buildControls() { Column() { // 顶部关闭按钮区域 Column() { Image($r("app.media.close_video")) .width(30) .height(30) .onClick(() => { if (this.onClose) { this.onClose() } }) } .width('100%') .height(80) .backgroundColor('#99000000') .padding({ top: 20, right: 12 }) .alignItems(HorizontalAlign.End) .visibility(this.state.isControlsVisible? Visibility.Visible : Visibility.Hidden) .animation(this.animationProperty) Blank() // 音量控制 Column() { Image(this.state.isVoiceOff ? $r('app.media.voice_off') : $r('app.media.voice_on')) .width(24) .height(24) .onClick(() => { this.state.isVoiceOff = !this.state.isVoiceOff }) } .padding({ right: 12 }) .width('100%') .visibility(this.state.isControlsVisible? Visibility.Visible : Visibility.Hidden) .alignItems(HorizontalAlign.End) // 底部进度控制区域 Column() { Row({ space: 8 }) { // 播放/暂停按钮 Image(this.state.isPlaying ? $r('app.media.pause_video') : $r('app.media.play_video')) .width(24) .height(24) .onClick(() => { if (this.state.isPlaying) { this.viewModel.controller.pause() } else { this.viewModel.controller.start() } this.state.isPlaying = !this.state.isPlaying }) .margin({ left: 10 }) // 当前时间 Text(this.formatTime(this.state.currentTime)) .fontColor(Color.White) .fontSize(12) .width(40) .textAlign(TextAlign.Center) // 进度条 Slider({ value: this.state.currentTime, min: 0, max: this.state.duration, style: SliderStyle.OutSet }) .layoutWeight(1) .blockColor(Color.White) .selectedColor('#FF4081') .trackColor('#CCCCCC') .trackThickness(3) .onChange((value: number) => { this.viewModel.controller.setCurrentTime(value) }) // 总时长 Text(this.formatTime(this.state.duration)) .fontColor(Color.White) .fontSize(12) .width(40) .textAlign(TextAlign.Center) .margin({ right: 10 }) } .width('100%') .height(60) .alignItems(VerticalAlign.Center) .backgroundColor('#99000000') } .width('100%') .visibility(this.state.isControlsVisible? Visibility.Visible : Visibility.Hidden) .animation(this.animationProperty) } .width('100%') .height('100%') // 手势控制:双击播放/暂停,单击显示/隐藏控制栏 .gesture( GestureGroup( GestureMode.Exclusive, TapGesture({ count: 2 }) .onAction(() => { if (this.state.isPlaying) { this.viewModel.controller.pause() } else { this.viewModel.controller.start() } this.state.isPlaying = !this.state.isPlaying }), TapGesture({ count: 1 }) .onAction(() => { if (this.state.isControlsVisible) { this.state.isControlsVisible = 0; } else { this.state.isControlsVisible = 1; } }) ) ) } // 时间格式化工具方法 private formatTime(seconds: number): string { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` } } // 视频状态类 @Observed class VideoState { isPlaying: boolean = false isFinish: boolean = false isLoading: boolean = true isVoiceOff: boolean = false isControlsVisible: number = 0 currentTime: number = 0 duration: number = 0 speed: PlaybackSpeed = PlaybackSpeed.Speed_Forward_1_00_X } // 动画配置类 class AnimationOption { duration: number = 300 curve: Curve = Curve.EaseInOut delay: number = 0 iterations: number = 1 playMode: PlayMode = PlayMode.Normal } 交互体检:用户操作流程:点击video→进度条及播放按钮隐藏→再次点击video→进度条及播放按钮显示。5.方案成果总结(1) 通过自定义封装组件一体化设计统一视频预览播放器的样式,减少开发人员的开发成本(2) 清晰传值方式,使得开发者很容易的使用这个视频预览组件(3) 加载时的图片预览可以使得加载时不是默认的黑屏,提高用户体验,统一的点击隐藏与显示效果,完美实现了客户对于视频播放器ui的需求,最终实现原生视频播放器video的优化升级。
  • [开发技术领域专区] 开发者技术支持-日历备忘录控件技术总结
    一、关键技术难点总结1. 问题说明在日常办公(日程管理、会议记录)、生活规划(待办事项、纪念日标记)等场景中,用户普遍需要 “日历查看 + 日期关联备忘录” 的一体化功能 —— 既能够直观浏览月历、定位日期,又能快速为指定日期添加、查看、删除备忘录,且数据需长期保存不丢失。然而,鸿蒙原生组件库中并无此类一体化控件:若直接拼接Calendar与List等基础组件实现需求,会暴露出一系列痛点:功能割裂:日历与备忘录需单独开发,缺乏日期与备忘录的原生联动机制,需手动处理 “日期选中→加载对应备忘录”“新增备忘录→关联当前日期” 等核心逻辑;开发低效:需重复编写日历数据生成(月份切换、空白天数填充)、数据持久化(备忘录存储)、状态同步(面板显隐、数据更新)等代码,且易因逻辑分散导致 bug;体验欠佳:用户需在日历组件与备忘录组件间频繁切换操作,无 “今日高亮”“有备忘录日期标记” 等引导性交互,易出现日期混淆、数据遗漏等问题。2. 原因分析日历逻辑的复杂性日历本质是 “时间维度的网格数据”,涉及年 / 月 / 日的时间计算、星期几的偏移量换算,原生组件未封装此类聚合逻辑,需开发者从零实现时间计算规则,增加了出错概率。持久化接口的异步特性鸿蒙preferences接口的getPreferences“put”“flush” 等方法均为异步操作,而组件渲染与用户交互是同步过程,若未做好异步等待与错误捕获,易出现 “数据未加载完成就渲染”“保存操作中断” 等问题。状态管理的分散性控件包含 “日历数据”“备忘录数据”“用户交互状态” 等多类状态,若仅依赖局部变量管理,会导致状态传递链路混乱,难以实现 “日期选中→面板显隐→数据加载” 的连贯逻辑。交互细节的缺失原生Text“Grid” 等组件仅提供基础展示能力,无针对 “日历场景” 的交互封装,需开发者结合业务需求手动设计 “今日高亮”“备忘录标记” 等样式,增加了交互优化的开发成本。3. 解决思路针对上述难点,核心思路是基于鸿蒙组件化与状态管理能力,对基础组件进行封装整合,实现 “日历展示 - 日期交互 - 备忘录管理 - 数据持久化” 的一体化解决方案:日历数据模块化生成封装独立的generateCalendarData方法,统一处理 “月份天数计算、月初空白天数填充、跨月数据更新” 逻辑,通过currentDate状态驱动数据实时刷新,确保日历数据准确性。持久化操作分层封装基于preferences接口封装 “初始化 - 加载 - 保存” 的完整流程,通过异步等待(async/await)处理读写时序,增加错误捕获机制,确保备忘录数据持久化的可靠性。状态集中管理与联动采用鸿蒙@State装饰器管理组件内部状态(如currentDate“memos”“showMemoPanel”),通过状态变更自动触发 UI 刷新,实现 “日期选中→备忘录加载→面板显隐” 的连贯逻辑。交互细节精细化优化新增 “今日高亮”“有备忘录日期小红点标记”“备忘录面板显隐动画” 等交互细节,通过条件渲染(if/else)与样式绑定,提升操作直观性与用户体验。4. 解决方案(一)日历数据生成模块通过generateCalendarData方法统一处理日历数据逻辑,根据当前选中的currentDate动态生成月历网格数据:generateCalendarData() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); // 获取当月第一天与最后一天 const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); // 计算当月第一天是星期几(0=周日,6=周六) const firstDayOfWeek = firstDay.getDay(); const daysInMonth = lastDay.getDate(); this.calendarData = []; // 填充月初空白天数 for (let i = 0; i < firstDayOfWeek; i++) { this.calendarData.push(null); } // 填充当月日期 for (let i = 1; i <= daysInMonth; i++) { this.calendarData.push(i); } } 关键逻辑:通过new Date(year, month + 1, 0)精准获取当月最后一天的日期,避免手动判断大月 / 小月 / 闰年;通过firstDay.getDay()计算月初偏移量,确保日期与星期对应正确。(二)数据持久化模块基于preferences实现备忘录数据的本地存储,封装初始化、加载、保存三个核心方法,处理异步时序与错误:// 初始化偏好设置 async initPreferences() { try { let context: Context = this.getUIContext().getHostContext() as Context; this.pref = await preferences.getPreferences(context, 'calendar_memos'); this.loadMemos(); // 初始化后立即加载数据 } catch (err) { console.error(`初始化偏好设置失败: ${(err as BusinessError).message}`); } } // 加载备忘录数据 async loadMemos() { if (!this.pref) return; try { const saved = await this.pref.get('memos', '{}'); this.memos = JSON.parse(saved as string); // 反序列化为对象 console.log('备忘录加载成功'); } catch (err) { console.error(`加载备忘录失败: ${(err as BusinessError).message}`); } } // 保存备忘录数据 async saveMemos() { if (!this.pref) return; try { await this.pref.put('memos', JSON.stringify(this.memos)); // 序列化为字符串 await this.pref.flush(); // 强制写入本地 console.log('备忘录保存成功'); } catch (err) { console.error(`保存备忘录失败: ${(err as BusinessError).message}`); } } 关键逻辑:通过async/await确保 “初始化→加载” 的时序正确性;使用JSON.stringify/parse实现对象与字符串的转换,适配preferences的字符串存储特性;增加try/catch捕获读写异常,避免控件崩溃。(三)日期 - 备忘录联动模块通过状态联动实现 “日期选中→备忘录加载→面板显隐” 的完整流程,核心方法如下:// 处理日期点击事件 handleDateClick(day: number) { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth() + 1; // 补0确保格式统一(如2024-05-01) const monthStr = month.toString().padStart(2, '0'); const dayStr = day.toString().padStart(2, '0'); this.selectedDate = `${year}-${monthStr}-${dayStr}`; this.showMemoPanel = true; // 显示备忘录面板 this.newMemoContent = ''; // 清空输入框 } // 获取指定日期的备忘录 getMemosForDate(dateStr: string): string[] { return this.memos[dateStr] || []; // 无数据时返回空数组 } // 添加备忘录 addMemo() { if (!this.selectedDate || !this.newMemoContent.trim()) return; // 若当前日期无备忘录,初始化空数组 if (!this.memos[this.selectedDate]) { this.memos[this.selectedDate] = []; } // 新增备忘录并去重 this.memos[this.selectedDate] = [...this.memos[this.selectedDate], this.newMemoContent.trim()]; this.newMemoContent = ''; this.saveMemos(); // 自动保存 } 关键逻辑:通过padStart(2, '0')统一日期格式(如 “5 月 3 日” 转为 “05-03”),避免因格式不一致导致数据关联失败;新增备忘录后自动调用saveMemos,确保数据实时持久化。(四)交互优化模块通过条件渲染与样式绑定实现精细化交互,提升用户体验:今日日期高亮isToday(day: number): boolean { const today = new Date(); return ( day === today.getDate() && this.currentDate.getMonth() === today.getMonth() && this.currentDate.getFullYear() === today.getFullYear() ); } 渲染时通过isToday判断,为今日日期添加蓝色半透明背景:if (this.isToday(day)) { Text('') .backgroundColor('#1677ff') .borderRadius(15) .width(30) .height(30) .opacity(0.5) } 有备忘录日期标记hasMemo(day: number): boolean { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth() + 1; const monthStr = month.toString().padStart(2, '0'); const dayStr = day.toString().padStart(2, '0'); const dateStr = `${year}-${monthStr}-${dayStr}`; return !!this.memos[dateStr]?.length; } 渲染时通过hasMemo判断,为有备忘录的日期添加红色小红点:if (this.hasMemo(day)) { Text('') .backgroundColor('#ff4d4f') .borderRadius(3) .width(6) .height(6) .position({ x: '50%', y: '70%' }) .transform({ translate: { x: -3, y: 0 } }); } ​ 完整代码示例:import { BusinessError } from '@ohos.base'; import preferences from '@ohos.data.preferences'; import { Context } from '@ohos.abilityAccessCtrl'; @Entry @Component struct Index { build() { Column() { CalendarMemoControl() } .width('100%') .height('100%') .backgroundColor('#f0f0f0') } } @Component export struct CalendarMemoControl { @State currentDate: Date = new Date(); @State calendarData: (number | null)[] = []; @State weekDays: string[] = ['日', '一', '二', '三', '四', '五', '六']; @State memos: Record<string, string[]> = {}; // 存储格式:{日期: ["09:05 - 备忘录内容", ...]} @State selectedDate: string = ''; @State newMemoContent: string = ''; @State showMemoPanel: boolean = false; @State showTimePicker: boolean = false; // ========== 关键修改1:时间状态改为 String 类型(与数组格式一致) ========== @State selectedHour: string = this.formatTimeUnit(new Date().getHours()); // 初始值:当前小时(如“09”) @State selectedMinute: string = this.formatTimeUnit(new Date().getMinutes()); // 初始值:当前分钟(如“05”) // ========== 关键修改2:时间数组改为 String 类型(两位数格式) ========== private hourList: string[] = []; // 最终值:["00", "01", ..., "23"] private minuteList: string[] = []; // 最终值:["00", "01", ..., "59"] // ====================================================================== private pref: preferences.Preferences | null = null; aboutToAppear() { this.generateCalendarData(); this.initPreferences(); this.initTimeLists(); // 初始化 String 类型的时间数组 } // ========== 工具方法:将数字转为两位数字符串(如 9 → "09",12 → "12") ========== private formatTimeUnit(num: number): string { return num.toString().padStart(2, '0'); } // ========== 初始化 String 类型的时间数组 ========== private initTimeLists() { // 1. 生成小时数组(00-23,String 类型) for (let i = 0; i < 24; i++) { this.hourList.push(this.formatTimeUnit(i)); } // 2. 生成分钟数组(00-59,String 类型) for (let i = 0; i < 60; i++) { this.minuteList.push(this.formatTimeUnit(i)); } } async initPreferences() { try { let context: Context = this.getUIContext().getHostContext() as Context; this.pref = await preferences.getPreferences(context, 'calendar_memos'); this.loadMemos(); } catch (err) { console.error(`初始化偏好设置失败: ${(err as BusinessError).message}`); } } generateCalendarData() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const firstDayOfWeek = firstDay.getDay(); const daysInMonth = lastDay.getDate(); this.calendarData = []; // 填充月初空白 for (let i = 0; i < firstDayOfWeek; i++) { this.calendarData.push(null); } // 填充当月日期 for (let i = 1; i <= daysInMonth; i++) { this.calendarData.push(i); } } async saveMemos() { if (!this.pref) return; try { await this.pref.put('memos', JSON.stringify(this.memos)); await this.pref.flush(); console.log('备忘录保存成功'); } catch (err) { console.error(`保存备忘录失败: ${(err as BusinessError).message}`); } } async loadMemos() { if (!this.pref) return; try { const saved = await this.pref.get('memos', '{}'); this.memos = JSON.parse(saved as string); console.log('备忘录加载成功'); } catch (err) { console.error(`加载备忘录失败: ${(err as BusinessError).message}`); } } // 获取指定日期的备忘录列表 getMemosForDate(dateStr: string): string[] { return this.memos[dateStr] || []; } // ========== 添加备忘录(时间已为 String 类型,直接拼接) ========== addMemo() { // 校验:日期未选择 或 内容为空,不执行添加 if (!this.selectedDate || !this.newMemoContent.trim()) { return; } // 拼接时间和内容(如“09:05 - 晨会”) const memoWithTime = `${this.selectedHour}:${this.selectedMinute} - ${this.newMemoContent.trim()}`; // 初始化当前日期的备忘录数组(若不存在) if (!this.memos[this.selectedDate]) { this.memos[this.selectedDate] = []; } // 添加新备忘录(不可变更新,触发状态刷新) this.memos[this.selectedDate] = [...this.memos[this.selectedDate], memoWithTime]; // 重置输入框和时间选择器 this.newMemoContent = ''; this.showTimePicker = false; // 保存到偏好设置 this.saveMemos(); } // 删除指定索引的备忘录 deleteMemo(index: number) { if (!this.selectedDate || !this.memos[this.selectedDate]) { return; } // 不可变更新:复制原数组并删除指定元素 const newMemos = [...this.memos[this.selectedDate]]; newMemos.splice(index, 1); // 若数组为空,删除当前日期的键(避免空数组残留) if (newMemos.length === 0) { this.memos[this.selectedDate]; } else { this.memos[this.selectedDate] = newMemos; } // 保存到偏好设置 this.saveMemos(); } // 点击日历日期:打开备忘录面板并重置时间 handleDateClick(day: number) { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth() + 1; // 格式化日期为“YYYY-MM-DD”(如“2024-05-20”) const monthStr = this.formatTimeUnit(month); const dayStr = this.formatTimeUnit(day); this.selectedDate = `${year}-${monthStr}-${dayStr}`; // 重置状态:打开面板、清空输入框、重置时间为当前时间 this.showMemoPanel = true; this.newMemoContent = ''; this.selectedHour = this.formatTimeUnit(new Date().getHours()); this.selectedMinute = this.formatTimeUnit(new Date().getMinutes()); } // 切换到上月 prevMonth() { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, 1); this.generateCalendarData(); } // 切换到下月 nextMonth() { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 1); this.generateCalendarData(); } // 回到今天 goToToday() { this.currentDate = new Date(); this.generateCalendarData(); } // 判断是否为今天 isToday(day: number): boolean { const today = new Date(); return ( day === today.getDate() && this.currentDate.getMonth() === today.getMonth() && this.currentDate.getFullYear() === today.getFullYear() ); } // 判断指定日期是否有备忘录(用于显示小红点) hasMemo(day: number): boolean { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth() + 1; const monthStr = this.formatTimeUnit(month); const dayStr = this.formatTimeUnit(day); const dateStr = `${year}-${monthStr}-${dayStr}`; // 存在备忘录且数组长度 > 0,返回 true return !!this.memos[dateStr]?.length; } // 格式化月份显示(如“2024年5月”) formatMonthDisplay(): string { return `${this.currentDate.getFullYear()}年${this.currentDate.getMonth() + 1}月`; } build() { Column({ space: 10 }) { List() { ListItem() { Column({ space: 12 }) { // 1. 日历标题栏(上月/当月/下月切换) Row({ space: 16 }) { Button('上月') .fontSize(14) .backgroundColor('#1677ff') .fontColor('#fff') .borderRadius(8) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .onClick(() => this.prevMonth()); Text(this.formatMonthDisplay()) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#333'); Button('下月') .fontSize(14) .backgroundColor('#1677ff') .fontColor('#fff') .borderRadius(8) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .onClick(() => this.nextMonth()); } .justifyContent(FlexAlign.Center) // 2. 回到今天按钮 Button('今天') .fontSize(14) .backgroundColor('#e6f2ff') .fontColor('#1677ff') .borderRadius(16) .padding({ left: 16, right: 16, top: 4, bottom: 4 }) .onClick(() => this.goToToday()); // 3. 星期标题栏(日/一/.../六) Row({ space: 0 }) { ForEach(this.weekDays, (day: string) => { Text(day) .fontSize(14) .flexGrow(1) .textAlign(TextAlign.Center) .padding(8) .fontColor(day === '日' || day === '六' ? '#ff4d4f' : '#666'); }); } Divider(); // 4. 日历网格(日期显示) Grid() { ForEach(this.calendarData, (day: number | null) => { GridItem() { if (day !== null) { Stack({ alignContent: Alignment.Center }) { // 日期文本 Text(day.toString()) .fontSize(14) .textAlign(TextAlign.Center) .width('100%') .height('100%') .padding(12) .fontColor(this.isToday(day) ? '#1677ff' : '#333'); // 今天标识(蓝色半透明圆) if (this.isToday(day)) { Text('') .backgroundColor('#1677ff') .borderRadius(15) .width(30) .height(30) .opacity(0.2); } // 备忘录标识(红色小点) if (this.hasMemo(day)) { Text('') .backgroundColor('#ff4d4f') .borderRadius(3) .width(6) .height(6) .position({ x: '50%', y: '70%' }) .transform({ translate: { x: -3, y: 0 } }); } } .onClick(() => this.handleDateClick(day)) .width('100%') .height('100%'); } else { // 空白格子(月初/月末无日期处) Text('') .width('100%') .height('100%'); } } }); } .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr') .rowsTemplate('1fr 1fr 1fr 1fr 1fr 1fr') .width('100%') .height(360) .padding(10); // 5. 备忘录面板(点击日期后显示) if (this.showMemoPanel && this.selectedDate) { Column({ space: 12 }) { // 面板标题(当前选中日期) Text(`【${this.selectedDate}】的备忘录`) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor('#333') .width('100%') .textAlign(TextAlign.Center); // 备忘录列表 List() { ForEach( this.getMemosForDate(this.selectedDate), (memo: string, index: number) => { ListItem() { Row({ space: 10}) { // 备忘录内容(带时间) Text(memo) .flexGrow(1) .fontSize(14) .fontColor('#333') .padding(8); // 删除按钮 Button('删除') .fontSize(12) .backgroundColor('#ff4d4f') .fontColor('#fff') .borderRadius(4) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .onClick(() => this.deleteMemo(index)); } .width('100%') .alignItems(VerticalAlign.Center) } .backgroundColor('#f5f5f5') .borderRadius(8) .margin(4) .padding(4); }, // 唯一标识(避免列表渲染混乱) (memo: string, index: number) => `${this.selectedDate}-memo-${index}` ); } .height(200) .width('100%') .padding(5) .scrollBar(BarState.Off) .backgroundColor('#fafafa') .borderRadius(8); // 6. 时间选择区域(String 类型时间显示) Column({ space: 8 }) { // 时间显示 + 修改按钮 Row({ space: 12}) { Text(`当前选择时间:${this.selectedHour}:${this.selectedMinute}`) .fontSize(14) .fontColor('#666'); Button('修改时间') .fontSize(12) .backgroundColor('#e6f2ff') .fontColor('#1677ff') .borderRadius(4) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .onClick(() => this.showTimePicker = !this.showTimePicker); } .alignItems(VerticalAlign.Center) // 时间选择器(TextPicker,数据源为 String 数组) if (this.showTimePicker) { Row({ space: 20 }) { // 小时选择器(数据源:hourList = ["00", "01", ..., "23"]) TextPicker({ range: this.hourList, // 计算初始选中索引(根据当前 selectedHour 匹配数组下标) selected: this.hourList.findIndex(item => item === this.selectedHour) }) .width(80) .onChange((value: string | string[], index: number | number[]) => { // 更新选中的小时(直接接收 String 类型值) this.selectedHour = value[0]+value[1]; }); // 分隔符“:” Text(':') .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor('#333'); // 分钟选择器(数据源:minuteList = ["00", "01", ..., "59"]) TextPicker({ range: this.minuteList, // 计算初始选中索引(根据当前 selectedMinute 匹配数组下标) selected: this.minuteList.findIndex(item => item === this.selectedMinute) }) .width(80) .onChange((value: string | string[], index: number | number[]) => { // 更新选中的分钟(直接接收 String 类型值) this.selectedMinute = value[0]+value[1]; }); } .padding(10) .backgroundColor('#f9f9f9') .borderRadius(8) .width('100%') .justifyContent(FlexAlign.Center) } } .width('100%') .alignItems(HorizontalAlign.Start) // 7. 添加备忘录输入区 Row({ space: 10}) { TextInput({ placeholder: '输入新的备忘录...', }) .width('70%') .fontSize(14) .height(40) .border({ width: 1, color: '#ddd', radius: 8 }) .padding(8) .onChange((value: string) => this.newMemoContent = value); Button('添加') .fontSize(14) .backgroundColor('#1677ff') .fontColor('#fff') .borderRadius(8) .padding({ left: 16, right: 16, top: 8, bottom: 8 }) .onClick(() => this.addMemo()); } .width('100%') .alignItems(VerticalAlign.Center) // 8. 关闭面板按钮 Button('关闭备忘录') .fontSize(14) .backgroundColor('#e0e0e0') .fontColor('#333') .borderRadius(8) .padding({ left: 20, right: 20, top: 8, bottom: 8 }) .onClick(() => { this.showMemoPanel = false; this.showTimePicker = false; // 关闭面板时同步隐藏时间选择器 }); } .width('100%') .padding(12) .backgroundColor('#fff') .borderRadius(12) .shadow({ radius: 10, color: '#00000010', offsetY: 2 }); } } } } .scrollBar(BarState.Off) .width('100%'); } .width('100%') .height('100%') .padding(10) .backgroundColor('#f5f5f5'); } } 5. 成果总结开发效率提升控件封装后可直接通过CalendarMemoControl()调用,无需重复编写日历生成、持久化、联动逻辑,开发工作量减少 60% 以上;统一的状态管理与错误处理机制,使 bug 率降低 70%。用户体验优化实现 “日历 - 备忘录” 一体化操作,用户从 “选日期→切页面→写备忘录” 的 3 步操作简化为 “点日期→写内容” 的 2 步,操作耗时减少 40%;“今日高亮”“小红点标记” 等交互细节,使日期识别准确率提升 90%。数据可靠性增强完善的异步错误捕获与数据持久化机制,确保备忘录数据无丢失,经测试,连续 100 次 “新增 - 删除 - 重启应用” 操作后,数据完整性达 100%。
  • [开发技术领域专区] 开发者技术支持-长截图功能技术方案
    鸿蒙开发长截图功能经验总结一、关键技术难点总结1. 问题说明在鸿蒙应用开发中,截图功能是常见的需求,用户常常需要截取整个页面(比如长网页、聊天记录等),但普通截图只能截取当前屏幕显示的内容,长截图才能满足用户需求,但实现高效的长截图功能面临以下核心挑战:问题1:长内容分页显示问题如何准确计算超出屏幕的长文本高度实现精准的内容分页与翻页控制保持分页内容连续性和完整性问题2:截图功能实现难点获取完整内容区域的组件快照2. 原因分析1. 内容分页计算难点文本内容高度动态变化,受字体大小、行高、设备分辨率等多因素影响需要精确计算单行高度作为分页基准单位页面高度需与行高对齐以保证分页完整性2.截图功能技术限制鸿蒙的componentSnapshotAPI只能捕获当前可见区域3. 解决思路1.动态分页计算实时监测文本区域变化,通过onTextAreaChanged监听文本区域和滚动区域的变化,动态更新布局参数(行高、总高度、每页高度)并计算总页数。基于行高计算分页逻辑,使用@State变量跟踪文本行高、页面高度、总内容高度、当前滚动偏移和页码。 翻页时调整滚动偏移量,并更新当前页码。2.截图处理流程获取内容组件像素快照,异步编码为PNG格式安全存储到系统相册使用componentSnapshot.get获取组件快照(像素映射),然后使用imagePacker将像素映射编码为PNG图片文件,通过photoAccessHelper保存到相册。4. 解决方案1. 动态分页实现核心逻辑代码:// 状态管理 @State lineHeight: number = 0; // 单行文本高度 @State pageHeight: number = 0; // 页面最大高度 @State totalTextHeight: number = 0; // 文本总高度 @State textContent: string = " "; // 文本内容 @State scrollPos: number = 0; // 滚动位置 @State totalPageCount: number = 1; // 总页数 @State currentPageNum: number = 1; // 当前页码 scroller: Scroller = new Scroller(); // 滚动控制器 // 常量定义接口 interface TextConstants { Previous_Page: string; Next_page: string; SAVE: string; SAVE_failure: string; } // 消息常量 const MSG: TextConstants = { Previous_Page: '没有上一页', Next_page: '没有下一页', SAVE: '图片已保存到相册', SAVE_failure: '保存失败,请检查权限!' }; // 判断是否是首次测量行高 isFirstLineMeasurement(newArea: Area): boolean { return this.lineHeight === 0 && newArea.height > 0; } // 初始化行高 initializeLineHeight(newArea: Area) { this.lineHeight = newArea.height as number; this.initTextContent(); } // 初始化文本内容 initTextContent() { this.textContent = this.createTextContent(); } // 生成文本内容 createTextContent() { let content = ""; for (let i = 1; i <= 15; i++) { content += ` ${i}、文本内容文本内容文本内容文本内容文本内容文本内容。`; } return content; } // 处理文本区域变化 onTextAreaChanged(newArea: Area) { if (this.isFirstLineMeasurement(newArea)) { this.initializeLineHeight(newArea); return; } this.updateTotalTextHeight(newArea); } // 计算有效页面高度(确保是行高的整数倍) calculateValidPageHeight(): number { return Math.floor(this.pageHeight / this.lineHeight) * this.lineHeight; } // 总页数 calculateTotalPages(): number { return Math.ceil(this.totalTextHeight / this.pageHeight); } //更新布局 adjustLayoutParams() { if (this.lineHeight > 0 && this.pageHeight > 0 && this.totalTextHeight > 0) { this.pageHeight = this.calculateValidPageHeight(); this.totalPageCount = this.calculateTotalPages(); } } 翻页控制实现// 处理翻页 tabpages(direction: 'shang' | 'xia') { if (this.isBoundaryPage(direction)) { this.showBoundaryToast(direction); return; } this.updatePagePosition(direction); } // 判断是否是边界页 isBoundaryPage(direction: 'shang' | 'xia'): boolean { return (direction === 'shang' && this.currentPageNum === 1) || (direction === 'xia' && this.currentPageNum === this.totalPageCount); } // 显示边界提示 showBoundaryToast(direction: 'shang' | 'xia') { const message = direction === 'shang' ? MSG.Previous_Page : MSG.Next_page; promptAction.showToast({ message }); } // 更新页面位置 updatePagePosition(direction: 'shang' | 'xia') { const scrollStep = direction === 'shang' ? this.pageHeight : -this.pageHeight; this.scrollPos += scrollStep; this.currentPageNum += direction === 'shang' ? -1 : 1; } 2.截图功能实现 // 获取应用上下文 getContext(): common.UIAbilityContext { return getContext(this) as common.UIAbilityContext; } // 处理保存错误 handleSaveError(error: BusinessError) { const err = error as BusinessError; console.error(`保存出错: ${err.code}, ${err.message}`); promptAction.showToast({ message: MSG.SAVE_failure, duration: 2000 }); } // 创建图片资源 async createImageAsset(context: common.UIAbilityContext) { const photoHelper = photoAccessHelper.getPhotoAccessHelper(context); return await photoHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg'); } // 打开文件用于写入 async openFileForWriting(uri: string) { return await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); } // 捕获并保存内容 async captureAndSaveContent(file: fs.File) { const pixelMap = await componentSnapshot.get("textContentArea"); const packOpts: image.PackingOption = { format: 'image/png', quality: 100 }; const imgPacker = image.createImagePacker(); await imgPacker.packToFile(pixelMap, file.fd, packOpts); this.cleanupResources(imgPacker, file); promptAction.showToast({ message: MSG.SAVE, duration: 2000 }); } 3.性能优化策略1.精确区域约束Scroll(this.scroller) { //文本内容Text } .scrollBar(BarState.Off) .constraintSize({ maxHeight: this.pageHeight || 1000 }) //约束区域 2.资源管理(释放)// 清理资源 cleanupResources(packer: image.ImagePacker, file: fs.File) { packer.release(); fs.close(file.fd); } 5. 成果总结1.自适应内容分割技术:开发出基于行高的分页算法,精准识别文本段落边界。突破传统截图工具内容截断问题,确保每页内容完整性达100%。2.实时渲染优化系统:完成长截图功能实现,内存占用降低70%实现毫秒级内容重排响应,测试数据显示在100页文档中翻页响应时间<50ms
  • [技术干货] 开发者技术支持-鸿蒙集成TRTC视频直播功能技术方案
    一、关键技术难点总结1. 问题说明在鸿蒙应用中集成 TRTC 视频直播功能时,开发者需应对从 SDK 导入到功能落地的全链路技术挑战,核心痛点可从以下维度展开:(一)  TRTC 基础搭建繁琐:TRTC 直播核心能力(如音视频采集、房间管理、流订阅)无原生鸿蒙组件支持,需依赖第三方 SDK 实现。原生系统未提供 “SDK 导入 - 权限申请 - 实例管理 - 流订阅 - 资源释放” 的一体化工具链,导致基础功能需从零搭建,难以快速满足 “初始化 - 进房 - 直播 - 退房” 的完整业务需求。(二) 直播开发成本风险高:为实现直播功能,开发者需处理多环节技术细节,增加开发成本与出错风险:SDK 集成需手动配置依赖路径与版本兼容,任一环节错误均导致项目同步失败;权限管理需兼顾静态声明与动态申请,敏感权限(相机 / 麦克风)还需配置使用场景说明,适配鸿蒙权限机制;实例创建与事件监听需处理上下文绑定、回调函数 this 指向等问题,否则关键事件(如进房结果)无法捕获;音视频流订阅需精准匹配 XComponent 配置与流类型,订阅时机过早或过晚均导致画面 / 声音异常;资源释放需手动执行停止采集、退出房间、销毁实例等步骤,遗漏任一环节均引发二次进房故障。(三) 直播使用体验欠佳:从用户视角看,直播功能使用过程存在明显体验短板:权限申请无引导,若用户误拒相机 / 麦克风权限,直播功能直接阻塞且无修复提示,用户不知如何操作;进房失败无明确反馈,错误码含义不直观,用户无法判断是网络问题还是参数错误;音视频流订阅延迟或失败时,无画面 / 声音但无加载提示,用户易误以为功能故障;退出房间后资源未释放,再次进房时出现卡顿、闪退等异常,影响直播连续性体验。2. 原因分析(一) 权限与隐私管理的严格性:鸿蒙系统对用户隐私(如相机、麦克风)采取强权限管控策略,TRTC 直播需的敏感权限不仅需静态声明,还需动态申请,且需明确说明使用场景。这种严格性导致权限配置链路长,任一环节缺失均导致功能阻塞,成为直播功能实现的首要障碍。(二) SDK 集成的复杂性:TRTC SDK 作为第三方库,与鸿蒙开发环境存在适配门槛:.har 文件路径、依赖配置格式需严格匹配,版本不兼容直接导致模块找不到;SDK 实例创建依赖有效上下文,回调函数需正确绑定 this,否则核心接口调用失效,增加集成难度。(三) 参数校验与时机控制的缺失:TRTC 进房、流订阅等关键操作依赖精准参数与时机:sdkAppId 与 userSig 不匹配、roomId 格式错误均导致进房失败;XComponent 配置错误(id 不唯一、类型不对)或订阅时机早于进房成功,均导致音视频流无法播放,且无明确错误日志可查。(四) 资源管理的链路疏漏:TRTC 直播涉及相机、麦克风、网络连接等多类资源,退出房间时需按 “停止采集→停止订阅→退出房间→销毁实例” 的固定链路释放资源。由于 SDK 未提供自动释放机制,开发者需手动串联各步骤,易因遗漏或顺序错误导致资源泄漏,引发二次进房异常。二、解决思路(一) SDK 与权限整合:构建标准化集成链路基于 TRTC SDK 特性与鸿蒙权限机制,打造 “SDK 导入 - 权限配置 - 动态申请” 的标准化流程:通过固定.har 文件路径与依赖格式解决导入问题;静态声明与动态申请结合,适配敏感权限管控要求,确保权限获取无阻塞。(二) 实例与事件管理:强化状态绑定与回调适配优化 SDK 实例创建逻辑,确保上下文有效传递;采用箭头函数绑定回调函数 this 指向,保证进房、错误等关键事件可靠监听;通过单例模式管理实例,避免重复创建导致的资源冲突。(三) 参数校验与流订阅优化:精准控制时机与配置建立进房参数校验机制,确保 sdkAppId、userSig、roomId 等核心参数格式正确;规范 XComponent 配置(id 唯一、类型为 SURFACE),严格在进房成功后执行流订阅操作,避免时机错误导致的音视频异常。(四) 资源释放机制:构建完整清理链路设计 “退出房间 - 资源释放” 标准化流程,在页面销毁时自动执行 “停止本地采集→停止远程订阅→退出房间→销毁实例” 步骤,确保资源完全释放,避免二次进房故障。三、解决方案(一) SDK 导入与依赖配置工具通过标准化.har 文件存放路径与依赖配置格式,解决 SDK 导入失败问题,确保项目同步成功。示例代码:// entry/oh-package.json5(依赖配置) "dependencies": { // 替换"xxxxxx"为实际版本号,确保路径与.har文件名一致 "liteavsdk": "file:libs/LiteAVSDK_Professional_xxxxxx.har" } 操作说明:将 TRTC SDK 的.har文件复制到entry/libs 目录;在oh-package.json5中添加上述依赖配置;点击IDE右上角“Sync”按钮同步项目,确认依赖加载成功。(二) 权限管理组件通过静态声明与动态申请结合,实现 TRTC 所需权限的完整配置,适配鸿蒙权限管控要求。示例代码:// entry/module.json5(权限静态声明) "module": { "requestPermissions": [ { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }, { "name": "ohos.permission.INTERNET" }, { "name": "ohos.permission.USE_BLUETOOTH" }, { "name": "ohos.permission.MICROPHONE", "reason": "$string:module_desc", // 在string.json中定义权限说明 "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, { "name": "ohos.permission.CAMERA", "reason": "$string:module_desc", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } 动态申请代码:// 动态申请相机和麦克风权限 async function requestMediaPermissions() { const permissions = ['ohos.permission.CAMERA', 'ohos.permission.MICROPHONE']; const result = await permission.requestPermissions(permissions); return result.every(item => item.granted); } (三) SDK 实例创建与事件监听工具通过上下文正确传递与回调绑定,确保 TRTC 实例创建成功及关键事件可靠监听。示例代码:import { getTRTCShareInstance, TRTCCloud, TRTCCloudCallback } from "liteavsdk"; import { promptAction } from '@kit.ArkUI'; @Entry @Component struct TRTCLivePage { private trtc: TRTCCloud | null = null; aboutToAppear() { // 创建SDK实例(传入正确上下文) this.trtc = getTRTCShareInstance(this.getUIContext()); if (!this.trtc) { console.error("创建TRTC实例失败"); return; } // 定义事件回调(箭头函数绑定this) const onCallback: TRTCCloudCallback = { onEnterRoom: (result: number) => { if (result > 0) { this.showToast(`进房成功,耗时${result}ms`); } else { this.showToast(`进房失败,错误码${result}`); } }, onError: (errCode: number, errMsg: string) => { console.error(`TRTC错误:${errCode},${errMsg}`); } }; // 注册回调 this.trtc.addCallback(onCallback); } private showToast(message: string) { promptAction.showToast({ message, duration: 2000 }); } aboutToDisappear() { // 移除回调 if (this.trtc) { this.trtc.removeCallback(); } } build() { } } (四) 进房参数配置与音视频流订阅组件通过参数校验与订阅时机控制,确保进房成功与音视频流正常播放。进房参数配置代码:// 进房参数配置与调用 async enterTRTCRoom() { if (!this.trtc) return; // 进房参数(从腾讯云控制台获取) const params = new TRTCParams(); params.sdkAppId = 1400xxxxxx; // 替换为实际SDKAppID params.userId = "live_user_001"; // 仅支持字母、数字、下划线 params.roomId = 10086; // 数字类型房间号 params.userSig = "eJyrVkrOT0lM..."; // 用userId生成的userSig params.role = TRTCRoleType.TRTCRoleAnchor; // 主播角色 // 进入房间(直播场景) this.trtc.enterRoom(params, TRTCAppScene.TRTCAppSceneLIVE); } 音视频流订阅代码:// 1. UI中定义XComponent(渲染远程画面) build() { Column() { // 主路画面(摄像头) XComponent({ id: "remote_big_view", // 全局唯一ID type: XComponentType.SURFACE, libraryname: 'liteavsdk' // 固定为TRTC SDK的so名称 }) .width('100%') .height(400) .backgroundColor(Color.Black) // 辅路画面(屏幕分享) XComponent({ id: "remote_small_view", type: XComponentType.SURFACE, libraryname: 'liteavsdk' }) .width(200) .height(150) .backgroundColor(Color.Gray) } } // 2. 进房成功后订阅流 private onEnterRoom = (result: number) => { if (result > 0) { this.showToast("进房成功"); // 订阅远程用户主路画面 this.trtc?.startRemoteView( "other_user", // 远程用户ID TRTCVideoStreamType.TRTCVideoStreamTypeBig, // 主路流 "remote_big_view" // 对应XComponent的id ); } }; (五) 资源释放工具通过标准化清理流程,确保退出房间后资源完全释放,避免二次进房异常。示例代码:// 退出房间并释放资源 async exitTRTCRoom() { if (!this.trtc) return; // 1. 停止本地采集 this.trtc.stopLocalAudio(); this.trtc.stopLocalVideo(); // 2. 停止所有远程订阅 this.trtc.stopAllRemoteView(); // 3. 退出房间 this.trtc.exitRoom(); // 4. 销毁实例 destroyTRTCShareInstance(); this.trtc = null; this.showToast("已退出房间"); } // 页面销毁时调用 aboutToDisappear() { this.exitTRTCRoom(); } 关键交互流程:用户操作流程:发起直播→权限申请(若未授权)→初始化 TRTC 实例→配置进房参数→进房(监听进房结果)→订阅远程音视频流→直播交互→退出房间(自动释放资源),单次流程完成直播全生命周期管理。四、方案成果总结(一) 功能层面:通过标准化 SDK 集成、权限管理、实例监听、参数校验与资源释放链路,解决了 SDK 导入失败、权限阻塞、进房异常、音视频无画面、资源泄漏等核心问题,直播功能成功率提升至 98% 以上。(二) 开发效率:通过工具函数与组件封装(权限申请、实例管理、流订阅、资源释放),将重复开发工作量减少 70%,开发者可直接复用模块快速集成功能,降低技术门槛与出错概率,开发周期缩短 50%。(三) 用户体验:明确的权限引导、实时的进房状态反馈、流畅的音视频播放、稳定的二次进房体验,让用户操作步骤从 “无序调试” 简化为 “线性流程”,直播启动耗时减少 40%,异常反馈清晰度提升 80%,用户对直播功能的满意度提升 60%,实现功能稳定性与体验流畅性的双重优化。
  • [开发技术领域专区] 开发者技术支持-TextPicker基础控件适配时间段区间选择
    1.1 问题说明在实际应用开发中,用户对于精确时间段的选择(如预约会议、预订服务、设置日程等场景)是非常常见的需求,例如需要明确选定 “2025-6-30 12:00-15:00” 这样的时间区间。然而,鸿蒙原生的 TextPicker 组件无此功能。然而在日常使用中应对此类需求时,由于其仅支持单点时间选择的特性,会暴露出一系列明显的痛点,具体可从以下几个方面展开:(一) 功能层面的天然缺失:TextPicker 组件的核心设计逻辑是针对单一时间点的选择,其本身不具备直接支持 “开始时间 - 结束时间” 区间选择的功能模块。这意味着它无法原生实现两个时间点之间的关联校验与联动选择,用户若想完成时间段的设定,必须依赖额外的逻辑设计。例如,无法在组件层面直接限制 “结束时间不得早于开始时间”,也不能在选择开始时间后自动为结束时间提供合理的初始范围建议,导致时间段选择的核心功能需要完全依赖开发者自行搭建。(二) 开发过程的低效与繁琐:为了实现时间段选择,开发者不得不手动组合多个 TextPicker 组件(通常至少需要两个,分别对应开始时间和结束时间)。这不仅需要额外处理组件的布局与样式协调,以保证视觉上的统一性,还需编写大量逻辑代码来实现两个时间点的关联:Ø 比如在用户选择开始时间后,需要动态限制结束时间的可选范围,避免出现逻辑矛盾;Ø 还需自行处理两个时间点的数据拼接与格式转换,最终形成 “开始时间 - 结束时间” 的完整字符串;Ø 同时,对于时间选择过程中的异常情况(如用户未选择结束时间就提交),也需要额外设计校验与提示机制。这些工作无疑增加了开发成本和出错概率,降低了开发效率。(三) 用户操作体验的不佳:从用户角度来看,使用多个 TextPicker 组件完成时间段选择需要进行多次独立操作:Ø 首先需点击开始时间选择器,完成开始时间的选择并确认;Ø 然后再点击结束时间选择器,重复类似的选择流程;Ø 若选择过程中需要修改,又得分别重新操作两个组件。这种割裂的操作方式不仅增加了用户的操作步骤和时间成本,还容易因两次操作的连贯性不足导致误选(如结束时间早于开始时间),进而影响用户对应用的使用体验,甚至可能导致用户因操作繁琐而放弃使用相关功能。组件样式示例: 1.2 原因分析(一) 时间单点选择定位下的区间功能缺失TextPicker 组件的核心定位是单一维度的选择工具,其设计初衷是满足用户对单个时间点(如某一具体日期、时刻)的快速选择需求。这种定位决定了组件在功能规划上更侧重单点选择的便捷性,而非复杂区间选择的完整性。导致其天然缺乏应对时间段选择场景的 “基因”。(二) 开发逻辑的独立性TextPicker 组件的底层实现逻辑具有较强的独立性与封闭性。每个组件实例仅负责处理自身对应的时间维度数据(如年、月、日或时、分),若要实现开始时间与结束时间的联动(如结束时间随开始时间动态调整),必须通过外部代码强制建立关联,这无疑增加了开发的复杂度,也容易因逻辑冲突导致功能异常。(三) 多组件分散:用户体验多重阻碍使用多个 TextPicker 组件进行时间段选择时,分散的交互模式会打破用户对时间选择的连贯认知,完整选段动作被拆分为多个独立步骤,导致用户注意力需多次聚焦,易因界面跳转产生认知中断,出现忘记数值或混淆组件功能的情况;步骤繁琐不仅增加操作难度,还需用户自行核对时间逻辑关系,易引发误操作,且因缺乏统一交互流程引导,用户需自行摸索操作顺序,加重认知负担,最终影响操作效率和使用体验。 1.3 解决思路(一) 组件整合:打造一体化时间段选择工具针对鸿蒙原生 TextPicker 组件在时间段区间选择场景中的痛点(功能缺失、开发低效、体验不佳),核心思路是基于组件化思想对原生组件进行封装扩展,通过复用原生能力、状态管理与动态适配,实现高效、直观的时间段区间选择功能。具体包括:Ø 复用 TextPicker 组件基础能力,组合单日期选择功能为 “日期 + 时间段” 的二级联动结构,解决区间选择功能缺失问题;Ø 采用鸿蒙状态管理机制(@State、@Link)维护父子组件状态同步,减少手动处理状态的开发成本,提升开发效率;Ø 结合动态时间规则处理,根据当前时间智能调整可选时段(如移除过期时段、跨天切换),优化用户操作体验,避免无效选择。​(二) 交互增强:提升操作连贯性与便捷性​支持在同一组件内通过拖拽、滑动等手势快速调整时间区间,比如拖动开始时间端点或结束时间端点来改变整个时间段,让操作更直观连贯。同时,保存用户的历史选择习惯,当用户再次进行时间段选择时,自动显示常用的时间范围作为初始选项,缩短选择路径。还可通过动画过渡效果增强两个时间点选择的关联性,例如选择开始时间后,结束时间区域以轻微高亮或缩放动画提示用户继续操作,强化操作的连贯性认知。​1.4 解决方案(一) 日期处理工具:通过工具函数实现日期格式化与动态时间规则适配,确保可选时段的有效性:日期格式化(getShiJian 函数):根据传入的天数偏移量(addDay),动态生成未来日期的 “YYYY-MM-DD” 格式,支持多日期选择。示例代码: getShiJian(addDay: number) { // 假设已经获取到时间戳并转换为Date对象 const date = new Date(Date.now() + (86400000 * addDay)); const year = date.getFullYear(); // 月份从0开始,需要加1并补0 const month = ("0" + (date.getMonth() + 1)).slice(-2); const day = ("0" + date.getDate()).slice(-2); // 日期补0 const formattedTime = `${year}-${month}-${day}` return formattedTime } timeRules(shiJian: Children[]) { const date: Date = new Date() const hours = date.getHours() if (hours >= 9 && hours < 12) { return } else if (hours >= 9 && hours < 11) { this.cascade[0].children?.splice(0, 1) } else if (hours >= 11 && hours < 16) { this.cascade[0].children?.splice(0, 2) } else if (hours >= 16 && hours < 22) { this.cascade[0].children?.splice(0, 3) } else if (hours < 8) { return } else if (hours >= 22) { this.cascade.splice(0, 1) this.cascade.push({ text: `${getShiJian(7)}`, children: shiJian }) } }动态时间规则(timeRules 函数):基于当前小时数(date.getHours ())智能调整可选时段。示例代码: if (hours >= 9 && hours < 12) { return } else if (hours >= 9 && hours < 11) { this.cascade[0].children?.splice(0, 1) } else if (hours >= 11 && hours < 16) { this.cascade[0].children?.splice(0, 2) } else if (hours >= 16 && hours < 22) { this.cascade[0].children?.splice(0, 3) } else if (hours < 8) { return } else if (hours >= 22) { this.cascade.splice(0, 1) this.cascade.push({ text: `${getShiJian(7)}`, children: shiJian }) }(二) 时间区间选择组件(TimeComponent):封装二级联动选择器,实现 “日期 + 时间段” 一体化选择:双级联动结构:通过TextPicker的range绑定cascade数组(包含日期及对应时段),实现联动选择。示例代码:@Component export struct TimeComponent { @Link selectTime: string; @Link cascade: TextCascadePickerRangeContent[] @Link isXuanZeShiJian: string @Link isXianShiShiJian: boolean shiJian: Children[] = [{ text: '08:00-09:00' }, { text: '09:00-11:00' }, { text: '11:00-16:00' }, { text: '16:00-22:00' }, ] aboutToAppear(): void { this.timeRules(this.shiJian) } build() { Scroll() { Column() { TextPicker({ range: this.cascade }) .onScrollStop((value: string | string[], index: number | number[]) => { console.info('TextPicker 多列联动:onScrollStop ' + JSON.stringify(value) + ', ' + 'index: ' + JSON.stringify(index)) this.selectTime = `${value[0]} ${value[1]}` }) .canLoop(false) .width('100%') .height(150) .margin({ bottom: 50 }) Row({ space: 20 }) { Button('取消') .width(120) .height(40) .buttonStyle(ButtonStyleMode.NORMAL) .onClick(() => { this.isXianShiShiJian = false }) Button('确定') .width(120) .height(40) .buttonStyle(ButtonStyleMode.EMPHASIZED) .onClick(() => { this.isXianShiShiJian = false if (this.selectTime) { this.isXuanZeShiJian = this.selectTime } else { this.isXuanZeShiJian = `${this.cascade[0].text} ${this.cascade[0]?.children![0].text}` } }) } .width('100%') .justifyContent(FlexAlign.Center) } .width('100%') .height('100%') .backgroundColor('#FFFFFF') .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) .translate({ y: -20 }) } .width('100%') .height('100%') } timeRules(shiJian: Children[]) { const date: Date = new Date() const hours = date.getHours() if (hours >= 9 && hours < 12) { return } else if (hours >= 9 && hours < 11) { this.cascade[0].children?.splice(0, 1) } else if (hours >= 11 && hours < 16) { this.cascade[0].children?.splice(0, 2) } else if (hours >= 16 && hours < 22) { this.cascade[0].children?.splice(0, 3) } else if (hours < 8) { return } else if (hours >= 22) { this.cascade.splice(0, 1) this.cascade.push({ text: `${getShiJian(7)}`, children: shiJian }) } } } interface Children { text: string; }状态管理:通过 @Link 装饰器同步父组件状态(如 cascade、selectTime),确保选择结果实时反馈。交互优化:默认取首个日期的首个时段(this.selectTime = cascade[0].text + cascade[0].children[0].text),避免空状态;通过 “取消”“确定” 按钮控制组件显隐(绑定 isShowTime 状态)。(三) 关键交互流程:用户操作流程:点击时间输入框→组件弹出→选择日期(联动加载对应时段)→选择时段→点击 “确定”→结果同步至父组件,单次操作完成区间选择。1.5   方案成果总结(一) 组件层面,通过组件一体化设计消除多组件切换的割裂感,减少用户手动校验成本,经测试,时间段选择效率提升约 40%;(二) 流程层面,清晰引导与快捷选项简化操作步骤,使操作步骤从原来的 3 - 4 步减少至 2 - 3 步,实时校验让选择错误率降低 65% 以上,显著降低用户认知负担;(三) 交互层面,手势操作与历史记忆功能缩短选择路径,平均选择耗时减少 30%,动画效果强化操作连贯性,用户操作满意度提升 50%,全面提升用户操作的直观性与便捷性,最终实现时间段选择体验的优化升级。
  • [问题求助] 大佬帮忙看看有没有错误
    import notificationManager from '@ohos.notificationManager';@Entry@Componentstruct Index { @State message: string = 'Hello World' @State libraryName: string = 'APP名称' aboutToAppear(){ //发布APP通知 notificationManager.publish({ id: 1, content: { contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, // 普通文本类型通知 normal: { title: this.libraryName, text: '欢迎使用' + this.libraryName + ',好好学习,天天向上!' } } }); } build() { Column() { Text(this.message) .fontSize(50) .fontWeight(FontWeight.Bold) } .width('100%') }}
  • [技术干货] harmony-cordova如何快速Android APP和Ios APP生成原生态鸿蒙APP
    harmony-cordova摘要cordova是美国Apache基金会下的移动端跨平台开源项目,目前并不支持HarmonyOS next版本,但是在鸿蒙三方库中心的harmony-cordova主要用于鸿蒙版跨平台研发,特别是原Android和Ios的cordova项目,无需投入任何研发即可轻松生成鸿蒙版APP。背景知识Android是谷歌旗下的操作系统,由于制裁原因,华为已不能使用Android操作系统,从HarmonyOS Next版本之后,华为手机也慢慢将不再兼容安卓APP,原安卓APP,如果继续要在华为手机上用,必须适配鸿蒙操作系统,说适配是好听的,真实情况是在HarmonyOS next操作系统上全部重新研发,不是简单的适配工作,原Android的代码已一无是处了,这样势必增加研发成本。对于大厂这些都是小case了,但是对于中小企业在新的操作系统投入研发,投入的时间成本和资金势必难以接受,另外由于HarmonyOS是一个新的生命,社区支持不够成熟,人才短缺很多中小企业就会望而生畏了。为此我推荐cordova混合研发,只有cordova才是真正的混合研发免费且开源,调用原生态API,一次研发满足安卓、Ios和鸿蒙操作系统。对于市场上流行的开源项目,我可以好不夸张的说只有cordova,没有之一了。国内也有也有一个开发框架,托管了APP的打包工作,虽然也是打着免费的口号,确强制集成了不该集成的SDK,以至于在上架的时候无法满足应用市场的要求和国家法律的要求,因为要说明这些SDK的作用是什么,但是很多开发者就不知道集成有这个SDK,因此隐私政策写不好而不能上架,因为盈利性公司不是基金会,不会提供免费的午餐。我们能够理解,这里我就不明说了。因此对于用心运营的APP的企业,我推荐混合式研发,也不要装X全部使用原生研发,后面维护和升级拿石头砸自己脚。鸿蒙原生开发原生鸿蒙研发支持ArkTS和C/C++语言。并不支持Java,Java的同学可能比较难过,其实也不用担心,看我接下来的分析,ArkTS是华为在TS语言上的进一步优化的后开发语言,不同于TS语言,学起来并不难,只需要几分钟看看就可以上手了,当然这是对于之前熟练的掌握了Java、Js或C/C++的基础上,说实在的如果有了前面的基础TS无需单独学习,直接就可以上手,如果使用native c/c++就不同了,C/C++语言会使很多开发者心里发怵,以致于不敢轻易创建native C++项目,所以有一个部分开发者只能使用ArkTS开发原生鸿蒙APP了,但这样就失去了优势,只能开发更上层应用性的内容,涉及框架或者底层的开发就无法胜任了.harmony-cordova就是使用C/C++研发的,cordova的插件也是C/C++研发的,只有涉及到鸿蒙UI层的必须使用ArkTS了。所以是两者结合开发的,harmony-cordova所涉及的技术方案,我这里不过多的说明,大家只要会用就可以了。接下来说一下harmonyOS吧,HarmonyOS 内核并不是Linux内核,虽然支持C/C++研发,但是并不是现存所有的Linux的开源项目都可以集成到HarmonyOS上的,在加上DevEco采用的是CMake编译,如果原Linux的开源项目是Makefile编译的,要移植到HarmonyOS上,也并非易事,需要Linux C/C++开发的熟手才可以做到,并非入门级别的就可以胜任。如果原Linux下的开源项目,源文件数量少,可以直接拷贝源码集成,如果原Linux下的开源项目文件众多,编译复杂,就需要使用交叉编译移植到HarmonyOS上,为什么要移植Linux开源项目呢?因为大多项目都会依赖Linux的动态库so,所以要进行移植,如果不移植很多C/C++程序就很难开发。但是并不是所有的Linux的开源项目都可以移植的,因为HarmonyOS并不是Linux内核,使用的编译器也不是gcc,所以Linxu下面的so之间互相依赖,因此在Harmony OS上有些so无法移植。另外Linux下面的C/C++的程序和HarmonyOS也有差异,HarmonyOS也预制集成了类似Linux下的so,虽然有预制so,但是并不是所有的函数都可以使用,因为接口并不全,特别是一些涉及到内核的调用和Linux并不一样。综上所述,在HarmonOS平台上,使用native c/c++研发框架类的har,则需要更多的C/C++开发经验,这方面的人才会更少。因此您在网上看到最多的都是ArkTS开发的。harmony-cordova为什么要研发鸿蒙版cordova,公司研发harmony cordova是因为公司内的APP原使用cordova框架研发,很遗憾的是Apache基金会不支持,所以我们公司就自己研发了,研发好以后首先使用在我们公司自己的产品上,1.0版本首先满足了我们公司自己需要插件,后面会慢慢升级以兼容大部分cordova插件。自定义插件,很多Android项目或者Ios项目,集成了自定义插件,例如每个手机厂商的推送功能,都是自定义插件研发的,这里插句话,不要集成第三方的统一推送的SDK,会让你的APP由于隐私政策审核起来出现很多的问题,所以建议集成所有手机厂商自己的推送SDK,因为手机厂商并不多,集成也没有太多的工作。还有OSS对象存储等都需要自定义插件,harmony-cordova也集成了一些常用的自定义插件。如果您的项目中有自定义插件,需要使用harmony-cordova的,就需要开发者支持了。Android移植鸿蒙步骤1,打开DevEco创建项目,选择Empty Ability进入下一步,填写必要信息,这里要注意,bundle name 先填写com.example.myapplication,也就是保持默认不变,因为在没有cordova.crt证书的情况下,cordova鸿蒙版要求bundle name必须为com.example.myapplication,主要用于研发测试,如果开发测试完成要修改bundle name上架鸿蒙应用市场,请联系开发者申请cordova.ert证书,或者事先联系开发者提供技术服务。2,项目创建成功后,复制原有Android studio的工程assests/www目录下面的所有文件到鸿蒙工程entry/src/main/resources/目录下,注意直接复制原andriod工程www目录下的文件,不包含www。3,复制原android工程res/xml目录下的config.xml文件到鸿蒙工程entry/src/main/resources/目录下。4,打开DevEco studio的Terminal终端,进入工程目录,执行 ohpm install harmony-cordova 安装本插件。5,打开鸿蒙工程文件entry/src/main/etx/pages/Index.ets文件,修改代码如下:import { MainPage, pageBackPress, pageHideEvent, pageShowEvent } from 'harmony-cordova/Index';  @Entry  @Component  struct Index {    onPageShow(){      pageShowEvent(); //页面显示通知cordova    }      onBackPress() {      pageBackPress(); //拦截返回键由cordova处理      return true;    }    onPageHide() {      pageHideEvent(); //页面隐藏通知cordova    }      build() {      RelativeContainer() {        MainPage(); //webview首页index.html      }      .height('100%')      .width('100%')    }  }6,打开鸿蒙工程文件/entry/src/main/ets/entryAbility/EntryAbility.ets文件,修改onCreate函数如下import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';      import { hilog } from '@kit.PerformanceAnalysisKit';      import { window } from '@kit.ArkUI';      import { webview } from '@kit.ArkWeb';      import { setSchemeHandler } from 'harmony-cordova/Index';...onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {webview.WebviewController.initializeWebEngine();//webview引擎初始化setSchemeHandler();//设置webview schemehilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');}7,鸿蒙混合研发,也许您会增加其他page页面,不一定应用的首页为cordova webview(index.html)的首页,例如应用增加了鸿蒙的原生的启动页面,包含首页弹窗,同意隐私政策后,然后再从启动页面进入cordova的页面,这样避免在用户没有同意隐私政策的情况下,初始化cordova sdk,因为初始化cordova sdk,系统读取了设备的网络状态,因为国内相关法律规定,在用户没有同意隐私政策的情况下,不允许读取设备的网络标识。8,做以上代码修改后,鸿蒙的移植已经完毕,可以使用模拟器或者真机进行编译和测试了。Ios移植鸿蒙步骤如果您的项目有android和Ios的工程,请参考android项目移植项目的鸿蒙下,如果您的项目没有andriod工程,只有Ios工程,请使用如下方法移植,移植时大部分内容和安卓一样,只是复制的文件的路径不一致,以下只介绍不同部分,相同部分请参考android移植步骤。1,复制Xcode的Ios工程目录下的Staging/www目录下的所有文件到鸿蒙工程entry/src/main/resources/目录下。 2,Xcode工程的config.xml文件在Staging目录下,Xcode工程的该文件不能直接被鸿蒙版cordova使用,需要进行转换,该文件主要记录的是插件的名称和初始化的类,因为鸿蒙版是根据android的config.xml进行插件初始化的,因此需要将Xcode工程config.xml转为安卓的config.xml,请将Xcode工程使用node加入安卓平台,系统会自动生成android版的config.xml。然后将文件复制到鸿蒙版工程的entry/src/main/resources/下。附加说明:本人认为使用cordoca跨平台研发,一般至少都会包含android和ios两大平台,很少只有ios平台,没有android平台的,所以大部分移植鸿蒙参考android移植步骤,后续升级SDK会兼容Ios工程的config.xml,无需转换就可以使用。新项目,一次开发适用于andriod、Ios和Harmony三大平台由于cordova官方当前并不支持HarmonyOS平台,使用node无法直接将HarmonyOS加入到cordova,也无法直接安装插件到HarmonyOS,因此对于新项目要一次开发满足三大平台的话,建议先通过node加入Android和Ios平台和安装插件,后续研发可以使用Android studio研发和调试,待研发成功后,然后再在Xcode和DevEco做跨平台适配。Xcode适配请参考cordova的官方文档,HarmonyOS适配请参考以上Android的移植步骤。特别说明当前版本不支持使用者自定义插件研发,如果该版本没有包含您要使用的插件,或者您的项目中有Android或Ios的自定义插件,需要移植到HarmonyOS平台,请您和本开发者联系,获取技术支持。使用鸿蒙版cordova sdk在开发测试阶段务必将bundle name修改为com.example.myapplication,如果将bunlde name改为正式的Id,鸿蒙版cordova sdk会读取entry/src/main/resources/目录的cordova.crt证书文件,用于验签,如果该文件不存在,启动应用后,应用会闪退。如果应用的bundle name为com.example.myapplication,鸿蒙版 cordova sdk会跳过验签,不检测cordova.crt文件。但是上架鸿蒙应用市场,必须将bundle name改为正式的id,所以请联系开发者申请cordova.ert证书,另外由于操作系统之间的差异,虽然保持了cordova的插件接口不变,但是返回值会有所调整,后续文档会逐步完善,在使用本插件跨平台研发时请联系开发者提供技术服务。                                                               
  • [问题求助] 接收POST上传的图片保存到服务器时出现格式不对的错误
    从模拟器相册中选择一个JPG图片,通过POST方式上传到服务器,服务器端用servlet负责接收,用servletInputStream输入流接收上传的图片内容,后来发现保存的图片格式不对,不知道哪里出了问题?
  • [热门活动] 【前端开发专题直播有奖提问】DTSE Tech Talk 技术直播 NO.56:看直播提问题赢华为云定制长袖卫衣、华为云定制U型按摩枕等好礼!
    中奖结果公示感谢各位小伙伴参与本次活动,欢迎关注华为云DTSE Tech Talk 技术直播更多活动~本次活动获奖名单如下:请于4月23日下班前在此问卷中反馈您的中奖邮寄信息~直播简介【直播主题】手把手教你实现 mini版TinyVue组件库【直播时间】2024年4月17日 16:30-18:00【直播专家】阿健 华为云开源 DTSE 技术布道师【直播简介】在前端 Web 开发过程中,跨版本兼容性问题是一个普遍存在的挑战。为了解决这些痛点,OpenTiny 推出跨端、跨框架、跨版本组件库 TinyVue。本期直播聚焦于华为云的前端开源组件库 TinyVue,通过 Mini版 TinyVue 的代码实践与大家共同深入解读 Vue2/Vue3 不同版本间的差异。这对于提升用户体验,减低维护成本,提升开发者技术洞察有重要意义。活动介绍【互动方式】直播前您可以在本帖留下您疑惑的问题,专家会在直播时为您解答。直播后您可以继续在本帖留言,与专家互动交流。我们会在全部活动结束后对参与互动的用户进行评选。【活动时间】即日起—2024年4月18日【奖励说明】评奖规则:活动1:直播期间在直播间提出与直播内容相关的问题,对专家评选为优质问题的开发者进行奖励。奖品:华为云定制长袖卫衣活动2:在本帖提出与直播内容相关的问题,由专家在所有互动贴中选出最优问题贴的开发者进行奖励。奖品:华为云定制U型按摩枕更多直播活动直 播互动有礼:官网直播间发口令“华为云 DTSE”抽华为云定制雨伞、填写问卷抽华为云定制保温杯等好礼。有奖实操 :4月17日-4月18日,完成课后动手实验即送华为云定制T恤。【注意事项】1、活动期间同类子活动每个ID(同一姓名/电话/收货地址)提问数≤20个;所有参与活动的问题,如发现为复用他人内容或直播间中重复内容,则取消获奖资格。2、为保证您顺利领取活动奖品,请您在活动公示奖项后2个工作日内私信提前填写奖品收货信息,如您没有填写,视为自动放弃奖励。3、活动奖项公示时间截止2024年4月19日,如未反馈邮寄信息视为弃奖。本次活动奖品将于奖项公示后30个工作日内统一发出,请您耐心等待。4、活动期间同类子活动每个ID(同一姓名/电话/收货地址)只能获奖一次,若重复则中奖资格顺延至下一位合格开发者,仅一次顺延。5、如活动奖品出现没有库存的情况,华为云工作人员将会替换等价值的奖品,获奖者不同意此规则视为放弃奖品。6、其他事宜请参考【华为云社区常规活动规则】。
总条数:17 到第
上滑加载中