-
HarmonyOS WebView 调试指南:使用Chrome DevToolsWeb组件支持使用DevTools工具调试前端页面。DevTools是Web前端开发调试工具,支持在电脑上调试移动设备前端页面。开发者通过setWebDebuggingAccess()接口开启Web组件前端页面调试能力,使用DevTools在电脑上调试移动前端网页,设备需为4.1.0及以上版本。前置准备在开始调试前,请确保完成以下配置:安装HDC工具:[Mac用户配置指南][Windows用户配置指南]验证安装:终端执行 hdc -v 输出版本 Ver: 3.1.0e 表示已安装 输出提示 command not found 检查是否安装或是否配置环境变量 四步调试流程第一步:获取应用进程IDhdc shell ps -ef | grep "您的应用包名" 示例输出解析:u0_a200 12345 678 0 10:00:00 ? 00:00:00 com.example.app12345 就是需要的进程ID🚨 重要提示:执行此命令时请确保:只连接一台HarmonyOS设备关闭所有模拟器第二步:建立调试隧道hdc fport tcp:9222 localabstract:webview_devtools_remote_进程ID成功执行后终端显示 Forwardport result:OK🚨 这个经常因锁屏等断联秩序执行上面命令第三步:Chrome调试配置打开Chrome访问:chrome://inspect点击"Configure" → 添加 localhost:9222保持终端命令运行,刷新页面在"Remote Target"区域找到您的应用 → 点击"inspect"第四步:结束调试hdc fport kill tcp # 关闭所有端口转发 # 或 hdc fport list # 查看当前转发情况 常见问题解答❓ 为什么看不到设备?检查HDC是否识别设备:hdc list targets确保没有多设备连接确认端口转发命令正在运行❓ 调试界面空白?尝试关闭Chrome所有实例重新打开检查HarmonyOS应用是否在前台运行❓ 多设备错误提示?[Fail]ExecuteCommand need connect-key? please confirm a device by help info解决方案:断开其他设备,确保只连接一台调试设备调试技巧元素检查:实时修改DOM和CSS控制台调试:直接执行JavaScript代码网络分析:监控所有网络请求性能分析:检测页面渲染性能通过以上步骤,您可以轻松实现HarmonyOS WebView页面的实时调试,显著提升开发效率!
-
通过分析提供的Swiper组件代码,结合HarmonyOS开发经验,针对该实现方案进行如下技术解析与优化建议:import { INDEX_DATA } from '../../mock/ProductsData'; import { SwiperModel } from '../../model/GoodsModel'; const isNewSlide: boolean = true; @Component export struct SwiperComponent { @State breakPoints: string | undefined = AppStorage.get('breakPoint'); @State currentIndex: number = 2; private uiContext: UIContext | undefined = undefined; /** * Get the image offset coefficients. * * @param index * @returns offset coefficients */ getImgCoefficients(index: number): number { let coefficient: number = this.currentIndex - index; let tempCoefficient: number = Math.abs(coefficient); if (tempCoefficient <= 2) { return coefficient; } let dataLength: number = INDEX_DATA.length; let tempOffset: number = dataLength - tempCoefficient; if (tempOffset <= 2) { if (coefficient > 0) { return -tempOffset; } return tempOffset; } return 0; } /** * Get the image offset. * * @param index * @returns offset */ getOffSetX(index: number): number { let offsetIndex: number = this.getImgCoefficients(index); let tempOffset: number = Math.abs(offsetIndex); let offsetX: number = 0; if (tempOffset === 1) { offsetX = -40 * offsetIndex; } return offsetX; } startAnimation(isLeft: boolean): void { this.uiContext?.animateTo({ duration: 300, }, () => { let dataLength: number = INDEX_DATA.length; let tempIndex: number = isLeft ? this.currentIndex + 1 : this.currentIndex - 1 + dataLength; this.currentIndex = tempIndex % dataLength; }) } build() { if (!isNewSlide) { } else { Column() { Stack() { ForEach(INDEX_DATA, (item: SwiperModel, index: number) => { Row() { Image(item.img) .objectFit(ImageFit.Fill) .borderRadius(8) .width(this.breakPoints === 'sm' ? 340 : 260) .height(index !== this.currentIndex ? "65%" : `${95 - 13 * Math.abs(this.getImgCoefficients(index))}%`) .opacity(1 - 0.2 * Math.min(2, Math.abs(this.getImgCoefficients(index)))) } .borderRadius(8) .offset({ x: this.getOffSetX(index), y: 0 }) .blur(10 * Math.abs(this.getImgCoefficients(index))) .zIndex(index !== this.currentIndex && this.getImgCoefficients(index) === 0 ? 0 : 2 - Math.abs(this.getImgCoefficients(index))) }, (item: SwiperModel) => JSON.stringify(item)) } .alignContent(Alignment.Center) .gesture( PanGesture({ direction: PanDirection.Horizontal }) .onActionStart((event: GestureEvent) => { this.startAnimation(event.offsetX < 0); }) ) } .width('100%') .height('25%') } } } 一、问题说明循环逻辑缺陷代码通过取模运算tempIndex % dataLength实现循环切换,但未开启Swiper组件的loop属性,导致:边缘位置切换时缺少平滑过渡动画手势操作与组件内置循环机制冲突无法通过SwiperController实现程序化控制动画性能问题自定义的PanGesture手势与animateTo实现存在:手势响应与动画帧率不同步缺少触摸速率检测导致滑动惯性缺失多层级嵌套动画可能引发渲染管线阻塞布局计算冗余getImgCoefficients/getOffSetX方法中:重复计算数据长度dataLength未考虑设备像素密度差异硬编码的40px偏移量导致多分辨率适配问题二、原因分析混合实现方案冲突同时使用原生Swiper组件特性与自定义手势控制,导致:内置布局机制与手动offset计算产生叠加效应zIndex层级管理未考虑组件渲染顺序breakPoints响应式逻辑未与Swiper属性联动状态管理不足@State修饰的currentIndex缺乏:与Swiper组件index属性的双向绑定防抖机制导致快速滑动时状态不同步未持久化存储当前浏览位置三、解决思路3.1 循环逻辑重构原生能力整合启用Swiper组件的loop属性替代手动计算循环逻辑,利用其内置的虚拟化渲染机制,避免边缘切换时的跳跃问题。结合displayCount属性控制预加载数量,平衡内存占用与流畅性(如设置displayCount: 3)。双向状态同步使用SwiperController与@Link装饰器实现currentIndex与Swiper组件索引的双向绑定,确保手势操作与程序化控制的一致性。3.2 动画与手势优化性能敏感型动画将animateTo替换为swipeTo方法,直接触发Swiper内置动画引擎,避免嵌套动画导致的帧率波动。引入Curve.EaseOut缓动曲线模拟自然滑动惯性,提升交互真实感。多点触控兼容添加PanGesture的priority参数配置,避免与页面滚动或其他手势冲突。通过GestureGroup实现多手势协同,支持滑动过程中轻触暂停等复合操作。3.3 工程化增强响应式设计规范抽取breakPoints相关参数至Resource资源文件,通过@ohos.mediaquery监听屏幕变化实现动态布局。使用GridContainer替代硬编码宽高值,适配折叠屏、平板等多形态设备。内存泄漏防护在aboutToDisappear生命周期中注销MediaDataHandler监听,并通过@Track装饰器标记关键对象引用链。采用LazyForEach替代ForEach实现图片懒加载,结合RecycleView复用机制降低内存峰值。3.4 视觉一致性提升动态模糊策略根据zIndex层级动态调整blur值,避免固定模糊系数导致的视觉割裂。引入LinearGradient实现边缘渐变遮罩,增强轮播内容的沉浸感。无障碍适配为Swiper子项添加accessibilityLabel描述,支持屏幕朗读工具识别轮播内容。四、解决方案启用原生循环特性调整组件声明:Swiper() { ForEach(...) } .loop(true) .autoPlay(false) .displayCount(3) // 根据搜索结果5优化显示数量 优化动画实现private swiperController: SwiperController = new SwiperController(); startAnimation(isLeft: boolean) { const targetIndex = this.calculateTargetIndex(isLeft); this.swiperController.showNext({ speed: 300, // 与animateTo duration保持一致 curve: Curve.Ease }); this.currentIndex = targetIndex; } 响应式布局改进@Builder swiperItemBuilder(item: SwiperModel) { Image(item.img) .aspectRatio(1.78) // 保持16:9比例 .width(this.breakPoints === 'sm' ? '80%' : '60%') .height(this.breakPoints === 'sm' ? '70%' : '90%') .margin({ left: $r('app.float.swiper_margin'), right: $r('app.float.swiper_margin') }) }
-
一、 问题说明:应用通知的困境作为一名HarmonyOS开发者,你是否曾遇到过以下痛点?重要通知被淹没:应用内生成了一张即将过期的优惠券或一个待支付的账单,你希望用户能及时看到,但传统的推送通知极易被其他信息淹没,导致用户错过。被动等待用户:传统的交互模式是“人找服务”,应用只能被动等待用户打开,无法主动在恰当时机为用户提供恰到好处的服务。体验割裂:即使用户点击通知,跳转回应用的体验也可能不够流畅,无法直接定位到相关页面。这些问题背后的核心是:应用缺乏一种与系统深度集成、由系统智慧决策并主动推荐服务的能力。二、 原因分析与解决思路原因分析:上述问题的根源在于传统通知机制是“单向广播”和“平等竞争”的。所有应用的通知都涌入同一个通知中心,缺乏上下文感知和智能调度,无法在最佳时机触达用户。解决思路:HarmonyOS的意图框架(Intents Kit) 提供了完美的解决方案。它的核心思想是:应用共享意图:应用将重要的业务事件(我们称之为“意图”,如“查看还款”、“使用优惠券”)结构化地共享给系统智慧分发平台。系统智能决策:系统平台会综合上下文(如时间、地点、用户习惯)进行智能分析,在最合适的时机(如还款日当天早上、到达商场附近时)主动向用户推荐一张可交互的提醒卡片。用户一键直达:用户点击卡片后,即可无缝跳转回应用内的对应详情页,实现“服务找人”的沉浸式体验。这不仅能极大提升关键信息的触达率,更能打造一种“润物细无声”的智能化用户体验。三、 解决方案:四步开发集成意图框架下面我们一步步来看如何为你的HarmonyOS应用集成这一能力。第一步:端侧注册意图首先,你需要在应用中声明你希望处理哪些“意图”。创建配置文件:在项目的 entry/src/main/resources/base/profile/ 目录下创建 insight_intent.json 文件。配置多个意图:在此文件中,以数组形式声明所有需要支持的意图。请注意,意图名称必须是系统预置的,不能自定义。// insight_intent.json{“insightIntents”: [{“intentName”: “ViewRepayment”, // 预置意图:查看还款“domain”: “BankingDomain”, // 所属业务垂域“intentVersion”: “1.0.1”, // 意图版本号“srcEntry”: “./ets/entryability/InsightIntentExecutorImpl.ets”, // 执行器入口“uiAbility”: {“ability”: “EntryAbility”,“executeMode”: [“background”, “foreground”] // 执行模式}},{“intentName”: “CheckCoupon”, // 预置意图:查看优惠券“domain”: “ShoppingDomain”,“intentVersion”: “1.0.0”,“srcEntry”: “./ets/entryability/InsightIntentExecutorImpl.ets”,“uiAbility”: {“ability”: “EntryAbility”,“executeMode”: [“foreground”]}}]}第二步:实现意图执行器系统在用户点击卡片后,会调用你注册的执行器。你需要在此处理跳转逻辑。// InsightIntentExecutorImpl.etsimport { insightIntent, InsightIntentExecutor } from ‘@kit.AbilityKit’;import { window } from ‘@kit.ArkUI’;import { BusinessError } from ‘@kit.BasicServicesKit’;export default class InsightIntentExecutorImpl extends InsightIntentExecutor {private static readonly VIEW_REPAYMENT = ‘ViewRepayment’;private static readonly CHECK_COUPON = ‘CheckCoupon’;// 处理卡片点击事件(前台模式)onExecuteInUIAbilityForegroundMode(intentName: string, param: Record<string, Object>, pageLoader: window.WindowStage): Promise<insightIntent.ExecuteResult> {switch (intentName) {case InsightIntentExecutorImpl.VIEW_REPAYMENT:return this.handleViewRepayment(param, pageLoader); // 跳转至还款页case InsightIntentExecutorImpl.CHECK_COUPON:return this.handleCheckCoupon(param, pageLoader); // 跳转至优惠券页default:return Promise.resolve({ code: -1, result: { message: ‘未知意图’ } });}}private handleViewRepayment(param: Record<string, Object>, pageLoader: window.WindowStage): Promise<insightIntent.ExecuteResult> {// 1. 解析param中的业务参数(如订单号)// 2. 携带参数,跳转到应用内的还款详情页let pageParams: Record<string, string> = { ‘repaymentInfo’: JSON.stringify(param) };let localStorage: LocalStorage = new LocalStorage(pageParams);return pageLoader.loadContent(‘pages/RepaymentDetailPage’, localStorage).then(() => ({ code: 0 })).catch((error: BusinessError) => ({ code: -1, result: { message: error.message } }));}private handleCheckCoupon(param: Record<string, Object>, pageLoader: window.WindowStage): Promise<insightIntent.ExecuteResult> {// … 类似逻辑,跳转到优惠券详情页return Promise.resolve({ code: 0 });}}第三步:云侧配置与交互意图框架需要云侧协同工作。平台配置:在AppGallery Connect上架你的应用,并在小艺开放平台为你的每个意图提交配置申请,等待审核通过。获取凭证(SID):在端侧调用API获取Service OpenID(SID),这是云侧操作的凭证。// 在应用合适的位置调用insightIntent.getSid(context, false).then((sid: string) => {console.info(‘获取到的SID:’, sid);// 将SID发送给你的服务器});捐赠事件:当业务事件发生时(如用户生成订单),你的应用服务器需要调用华为的接口,将事件数据(遵循预置意图的Schema格式)和SID一起发送给智慧分发平台。事件撤销:如果事件过期,你的服务器应及时通知平台撤销推荐。第四步:处理意图调用这一步已在第二步的意图执行器中自动完成。你无需额外编码,系统会在用户点击卡片后自动调用你已实现的 onExecuteInUIAbilityForegroundMode 方法,并执行你写好的跳转逻辑。
-
一、问题说明**文字位置偏移问题:**绘制文字时出现位置偏移,特别是在设置旋转角度后,文字坐标未正确对齐**复合样式支持缺失:**当前只能处理单一字体样式(如单独斜体或加粗),无法支持组合样式**文字测量不准确:**markHeight始终为0,无法正确获取文字高度**透明度叠加异常:**全局透明度设置会影响后续绘制操作**性能问题:**离屏画布尺寸固定导致大图水印质量下降二、原因分析rotate()方法未配合translate()调整坐标系原点fillText方法的y坐标基准线未统一设置多次绘制未重置上下文状态switch-case结构仅处理单一枚举值字体字符串拼接未采用标准格式measureText()返回的TextMetrics对象未正确解析未考虑字体metrics实际渲染特性globalAlpha属性未在每次绘制前重置透明度计算未考虑颜色本身的alpha通道三、解决思路使用save()/restore()隔离每次绘制状态设置统一的textBaseline基准线采用坐标系平移配合旋转改用位运算判断样式组合遵循font属性格式规范:[style] [weight] size family四、解决方案文字位置偏移问题优化:offScreenContext.save(); offScreenContext.translate(startX, startY); // 设置绘制起点 offScreenContext.rotate(-config.dRotationAngle * Math.PI / 180); offScreenContext.textBaseline = 'alphabetic'; // 统一基准线 // ...绘制操作 offScreenContext.restore(); 复合样式支持缺失优化:let fontStyle = ''; if (config.iWordFontStyle & FontStyleFlag.ITALIC) fontStyle += 'italic '; if (config.iWordFontStyle & FontStyleFlag.BOLD) fontStyle += 'bold '; offScreenContext.font = `${fontStyle}${config.iWordFontSize}px ${config.strWordFontName}`; 文字测量优化:const metrics = offScreenContext.measureText(text); const actualHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; const lineYPos = metrics.fontBoundingBoxAscent; // 计算基准线位置 透明度叠加异常优化:// 改用RGBA颜色设置替代全局透明度 const alpha = config.lvpWmWords.iTransparent / 100; offScreenContext.fillStyle = `rgba(${r},${g},${b},${alpha})`; // 镂空效果采用复合路径 offScreenContext.save(); offScreenContext.clip(textPath); // 创建文字路径 offScreenContext.clearRect(0, 0, canvasW, canvasH); // 镂空处理 offScreenContext.restore(); 性能优化:// 动态计算画布尺寸 const canvasW = markWidth * 1.2; const canvasH = actualHeight * 1.5; const offScreenCanvas = new OffscreenCanvas(canvasW, canvasH); // 添加设备像素比适配 const dpr = window.devicePixelRatio; offScreenCanvas.width *= dpr; offScreenCanvas.height *= dpr; offScreenContext.scale(dpr, dpr); 异常处理增强:try { await pixelMap2Buffer(bmp, localId); } catch (err) { console.error(`Watermark generation failed: ${err.code} ${err.message}`); throw new Error('WATERMARK_RENDER_ERROR'); }
-
# 问题说明仓颉编程语言是一款面向全场景智能的新一代编程语言,主打原生智能化、天生全场景、高性能、强安全。主要应用于鸿蒙原生应用及服务应用等场景中,为开发者提供良好的编程体验。## 初识仓颉(1)> 编译器安装在使用仓颉编程语言进行开发时,可以选择DevEco Studio或者VsCode等主流软件进行开发,由于本篇文章主要介绍使用仓颉编程语言进行鸿蒙原生应用的开发,故不再做过多介绍。感兴趣的小伙伴可以参考仓颉编程语言官网进行学习。使用DevEcoStudio和仓颉编程语言进行开发鸿蒙原生应用,需要在开发者官网上申请开发者账号,并且通过审核后,才可以获取到对应的资源包。> 安装DevEco Studio过程不再进行演示,如果有需要的同学可以通过开发者官网进行查看通过审核后,就可以在下载资源中看到对应的插件,如图:下载完成后,只需在DevEco Studio中安装即可使用:Step1:下载后无需解压Step2:选择从磁盘中加载插件Step3:创建一个新项目Step4:将项目运行后,即可看到屏幕显示“Hello Cangjie”字样本期内容就先介绍这么多,如有纰漏还请指正,谢谢
-
问题说明在开发一个电商类鸿蒙应用时,我们遇到了一个典型的性能瓶颈:商品列表页在快速滑动时会出现明显的卡顿、掉帧现象。具体现象描述:用户快速上下滑动浏览商品列表时,屏幕内容无法流畅跟随手指滚动。出现白块、跳帧,视觉上感到“一顿一顿”的。在低端设备上,此问题尤为严重,甚至伴有短暂的假死现象。通过 DevEco Studio 的 Performance Profiler 工具抓取分析,可以观察到UI线程(主线程)存在大量的耗时操作和帧超时(Frame overdue)。这个问题严重影响了用户的浏览体验,是必须解决的高优先级生产问题。原因分析通过对问题页面的代码进行排查和性能剖析,我们归纳出以下几个主要原因:UI 布局过于复杂:单个列表项(ListItem)的组件树层级过深,包含了大量的嵌套容器和组件。测量(measure)、布局(layout)和绘制(draw)每一个复杂的项都需要消耗大量计算资源。主线程耗时操作:在 @Builder 或组件 build 函数中,同步执行了繁重的数据处理或图片解码操作。ArkUI 的UI更新是单线程模型,这些操作会阻塞UI线程,导致无法在16ms内完成一帧的绘制(以达到60FPS的流畅度)。图片加载不当:直接使用巨大的图片资源而未进行适当缩放,或者在滑动过程中未对图片加载进行优化,导致在滑动时频繁进行IO操作和解码,占用大量CPU资源。无效的重复渲染:由于状态管理不当,列表在滑动时触发了不必要的全局刷新或过多项的重新构建。解决思路解决列表卡顿的核心思路是:减轻主线程负担,减少每帧的计算量。扁平化布局:简化单个列表项的UI结构,减少嵌套层级,使用更高效的布局组件。异步与懒加载:将耗时操作(如数据加工、图片解码)移出UI线程,放到异步任务或Worker中处理。对于图片,采用懒加载策略,仅在需要显示时才进行加载。复用与缓存:充分利用 List 组件的原生复用机制(回收不可见项的视图,复用其结构用于新出现的项)。同时,对已加载的图片、计算结果等进行缓存,避免重复计算。精细化渲染控制:使用 @ObjectLink 和 @Observed 来实现组件级别的状态更新,避免因某个列表项的数据变化而导致整个列表的重新渲染。解决方案使用精准渲染控制(ArkUI最佳实践)对于复杂列表项,避免使用 @State 管理整个数组。使用 @Observed 和 @ObjectLink 来实现项级别的更新。// 定义可观察的类@Observedclass GoodsInfo {id: string;name: string;price: number;imageUrl: string;// …}@Componentstruct GoodsListItem {@ObjectLink item: GoodsInfo; // 关联到可观察对象的某个属性build() {Row() {Image(this.item.imageUrl)Text(this.item.name)Text(¥${this.item.price})}// 当这个具体的 item 发生变化时,只会重新构建这个 GoodsListItem 组件,// 而不是整个List,极大提升性能。}}// 在Page中使用@Entry@Componentstruct GoodsPage {@State goodsList: GoodsInfo[] = []; // List管理数组引用build() {List() {ForEach(this.goodsList, (item: GoodsInfo) => {ListItem() {GoodsListItem({ item: item }) // 将每个项传递给子组件}}, (item: GoodsInfo) => item.id)}}}
-
在万物互联时代,用户需求正从“人找服务”逐步向“服务找人”转变。HarmonyOS 以用户为中心,依托POI、信标、鸿蒙标签、NFC iTAP等技术打造近场服务能力,将近场服务融入用户日常生活场景,悄然改变众多领域的服务体验。本期近场服务聚焦商超、文旅、餐饮三大行业的典型应用场景,带你感受HarmonyOS近场服务带来的体验提升。一、智慧商超:为商铺装上“智能导购” 在传统商超综合体中,商铺客流大多依赖品牌影响力和区位优势,普通商铺难以有效吸引顾客驻足。 而当商铺部署信标设备后,用户进入信标连接范围即可收到传输信号,通过“小艺建议”获取门店活动、特色服务等推荐,助力商家在用户消费决策前实现精准曝光,显著提升店铺引流能力,为会员转化和成交率带来新增长点。 二、智慧文旅:打造沉浸式游览体验 假期出游高峰时,排队购票导致入园拥堵、景区导览设置不清导致错过打卡点等都会影响游客的游览体验。 近场服务基于POI位置推荐可在游客靠近景区附近时通过小艺建议获取购票服务卡片推荐,一键直达购票页面,比传统线上购票软件减少约50%操作步骤。进入景区游览时,游客也可以基于景区内不同景点的POI点位推荐一键跳转至景区元服务详情页,当前景点讲解、后续景点推荐、游览路线推荐等一目了然,告别盲目寻找和人工问询。 三、智慧餐饮:一碰直达,极速点餐 餐饮门店可在餐桌或入口处设置HarmonyOS标签,用户通过手机“碰一碰”即可快速直达商家元服务页面。 消费者无需排队点单,手机“碰一碰”即可实现会员一键入会、获取优惠套餐、快速点餐等。不仅大大缩短用户操作步骤,提升了用户体验,也帮助商家大幅提升会员转化与订单效率,实现用户与商家的双赢。 HarmonyOS近场服务在以上行业应用场景中展示了强大的适配性和创新价值。除上述典型案例场景之外,还广泛应用在智慧办公、运动健康、本地生活、政务民生等领域。欢迎开发者点击下方链接了解并接入使用,与HarmonyOS一起共建共享鸿蒙新世界! 👉 点击了解更多并申请接入:申请开通权限-近场服务 - 华为HarmonyOS开发者 (huawei.com) AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。 如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
-
对H5页面占比高的APP而言,“加载慢”是用户体验的“头号杀手”——转圈的加载动画、迟迟不显示的内容,很容易让用户直接退出。为解决这一痛点,AppGallery Connect推出高性能Web容器组件FastWeb,专为H5页面提速而生,帮开发者搞定H5优化,让用户告别“加载卡顿”烦恼,体验更丝滑。一、先搞懂:什么是FastWeb组件?FastWeb是基于OpenHarmony开发的“高性能Web容器”,适用于对H5页面有性能优化需求(加载提速)的场景。像电商APP的商品详情页、资讯新闻列表页、工具类功能操作页等,只要是以H5形式呈现且对页面性能优化有诉求,希望提升加载速度,FastWeb都能派上用场。它聚焦网络大资源的“提速”核心,而非复杂业务逻辑的处理,旨在帮助大家用轻量化开发实现加载优化。二、两种使用方式:按需选择,灵活配置考虑到不同APP的H5开发现状,FastWeb提供两种灵活方案,无论全面改造还是增量式“迭代开发”,都带来了不错的提升效果。实验数据显示,某APP首次打开且无缓存时,直接加载Web页面需5413.58ms,多次打开有缓存时仍需1345.93ms,这是因为该方式要在页面加载时才拉起渲染进程、发起资源请求,额外增加了加载耗时;而使用FastWeb组件后,首次打开(无缓存)加载页面加载时间缩短49.9%;多次打开(有缓存)页面加载时间缩短39.7%。具体数据如下: 方式一:全面改造,解锁全能力若想彻底发挥FastWeb的优化实力,即便H5已封装过Web容器,也能通过此方式“全方位提速”。它会调用预启动、预渲染、预编译JavaScript生成字节码缓存、离线资源拦截注入四大能力,从“提前准备”到“资源复用”拉满效率。操作很简单:APP启动时(或合适时机)创建空的ArkWeb组件“预热”,展示H5页面时直接挂载即可。需注意删除原有Web容器,将属性和事件写入FastWeb暴露对象,适合有调整空间的团队。方式二:增量式“迭代开发”,快速提效如果已经将H5页面封装成Web容器,并希望在不修改原页面的基础上进行优化,你可以通过FastWeb的预编译JavaScript生成字节码缓存、离线资源拦截注入两大能力,实现提速。操作逻辑同上:提前创建空ArkWeb组件,可以在App启动时创建,或者其他合适的页面创建。展示H5时直接用原有页面,无需额外调整。适合追求“低成本快速优化”的团队,兼顾效果与业务稳定性。三、实用建议:避坑指南,用得更顺手想让FastWeb稳定发挥提速效果,这几个细节要注意:FastWeb组件的核心优势在于网络大资源的预加载能力,而非复杂业务逻辑处理,建议优先用于首页H5、高频核心页等“优化关键路径”,能让提速效果更突出。若应用涉及桥接功能需求,优先选方式二,避免改动原有容器,确保通信稳定的同时,不影响加载速度提升。创建FastWeb组件将占用内存(每个FastWeb组件大约200MB)和计算资源,建议避免一次性创建大量FastWeb组件,按页面访问频率合理规划,避免出现“为了快而牺牲流畅”的情况。对H5多的APP来说,FastWeb不是“可选优化项”,而是“刚需组件”。它无需复杂适配,两种方式覆盖不同开发场景。若你正为H5加载慢头疼,不妨试试FastWeb——让用户告别等待,让APP体验再上台阶。AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。 如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
-
在用户注意力稀缺的今天,如何让每一次触达都精准转化为应用内的活跃行为?华为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的支持!
-
一、问题说明**屏幕方向适配问题:**横竖屏切换时布局样式未自适应调整**全屏控制API使用问题:**getContext() 已废弃导致全屏控制失效**动画控制机制优化:**定时器与手动控制存在状态冲突风险二、原因分析MediaQueryListener 监听逻辑未正确绑定生命周期横屏判断条件 min-aspect-ratio: 1.5 可能不适用于所有设备样式调整逻辑分散在布局代码中,可维护性差旧版 getContext() 方法已被鸿蒙新架构弃用未正确处理窗口获取的异步操作全屏状态切换未考虑生命周期管理定时器未正确清理可能造成内存泄漏全屏状态与动画状态未解耦缺乏平滑的动画过渡效果三、解决思路使用 @ohos.mediaquery 标准API进行屏幕方向监听将布局参数抽离为状态变量集中管理通过 @Builder 分离不同屏幕方向的布局实现使用 getUIContext() 替代弃用API采用 @WindowLink 实现窗口状态同步增加错误边界处理使用 TransitionEffect 实现组合动画通过 Animator 管理动画生命周期采用Promise链式调用确保状态顺序四、解决方案屏幕方向适配问题优化:@Component export struct TransitionsAnimation { @State private isLandscape: boolean = false private mediaListener: mediaquery.MediaQueryListener = mediaquery.matchMedia('(orientation: landscape)') aboutToAppear() { this.mediaListener.on('change', (result: mediaquery.MediaQueryResult) => { this.isLandscape = result.matches }) } @Builder DynamicMargin(topValue: number | string) { Column() .margin({ top: this.isLandscape ? '10vp' : `${topValue}vp` }) } } 全屏控制API使用问题优化:export class WindowUtils { static async setFullScreen(enabled: boolean) { try { const window = await window.getLastWindow(this.getUIContext()) await window.setWindowLayoutFullScreen(enabled) await window.setWindowSystemBarEnable([]) // 隐藏状态栏 } catch (err) { console.error('Window control failed:', err.message) } } } 动画控制机制优化:@Component struct TransitionEffectExample { @State isVisible: boolean = true build() { Column() { if (this.isVisible) { Image($r('app.media.logo')) .transition(TransitionEffect.OPACITY .combine(TransitionEffect.scale({ x: 0, y: 0 })) .combine(TransitionEffect.rotate({ angle: 180 })) ) } Button('切换') .onClick(() => { this.isVisible = !this.isVisible WindowUtils.setFullScreen(!this.isVisible) }) } .animation({ duration: 1000, curve: Curve.EaseInOut }) } } 工程化建议:组件拆分:将动画元素封装为 @Reusable 组件状态管理:使用 @Provide/@Consume 实现跨组件状态共享性能优化:.onAppear(() => { this.mediaListener = mediaquery.matchMedia(...) }) .onDisappear(() => { this.mediaListener.off('change') }) 设备适配:在 resource 目录下定义多维度资源文件
-
1. 问题说明在实现WebRTC视频通信系统时,开发者面临的主要问题包括:信令传输问题:WebRTC本身不提供信令传输机制,需要自行实现客户端之间的通信协调NAT穿透难题:在不同网络环境下的设备之间建立直接P2P连接存在技术障碍媒体流处理复杂:音视频流的采集、编码、传输和渲染需要处理多种技术细节跨浏览器兼容性:不同浏览器对WebRTC的支持程度和API实现存在差异错误处理机制不足:网络异常、设备权限等问题需要完善的错误处理和重试机制2. 原因分析通过对WebRTC通信流程的深入分析,可以识别出以下关键原因:2.1 信令机制缺失WebRTC标准未规定信令协议,需要开发者自行选择实现方式信令服务器需要处理SDP交换和ICE候选信息传递2.2 NAT和防火墙限制企业网络和家庭路由器通常使用NAT技术防火墙策略可能阻止P2P直接连接STUN/TURN服务器配置复杂2.3 媒体流处理复杂性音视频编解码器协商需要精确匹配网络状况变化需要动态调整码率和分辨率设备权限获取需要用户明确授权2.4 浏览器兼容性问题不同浏览器对WebRTC API的支持程度不同SDP格式和ICE候选处理存在细微差异2.5 状态管理困难连接建立过程涉及多个异步步骤错误处理和状态恢复机制复杂3. 解决思路基于以上分析,提出以下系统性的解决方案思路:3.1 分层架构设计信令层:使用WebSocket实现高效的双向通信媒体层:基于WebRTC实现音视频传输控制层:管理连接状态和错误处理3.2 完善的信令协议定义标准的消息格式和类型实现SDP交换和ICE候选传递机制添加心跳检测和重连机制3.3 健壮的NAT穿透策略集成STUN服务器进行地址发现备用TURN服务器中继方案动态ICE候选收集和处理3.4 兼容性处理浏览器特性检测和polyfill统一的API封装和错误处理降级方案和功能检测3.5 用户体验优化清晰的连接状态指示自动重连和恢复机制详细的错误信息和调试支持4. 解决方案4.1 增强型信令服务器实现const WebSocket = require('ws'); const http = require('http'); class SignalingServer { constructor(port = 8080) { this.port = port; this.wss = null; this.clients = new Map(); // 客户端管理 this.initializeServer(); } initializeServer() { const server = http.createServer(); this.wss = new WebSocket.Server({ server }); this.wss.on('connection', (ws, request) => { this.handleConnection(ws, request); }); server.listen(this.port, () => { console.log(`Signaling server running on ws://localhost:${this.port}`); }); } handleConnection(ws, request) { const clientId = this.generateClientId(); this.clients.set(clientId, { ws, id: clientId }); console.log(`Client ${clientId} connected`); // 发送欢迎消息和客户端ID ws.send(JSON.stringify({ type: 'welcome', clientId: clientId, timestamp: Date.now() })); ws.on('message', (message) => { this.handleMessage(clientId, message); }); ws.on('close', () => { this.handleDisconnection(clientId); }); ws.on('error', (error) => { this.handleError(clientId, error); }); } handleMessage(clientId, message) { try { const parsedMessage = JSON.parse(message.toString()); // 验证消息格式 if (!this.validateMessage(parsedMessage)) { this.sendError(clientId, 'Invalid message format'); return; } switch (parsedMessage.type) { case 'offer': case 'answer': case 'candidate': this.relayMessage(clientId, parsedMessage); break; case 'ping': this.handlePing(clientId); break; default: this.sendError(clientId, 'Unknown message type'); } } catch (error) { console.error('Message parsing error:', error); this.sendError(clientId, 'Message parsing failed'); } } relayMessage(senderId, message) { // 这里假设消息中包含目标客户端ID const targetClient = this.clients.get(message.targetClientId); if (targetClient && targetClient.ws.readyState === WebSocket.OPEN) { targetClient.ws.send(JSON.stringify({ ...message, senderClientId: senderId })); } } generateClientId() { return Math.random().toString(36).substring(2, 15); } validateMessage(message) { return message && message.type && typeof message.type === 'string'; } handlePing(clientId) { const client = this.clients.get(clientId); if (client) { client.ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); } } handleDisconnection(clientId) { this.clients.delete(clientId); console.log(`Client ${clientId} disconnected`); // 通知其他客户端 this.broadcast({ type: 'client-disconnected', clientId: clientId, timestamp: Date.now() }); } handleError(clientId, error) { console.error(`Client ${clientId} error:`, error); } sendError(clientId, errorMessage) { const client = this.clients.get(clientId); if (client) { client.ws.send(JSON.stringify({ type: 'error', message: errorMessage, timestamp: Date.now() })); } } broadcast(message) { const messageString = JSON.stringify(message); this.clients.forEach(client => { if (client.ws.readyState === WebSocket.OPEN) { client.ws.send(messageString); } }); } } // 启动服务器 new SignalingServer(8080); 4.2 增强型客户端实现<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>WebRTC视频通信系统</title> <style> .video-container { display: flex; gap: 20px; margin-bottom: 20px; } video { width: 400px; height: 300px; border: 1px solid #ccc; background-color: #000; } .controls { margin: 20px 0; } button { padding: 10px 20px; margin: 0 10px 10px 0; font-size: 16px; cursor: pointer; } textarea { width: 100%; height: 200px; margin-bottom: 20px; } .status { padding: 10px; margin: 10px 0; border: 1px solid #ccc; background-color: #f9f9f9; } </style> </head> <body> <h1>WebRTC视频通信系统</h1> <div class="video-container"> <div> <h3>本地视频</h3> <video id="localVideo" autoplay muted></video> </div> <div> <h3>远程视频</h3> <video id="remoteVideo" autoplay></video> </div> </div> <div class="status" id="connectionStatus">未连接</div> <div class="controls"> <button onclick="startCamera()">开启摄像头</button> <button onclick="createOffer()">创建连接</button> <button onclick="hangUp()">挂断</button> </div> <textarea id="messages" readonly></textarea> <script> class WebRTCClient { constructor() { this.socket = null; this.peerConnection = null; this.localStream = null; this.remoteStream = null; this.clientId = null; this.targetClientId = null; this.initialize(); } initialize() { this.initializeUI(); this.initializeWebSocket(); this.initializePeerConnection(); } initializeUI() { this.messages = document.getElementById('messages'); this.connectionStatus = document.getElementById('connectionStatus'); this.localVideo = document.getElementById('localVideo'); this.remoteVideo = document.getElementById('remoteVideo'); } initializeWebSocket() { const serverUrl = 'ws://10.88.27.199:8080'; this.socket = new WebSocket(serverUrl); this.socket.onopen = () => { this.log('连接到信令服务器'); this.updateStatus('已连接到信令服务器'); }; this.socket.onmessage = (event) => { this.handleSignalingMessage(event.data); }; this.socket.onclose = () => { this.log('信令服务器连接断开'); this.updateStatus('信令服务器连接断开'); }; this.socket.onerror = (error) => { this.log('WebSocket错误: ' + error); this.updateStatus('连接错误'); }; } initializePeerConnection() { const configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }; this.peerConnection = new RTCPeerConnection(configuration); // 设置事件监听器 this.peerConnection.onicecandidate = (event) => { if (event.candidate) { this.sendSignalingMessage({ type: 'candidate', candidate: event.candidate, targetClientId: this.targetClientId }); } }; this.peerConnection.ontrack = (event) => { this.remoteStream = event.streams[0]; this.remoteVideo.srcObject = this.remoteStream; this.log('接收到远程视频流'); this.updateStatus('视频连接已建立'); }; this.peerConnection.onconnectionstatechange = () => { this.updateStatus('连接状态: ' + this.peerConnection.connectionState); }; this.peerConnection.oniceconnectionstatechange = () => { this.log('ICE连接状态: ' + this.peerConnection.iceConnectionState); }; } async startCamera() { try { const constraints = { video: { width: 640, height: 480 }, audio: true }; this.localStream = await navigator.mediaDevices.getUserMedia(constraints); this.localVideo.srcObject = this.localStream; // 添加本地流到peer connection this.localStream.getTracks().forEach(track => { this.peerConnection.addTrack(track, this.localStream); }); this.log('摄像头启动成功'); this.updateStatus('摄像头就绪'); } catch (error) { this.log('摄像头访问失败: ' + error.message); this.updateStatus('摄像头访问被拒绝'); } } async createOffer() { if (!this.localStream) { this.log('请先启动摄像头'); return; } try { const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); this.sendSignalingMessage({ type: 'offer', sdp: offer, targetClientId: this.targetClientId }); this.log('已创建并发送Offer'); this.updateStatus('等待对方应答'); } catch (error) { this.log('创建Offer失败: ' + error); } } async handleOffer(offer, senderClientId) { this.targetClientId = senderClientId; try { await this.peerConnection.setRemoteDescription(offer); const answer = await this.peerConnection.createAnswer(); await this.peerConnection.setLocalDescription(answer); this.sendSignalingMessage({ type: 'answer', sdp: answer, targetClientId: this.targetClientId }); this.log('已处理Offer并发送Answer'); } catch (error) { this.log('处理Offer失败: ' + error); } } async handleAnswer(answer) { try { await this.peerConnection.setRemoteDescription(answer); this.log('已处理Answer,连接建立中'); } catch (error) { this.log('处理Answer失败: ' + error); } } async handleCandidate(candidate) { try { await this.peerConnection.addIceCandidate(candidate); } catch (error) { this.log('添加ICE候选失败: ' + error); } } handleSignalingMessage(message) { try { const parsedMessage = JSON.parse(message); switch (parsedMessage.type) { case 'welcome': this.clientId = parsedMessage.clientId; this.log('连接到服务器,客户端ID: ' + this.clientId); break; case 'offer': this.handleOffer(parsedMessage.sdp, parsedMessage.senderClientId); break; case 'answer': this.handleAnswer(parsedMessage.sdp); break; case 'candidate': this.handleCandidate(parsedMessage.candidate); break; case 'error': this.log('服务器错误: ' + parsedMessage.message); break; default: this.log('未知消息类型: ' + parsedMessage.type); } } catch (error) { this.log('消息处理错误: ' + error); } } sendSignalingMessage(message) { if (this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } else { this.log('WebSocket未连接,无法发送消息'); } } hangUp() { if (this.peerConnection) { this.peerConnection.close(); this.initializePeerConnection(); } if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; this.localVideo.srcObject = null; } this.remoteVideo.srcObject = null; this.log('通话已结束'); this.updateStatus('通话结束'); } log(message) { const timestamp = new Date().toLocaleTimeString(); this.messages.value += `[${timestamp}] ${message}\n`; this.messages.scrollTop = this.messages.scrollHeight; } updateStatus(status) { this.connectionStatus.textContent = status; } } // 全局客户端实例 let webrtcClient; // 页面加载完成后初始化 window.onload = function() { webrtcClient = new WebRTCClient(); }; // 全局函数供按钮调用 function startCamera() { webrtcClient.startCamera(); } function createOffer() { // 在实际应用中,这里应该通过UI选择目标客户端 webrtcClient.targetClientId = prompt('请输入目标客户端ID:'); if (webrtcClient.targetClientId) { webrtcClient.createOffer(); } } function hangUp() { webrtcClient.hangUp(); } </script> </body> </html>
-
一、问题说明直播场景下播控中心显示了已播放时间、视频时长、播放进度条,用户滑动播放进度条可以seek视频,此时的seek无任何意义。二、原因分析业务侧没有区分直播、点播等场景,播控中心会话接收播放器的进度后进行了更新操作,可以不用考虑业务如何实现、如何更新视频播放的positon、duration,我们关注播控会话调用更新API方法侧,这样改动最小,影响最小。三、解决思路1.查找官方文档,涉及播控中心更新数据视频相关的position、duration相关方法等,了解如何使用2.梳理本地播控中心管理类更新当前视频相关的position、duration相关方法3.梳理取消直播场景session下的相关监听四、解决方法1.根据官方API在setAVMetadata、setAVPlaybackState方法中调整position、duration2.按照1调整完后在投屏过程中仍然发现会播放时长显示,排查到投屏方法prepare(item: AVQueueItem): Promise<void>;会进行传值duration,将duration字段值赋值-1后,在播放视频过程中、或者投屏过程中,直播节目播放中心不在显示播放position、duration,播控中心上一个或者下一个节目验证也是没问题的。3.移除直播场景下的播控会话监听播放进度也不能seek至此,播控中心播放时间不显示、总时长不显示、进度条不能seek,完成业务侧需求。
-
一、关键技术难点总结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的优化升级。
-
一、问题说明A页面快速进入B页面后,B页面快速进入后台,即整个App都处于后台中,这个时候小窗开启;点击小窗或者点击App桌面图标唤起应用,这个时候快速把B页面关闭掉让App快速进入后台,此时A页面的不能自动拉起画中画。(这里所说的A、B页面同属于一个Ability)二、原因分析先贴上关闭画中画的代码:/*** 关闭画中画*/async stopPip() { try { this.autoStartPip = false if(this.pipController){ this.pipController?.off('stateChange'); this.pipController?.off('controlEvent'); await this.pipController?.stopPiP(); } } catch (e) { console.log('PipManager','关闭画中画: '+json.stringify(e)) }}1.本地画中画关闭日志、拉起日志分析PipManager, 关闭画中画: {"code":1300012},发现多次打印关闭画中画日志2.官方文档查询,画中画拉起和关闭怎么使用的三、解决思路1.断点查找关闭画中画日志的调用栈,减少不必要的关闭动作2.画中画的stateChange、controlEvent事件监听放在画中画关闭成功的回调里面3.页面B不需要画中画,进行关闭处理。四、解决方法关闭画中画方法调整如下:/** * 关闭画中画 */async stopPip(stopPipFinished?:()=>void) { if (this.pipController) { this.pipController.stopPiP() .then(() => { this.pipController?.off('stateChange'); this.pipController?.off('controlEvent'); console.log('PipManager', "关闭画中画成功 ") }) .catch((e: BusinessError) => { console.log('PipManager', '关闭画中画 error:' + json.stringify(e)) }) .finally(() => { this.autoStartPip = false stopPipFinished?.() console.log('PipManager', "stopPip-finally方法执行 ") }) } else { this.autoStartPip = false stopPipFinished?.() }}再开启画中画之前将之前的关闭,一定要在关闭结束后再开启画中画:this.stopPip(()=> { //开启画中画})画中画的开启参照官方文档:
-
问题说明:过去我们遇到了什么麻烦?在HarmonyOS此前的版本中,开发者在使用图标、矢量图形或自定义组件时,常常会遇到以下几个痛点:动效复用困难:如果想为多个图标赋予相同的交互动效(如点击时缩放、旋转),开发者往往需要为每个图标单独编写冗长的animate代码或状态机逻辑。这不仅导致代码冗余,维护起来更是噩梦,修改一个动效需要找到所有使用的地方逐一更改。效果叠加不便捷:为图形添加阴影或渐变效果通常需要嵌套多个组件或使用复杂的Decoration配置。尤其是动态阴影(如根据按压状态改变阴影深度),需要手动绑定数据变化和UI更新,逻辑繁琐。状态管理不连贯:“禁用”状态是一个常见的组件状态。过去,要实现禁用状态,我们通常需要手动切换图标资源(如从彩色切到灰色),并取消所有的交互动效。这个过程是割裂的,动效的禁用和样式的更改需要分开处理,不够原子化。设计一致性挑战:由于上述实现的复杂性,不同开发者甚至同一开发者在不同页面实现的相似效果都可能存在细微差异,难以保证整个应用体验的统一性,与设计稿的还原度也会大打折扣。简单来说,过去的开发模式是 “手动” 和 “分散” 的,缺乏一种声明式的、可复用的范式来统一管理图形的视觉效果和交互反馈。原因分析:为什么鸿蒙6.0要引入这些新特性?HarmonyOS 6.0 对 SymbolGlyph 的增强,其核心目的是为了解决上述问题,背后的原因可以归结为三点:提升开发效率与代码可维护性:通过将常见的视觉效果(阴影、渐变)和交互逻辑(动效、禁用)内置化、配置化,开发者无需再编写重复的样板代码。这极大地简化了开发流程,缩短了开发时间,并且让代码更加清晰、易于维护。强化设计系统与开发的一致性:SymbolGlyph 本身也是设计 tokens 的工程化体现。将这些效果作为其固有属性,使得设计师在设计规范中定义的阴影值、渐变色、微动效能够被精准、方便地落地到代码中,保证了设计与实现的高度统一。赋能更流畅细腻的交互体验:用户对应用的体验要求越来越高,流畅自然的动效和恰当的视觉反馈是提升应用品质的关键。系统级的支持可以优化这些效果的渲染性能,并提供更稳定流畅的动画表现,从而让应用显得更加精致和高端。本质上,这是鸿蒙设计系统(HarmonyOS Design)走向成熟,并更加注重开发者体验和用户体验双提升的重要标志。解决思路:如何利用新特性优雅地开发?HarmonyOS 6.0 为 SymbolGlyph 组件提供了一系列新的属性方法来应对上述挑战。我们的解决思路从“配置”代替“编码”出发。解决方案:属性1.渐变色效果(shaderStyle)通过shaderStyle设置SymbolGlyph组件的渐变色效果。shaderStyle(shaders: Array<ShaderStyle | undefined> | ShaderStyle)使用案例@Entry@Componentstruct Index {@State message: string = ‘Hello World’;linearGradientOptions1: LinearGradientOptions = {angle: 45,colors: [[Color.Red, 0.0], [Color.Blue, 0.3], [Color.Green, 0.5]]};linearGradientOptions2: LinearGradientOptions = {direction: GradientDirection.LeftTop,colors: [[Color.Red, 0.0], [Color.Blue, 0.3], [Color.Green, 0.5]],repeating: true,};radialGradientOptions: RadialGradientOptions = {center: [“50%”, “50%”],radius: “20%”,colors: [[Color.Red, 0.0], [Color.Blue, 0.3], [Color.Green, 0.5]],repeating: true,};build() {Column() {Row() {Column() {Text(‘angle为45°的线性渐变’).fontSize(18).fontColor(0xCCCCCC).textAlign(TextAlign.Center)SymbolGlyph(r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle([new LinearGradientStyle(this.linearGradientOptions1)]) } .margin({ right: 20 }) Column() { Text('LeftTop的线性渐变') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph(r(‘sys.symbol.ohos_folder_badge_plus’)).fontSize(96).shaderStyle([new LinearGradientStyle(this.linearGradientOptions2)])}.margin({ right: 20 })} Row() { Column() { Text('径向渐变') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle([new RadialGradientStyle(this.radialGradientOptions)]) } .margin({ right: 20 }) Column() { Text('纯色') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle([new ColorShaderStyle(Color.Red)]) } .margin({ right: 20 }) Column() { Text('线性和径向渐变') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle([ new LinearGradientStyle(this.linearGradientOptions2), new LinearGradientStyle(this.linearGradientOptions2), new RadialGradientStyle(this.radialGradientOptions) ]) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY) } .margin({ right: 20 }) } Row() { Column() { Text('数组单层渐变') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle([ new LinearGradientStyle(this.linearGradientOptions2), ]) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY) }.margin({ right: 20 }) Column() { Text('非数组覆盖全部') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle(new RadialGradientStyle(this.radialGradientOptions)) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY) }.margin({ right: 20 }) Column() { Text('首层为默认') .fontSize(18) .fontColor(0xCCCCCC) .textAlign(TextAlign.Center) SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .shaderStyle([ undefined, new LinearGradientStyle(this.linearGradientOptions2), ]) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY) }.margin({ right: 20 }) } } .margin({ left: 20, top: 50 })}}2.阴影效果(symbolShadow)通过symbolShadow设置SymbolGlyph组件的阴影效果。symbolShadow(shadow: Optional<ShadowOptions>)使用案例(通过设置动效和阴影)// xxx.ets@Entry@Componentstruct Index {@State isActive: boolean = true;@State triggerValueReplace: number = 0;@State triggerValueReplace1: number = 0;@State triggerValueReplace2: number = 0;@State renderMode: number = 1;replaceFlag: boolean = true;replaceFlag1: boolean = true;replaceFlag2: boolean = true;options: ShadowOptions = {radius: 10.0,color: Color.Blue,offsetX: 10,offsetY: 10,};build() {Column() {Row() {Column() {Text(“可变颜色动效”)SymbolGlyph($r(‘sys.symbol.ohos_wifi’)).fontSize(96).symbolEffect(new HierarchicalSymbolEffect(EffectFillStyle.ITERATIVE), this.isActive)Button(this.isActive ? ‘关闭’ : ‘播放’).onClick(() => {this.isActive = !this.isActive;})}.margin({ right: 20 })Column() {Text(“替换动效”)SymbolGlyph(this.replaceFlag ? $r(‘sys.symbol.checkmark_circle’) : $r(‘sys.symbol.repeat_1’)).fontSize(96).symbolEffect(new ReplaceSymbolEffect(EffectScope.WHOLE), this.triggerValueReplace)Button(‘trigger’).onClick(() => {this.replaceFlag = !this.replaceFlag;this.triggerValueReplace = this.triggerValueReplace + 1;})}.margin({ right: 20 })} Row() { Column() { Text("禁用动效") SymbolGlyph(this.replaceFlag1 ? $r('sys.symbol.eye_slash') : $r('sys.symbol.eye')) .fontSize(96) .renderingStrategy(this.renderMode) .symbolEffect(new DisableSymbolEffect(EffectScope.LAYER), this.triggerValueReplace1) Button('trigger') .onClick(() => { this.replaceFlag1 = !this.replaceFlag1; this.triggerValueReplace1 = this.triggerValueReplace1 + 1; }) } .margin({ right: 20 }) Column() { Text("快速替换动效") SymbolGlyph(this.replaceFlag2 ? $r('sys.symbol.checkmark_circle') : $r('sys.symbol.repeat_1')) .fontSize(96) .symbolEffect(new QuickReplaceSymbolEffect(EffectScope.WHOLE), this.triggerValueReplace2) Button('trigger') .onClick(() => { this.replaceFlag2 = !this.replaceFlag2; this.triggerValueReplace2 = this.triggerValueReplace2 + 1; }) } .margin({ right: 20 }) Column() { Text("阴影能力") SymbolGlyph($r('sys.symbol.ohos_wifi')) .fontSize(96) .symbolEffect(new HierarchicalSymbolEffect(EffectFillStyle.ITERATIVE), this.isActive) .symbolShadow(this.options) Button(this.isActive ? '关闭' : '播放') .onClick(() => { this.isActive = !this.isActive; }) } .margin({ right: 20 }) } } .margin({ left: 45, top: 50 })}}设置渲染和动效策略使用案例// xxx.ets@Entry@Componentstruct Index {build() {Column() {Row() {Column() {Text(“Light”)SymbolGlyph($r(‘sys.symbol.ohos_trash’)).fontWeight(FontWeight.Lighter).fontSize(96)} Column() { Text("Normal") SymbolGlyph($r('sys.symbol.ohos_trash')) .fontWeight(FontWeight.Normal) .fontSize(96) } Column() { Text("Bold") SymbolGlyph($r('sys.symbol.ohos_trash')) .fontWeight(FontWeight.Bold) .fontSize(96) } } Row() { Column() { Text("单色") SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .renderingStrategy(SymbolRenderingStrategy.SINGLE) .fontColor([Color.Black, Color.Green, Color.White]) } Column() { Text("多色") SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR) .fontColor([Color.Black, Color.Green, Color.White]) } Column() { Text("分层") SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus')) .fontSize(96) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY) .fontColor([Color.Black, Color.Green, Color.White]) } } Row() { Column() { Text("无动效") SymbolGlyph($r('sys.symbol.ohos_wifi')) .fontSize(96) .effectStrategy(SymbolEffectStrategy.NONE) } Column() { Text("整体缩放动效") SymbolGlyph($r('sys.symbol.ohos_wifi')) .fontSize(96) .effectStrategy(SymbolEffectStrategy.SCALE) } Column() { Text("层级动效") SymbolGlyph($r('sys.symbol.ohos_wifi')) .fontSize(96) .effectStrategy(SymbolEffectStrategy.HIERARCHICAL) } } }}}
上滑加载中
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签