-
在用户注意力稀缺的今天,如何让每一次触达都精准转化为应用内的活跃行为?华为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的支持!
-
1.问题说明:如何进行卡片开发2.原因分析:创建卡片类型,3.解决思路:熟悉卡片的概念,开发流程,部署测试4.解决方案:如下HarmonyOS Next 中的卡片(Service Widget)是一种轻量级应用组件,可直接在桌面展示关键信息并支持快速交互,提升用户体验。以下是卡片开发的核心要点和实现流程:一、卡片基本概念类型:静态卡片:内容固定,通过配置文件定义布局和数据。动态卡片:内容可实时更新,通过后台服务(Ability)刷新数据。交互卡片:支持用户点击、滑动等操作,触发页面跳转或数据处理。尺寸:系统提供多种预设尺寸(如 2x2、4x2 等),需适配不同规格。二、开发流程1. 创建卡片工程在 DevEco Studio 中,新建 HarmonyOS 项目时选择 Application Widget 模板,自动生成基础结构:widgets 目录:存放卡片布局和配置文件。entry 目录:主应用逻辑(可选,用于卡片交互)。2. 定义卡片布局(XML/ArkTS)使用 XML 或 ArkTS 声明式 UI 定义卡片界面,示例如下:ArkTS 布局: // widgets/CardWidget.ets@Entry@Componentstruct CardWidget { // 卡片数据(静态或从服务获取) @State message: string = 'Hello Card' build() { Column() { Text(this.message) .fontSize(16) .margin(10) Button('点击更新') .onClick(() => { // 触发卡片交互(需配置路由) postCardAction(this, { action: 'router', params: { url: 'pages/DetailPage' } }) }) } .width('100%') .height('100%') .backgroundColor('#f5f5f5') }}3. 配置卡片信息在 main_pages.json 中注册卡片,并配置尺寸、更新策略等: { "src": [ "pages/Index", "widgets/CardWidget" // 注册卡片 ], "window": { "designWidth": 720, "autoDesignWidth": true }, "widget": { "styles": [ { "name": "2x2", "description": "2x2 尺寸卡片", "dimensions": { "width": 2, "height": 2 } } ], "updateEnabled": true, // 允许动态更新 "scheduledUpdateTime": "08:00", // 定时更新时间 "updateDuration": 3600 // 更新周期(秒) }}4. 动态数据更新通过 FormProvider 实现卡片数据刷新:注册卡片服务: // entry/src/main/ets/entryability/EntryAbility.tsimport formInfo from '@ohos.app.form.formInfo';import formProvider from '@ohos.app.form.formProvider';export default class EntryAbility extends Ability { onAddForm(want: Want): formInfo.FormState { // 初始化卡片数据 const formData = { message: '初始数据' }; formProvider.updateForm(want.parameters[formInfo.FormParam.ID], formData) .catch(err => console.error('更新卡片失败', err)); return formInfo.FormState.READY; } // 其他生命周期方法...} 主动更新数据: // 在需要更新的地方调用(如网络请求后)import formProvider from '@ohos.app.form.formProvider';function refreshCard(formId: string) { const newData = { message: '实时更新的数据' }; formProvider.updateForm(formId, newData) .then(() => console.log('卡片更新成功')) .catch(err => console.error('卡片更新失败', err));}5. 卡片交互配置实现卡片点击跳转应用页面: 在卡片布局中添加 postCardAction: // 点击按钮跳转到应用内页面Button('查看详情') .onClick(() => { postCardAction(this, { action: 'router', params: { url: 'pages/DetailPage', // 目标页面 data: JSON.stringify({ id: 123 }) // 传递参数 } }) }) 在主应用中接收参数: // pages/DetailPage.etsimport router from '@ohos.router';@Entry@Componentstruct DetailPage { private data: string = '' aboutToAppear() { // 获取卡片传递的参数 const params = router.getParams(); this.data = params.data as string; } build() { Text('接收数据: ' + this.data) }}三、部署与调试预览卡片:在 DevEco Studio 中使用 Previewer 查看不同尺寸的卡片效果。运行到设备:将应用安装到真机后,长按桌面添加卡片。调试工具:通过 HVD(HarmonyOS Virtual Device) 或真机日志查看卡片生命周期和数据更新情况。四、注意事项性能优化:卡片布局应简洁,避免复杂计算,更新频率不宜过高。权限管理:动态卡片获取网络、位置等数据时,需在 module.json5 中声明对应权限。兼容性:不同设备可能支持的卡片尺寸不同,需做好适配。 通过以上步骤,可快速实现一个具有动态更新和交互能力的 HarmonyOS Next 卡片。更多高级特性(如跨设备卡片、数据持久化)可参考官方文档。
-
1.问题说明:多实例模式下,UIAbility的实例是如何管理的2.原因分析:并行存在,独立调度,3.解决思路:实例标识:通过abilityRecordId区分试4.解决方案:如下在 HarmonyOS 的multiton(多实例模式) 下,UIAbility 的实例管理遵循 “独立创建、分别维护、各自销毁” 的原则,每个实例拥有独立的生命周期、资源空间和状态。具体管理机制如下:一、实例创建:每次启动均生成新实例触发时机:每次通过startAbility()启动该 UIAbility 时,系统都会创建一个全新的实例,无论之前是否存在该 UIAbility 的实例。独立性:新实例与已有实例完全隔离,拥有独立的内存空间、生命周期状态和数据存储(如成员变量、页面栈等)。参数传递:每次启动的Want参数仅传递给当前新创建的实例,通过onCreate(want)或onWindowStageCreate中的want参数获取,不影响其他实例。 示例场景:多次打开 “记事本” 应用的 “新建文档” 页面,每次打开都会创建一个独立的 UIAbility 实例,每个实例对应一个独立的文档编辑界面,编辑内容互不干扰。二、实例运行:并行存在,独立调度多实例共存:系统允许同时存在多个该 UIAbility 的实例,数量不受限制(仅受系统内存等资源约束)。独立生命周期:每个实例的生命周期回调(如onForeground、onBackground)独立触发,一个实例的状态变化(如切换到后台)不会影响其他实例。任务栈管理:默认情况下,多实例会共享应用的任务栈,但每个实例对应栈中的一个独立任务记录;也可通过配置missionStackType让不同实例归属不同任务栈(如多窗口场景)。三、实例标识:通过abilityRecordId区分系统为每个 UIAbility 实例分配唯一的abilityRecordId(可通过getAbilityRecordId()获取),用于在系统层面唯一标识该实例。开发者可通过此 ID 跟踪特定实例(如在跨实例通信、状态管理时区分不同实例)。四、实例销毁:单独处理,互不影响销毁触发:当单个实例被关闭(如用户在任务管理器中划掉该实例,或调用terminateSelf()),仅该实例触发销毁流程:onWindowStageDestroy() → onDestroy()。资源释放:销毁时,该实例占用的内存、句柄等资源会被释放,其他实例不受影响继续运行。系统回收:当系统内存不足时,可能按 LRU(最近最少使用)策略优先回收后台的多实例(非关键实例),但不会影响前台实例。五、开发者的管理责任在多实例模式下,开发者需要手动处理以下事项: 状态隔离:确保实例间的数据不共享(如需共享需通过全局存储、数据库等跨实例机制)。资源控制:避免无限制创建实例导致的资源耗尽(可通过业务逻辑限制最大实例数)。实例通信:若需多实例间交互,需使用IPC、EventHub或分布式数据服务等跨进程 / 实例通信方式。任务栈配置:根据业务需求配置missionStackType,控制实例在任务栈中的组织方式(如独立栈适合多窗口并行操作)。总结多实例模式的核心是 “完全独立”:系统负责按启动请求创建新实例并维护其唯一性,开发者负责管理实例间的隔离与协作。这种模式适合需要同时运行多个独立页面的场景(如多文档编辑、多会话窗口),但需注意资源消耗和状态管理的复杂性。
-
1.1问题说明在鸿蒙(HarmonyOS)应用的分类模块中,存在一个影响用户体验的关键问题:当用户在使用分类页面选择了某些分类后,退出页面(包括返回上级页面、切换应用至后台或应用被系统回收),再次进入该页面时,之前选择的分类数据会丢失,用户需要重新进行选择操作。1.2原因分析(一)内存状态未持久化:已选分类数据仅依赖页面组件的@State装饰器变量进行内存级存储,未与鸿蒙系统的持久化存储介质(如Preferences)建立同步机制。在鸿蒙 ArkUI 框架中,@State变量的生命周期与组件实例强绑定 ,当用户退出页面、应用被切换至后台,或页面因路由跳转被销毁时,组件实例会被销毁,内存释放而丢失。(二)生命周期管理缺失:未充分利用鸿蒙组件的生命周期函数,在页面隐藏、销毁或被系统回收时,未触发数据保存逻辑,导致临界状态下的数据丢失。1.3解决思路(一)建立完整的数据同步链路:构建 “内存状态与持久化存储” 的双向实时同步机制,确保用户操作产生的已选分类数据在任何场景下都能保持一致,具体包含两个核心流向:内存状态变更→即时持久化:当用户在页面中勾选或取消勾选分类时,首先更新内存中的@State状态变量,触发 UI 实时刷新以反馈选择结果;同时,同步调用持久化存储工具,将最新的selectedCategoryIds数组写入鸿蒙Preferences。页面初始化→从存储恢复状态:当用户再次进入分类页面时,通过存储工具读取之前保存的已选分类数据,并将其赋值给内存中的@State变量,使页面展示用户上次的选择结果。(二)结合生命周期强化数据安全:针对鸿蒙组件的完整生命周期流转,在关键节点嵌入数据保存 / 恢复逻辑,构建 “多层级兜底保障网”,确保极端场景下已选分类数据不丢失。具体实现如下:页面初始化阶段(aboutToAppear):作为页面加载的第一个生命周期节点,在此阶段完成两项核心操作:初始化持久化存储工具,确保存储服务就绪;从Preferences中读取历史已选数据,赋值给内存中的@State变量,使页面刚加载就恢复上次选择状态。页面隐藏阶段(onPageHide):当用户将应用切换到后台或跳转到其他页面时,触发此节点。此时执行一次数据保存操作,将当前内存中的已选分类数据同步到Preferences。组件复用(aboutToReuse):当可复用的自定义组件从复用缓存中重新加入节点树时,会触发aboutToReuse生命周期回调。在分类页面的实现中,这一机制被用于精准恢复组件状态:当页面组件从缓存中复用激活时,通过校验快照数据的有效性,将已选分类 ID 列表赋值给内存中的@State变量,确保组件重建后能立即恢复用户之前的选择状态;复用组件回收(aboutToRecycle):在可复用组件从组件树上被移除并即将加入复用缓存之前,会触发aboutToRecycle生命周期回调。当组件即将进入回收时,通过调用storageUtil.saveSelectedCategories方法,将当前内存中selectedCategoryIds记录的已选分类 ID 列表同步至持久化存储。杜绝了因组件生命周期流转导致的数据丢失风险。页面彻底销毁时(aboutToDisappear):当用户明确退出页面(如点击返回按钮),页面进入最终销毁阶段。在此节点执行最后一次数据保存,覆盖所有可能遗漏的场景,作为数据安全的 “最终兜底”。1.4解决方案(一) 内存状态变更→即时持久化链路当用户在页面中勾选或取消勾选分类时:首先更新@State装饰的selectedCategoryIds数组,触发 ArkUI 框架自动刷新 UI,实时反馈选择结果同步调用saveSelectedCategories方法,将最新的已选 ID 列表写入鸿蒙Preferences,即使遇到应用崩溃、系统强制回收等极端场景,数据也不会丢失。(二)页面初始化→从存储恢复状态链路当用户再次进入分类页面时:在页面初始化的aboutToAppear生命周期阶段,通过存储工具类读取之前保存的已选分类数据,将读取到的数据赋值给内存中的@State变量selectedCategoryIds框架自动根据最新的@State状态重新渲染,既保证了页面交互的流畅性,又确保了数据的持久性和可靠性。(三)结合生命周期的数据安全保障实现页面初始化时从存储恢复数据,用户操作中实时同步内存与存储,页面隐藏时备份数据,页面回收前进行存储,彻底杜绝分类已选数据在临界状态下的丢失风险。代码示例:1、CategoryIndex:import { CategoryStorageUtil } from './StorageUtil'; import { UserUtil } from './UserUtil'; import { BusinessError } from '@kit.BasicServicesKit'; interface Category { id: number, name: string } @Entry @Component struct CategoryIndex { // 内存中的已选分类ID列表 @State selectedCategoryIds: number[] = []; // 分类数据源 private categoryList: Array<Category> = [ { id: 0, name: '职业资格' }, { id: 1, name: '建筑工程' }, { id: 2, name: '财会经济' }, { id: 3, name: '医学健康' }, { id: 4, name: '语言学习' }, { id: 5, name: '职业技能' } ]; // 存储工具实例 private storageUtil: CategoryStorageUtil = CategoryStorageUtil.getInstance(); // 当前用户ID private currentUserId: string | undefined = UserUtil.getUserId(); /** * 1. 页面初始化阶段(aboutToAppear) * 数据恢复的第一道防线 */ aboutToAppear() { let context = this.getUIContext().getHostContext() as Context; // 初始化持久化存储工具 this.storageUtil.initialize(context).then((success: boolean) => { if (!success) { console.error('初始化存储工具失败'); return } // 从Preferences中读取历史已选数据 this.storageUtil.getSelectedCategories(this.currentUserId).then((savedIds: number[]) => { // 赋值给内存状态变量,恢复上次选择状态 this.selectedCategoryIds = savedIds; }) }) } /** * 处理分类选择状态变更 * 操作中实时同步 */ handleCategorySelectionChange(categoryId: number, isChecked: boolean) { // 更新内存状态 if (isChecked) { this.selectedCategoryIds.push(categoryId); } else { this.selectedCategoryIds = this.selectedCategoryIds.filter(id => id !== categoryId); } console.info('页面隐藏,执行数据备份'); } /** * 2. 页面隐藏阶段(onPageHide) * 针对用户未主动退出但页面不可见的场景 */ onPageHide() { console.info('页面隐藏,执行数据备份'); // 将当前内存中的已选分类数据同步到Preferences this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面隐藏时保存失败: ${error}`); }); } /** * 3. 可复用组件从组件树上被加入到复用缓存之前调用 * 针对系统强制回收的极端场景 */ aboutToRecycle() { console.info('系统即将回收页面,执行'); // 同时执行持久化存储,形成"快照+持久化"双重备份 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`系统回收前保存失败: ${error}`); }); } /** * 4. 页面重建时(aboutToReuse) * 系统回收后的恢复机制 */ aboutToReuse(state: Record<string, number[]>) { console.info('页面重建,从快照恢复数据'); // 从快照中恢复已选分类数据到内存状态 if (state['selectedCategoryIds'] && Array.isArray(state['selectedCategoryIds'])) { this.selectedCategoryIds = state['selectedCategoryIds'] as number[]; // 同步将数据写入持久化存储,确保快照数据与Preferences一致 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面重建时同步数据失败: ${error}`); }); } } /** * 5. 页面彻底销毁时(onDestroy) * 数据安全的最终兜底 */ aboutToDisappear(): void { console.info('页面销毁,执行最终数据保存'); // 执行最后一次数据保存,覆盖所有可能遗漏的场景 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面销毁时保存失败: ${error}`); }); } build() { Column() { Text('分类选择') .fontSize(20) .fontWeight(FontWeight.Bold) .margin(16); List() { ForEach(this.categoryList, (category: Category) => { ListItem() { Row() { Checkbox() .select(this.selectedCategoryIds.includes(category.id)) .onChange((isChecked: boolean) => { this.handleCategorySelectionChange(category.id, isChecked); }) .size({ width: 20, height: 20 }) .margin({ left: 12 }); Text(category.name) .fontSize(16) .width('100%') .margin({ left: 12 }); } .height(56) .alignItems(VerticalAlign.Center) .backgroundColor('#FFFFFF') } }, (category: Category) => category.id.toString()); } .divider({ strokeWidth: 1, color: '#EEEEEE' }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5'); } } 2、StorageUtil:import { preferences } from '@kit.ArkData'; import { BusinessError } from '@ohos.base'; import { deviceInfo } from '@kit.BasicServicesKit'; /** * 课程分类存储工具类 * 负责已选分类数据的持久化存储与读取 */ export class CategoryStorageUtil { private static instance: CategoryStorageUtil; private dataPreferences: preferences.Preferences | null = null; private isInitialized: boolean = false; // 单例模式确保存储实例唯一 static getInstance(): CategoryStorageUtil { if (!CategoryStorageUtil.instance) { CategoryStorageUtil.instance = new CategoryStorageUtil(); } return CategoryStorageUtil.instance; } /** * 初始化存储服务 * @param context 页面上下文 */ async initialize(context: Context): Promise<boolean> { if (this.isInitialized) { return true; } try { // 创建或获取名为"course_category"的偏好设置实例 this.dataPreferences = await preferences.getPreferences(context, 'course_category'); this.isInitialized = true; return true; } catch (err) { console.error(`存储初始化失败: ${(err as BusinessError).message}`); return false; } } /** * 生成唯一缓存键,支持多用户和设备隔离 * @param userId 用户ID,未登录时使用设备ID */ private getStorageKey(userId?: string): string { // 未登录用户使用设备唯一标识 const uniqueIdentifier = userId || `device_${deviceInfo.udid}`; return `selected_category_ids_${uniqueIdentifier}`; } /** * 保存已选分类数据到持久化存储 * @param selectedIds 已选分类ID数组 * @param userId 用户ID */ async saveSelectedCategories(selectedIds: number[], userId?: string): Promise<boolean> { if (!this.dataPreferences || !this.isInitialized) { console.error('存储服务未初始化,无法保存数据'); return false; } // 数据格式验证 if (!Array.isArray(selectedIds) || !selectedIds.every(id => typeof id === 'number')) { console.error('已选分类数据格式无效,必须是数字数组'); return false; } try { const storageKey = this.getStorageKey(userId); await this.dataPreferences.put(storageKey, selectedIds); await this.dataPreferences.flush(); // 立即写入磁盘确保数据持久化 return true; } catch (err) { console.error(`保存已选分类失败: ${(err as BusinessError).message}`); return false; } } /** * 从持久化存储获取已选分类数据 * @param userId 用户ID */ async getSelectedCategories(userId?: string): Promise<number[]> { if (!this.dataPreferences || !this.isInitialized) { console.error('存储服务未初始化,无法获取数据'); return []; } try { const storageKey = this.getStorageKey(userId); const storedData = await this.dataPreferences.get(storageKey, []); // 验证存储数据格式 if (Array.isArray(storedData)) { return storedData as number[]; } else { console.warn('存储数据格式不正确,返回空数组'); // 清理错误数据 await this.dataPreferences.delete(storageKey); await this.dataPreferences.flush(); return []; } } catch (err) { console.error(`获取已选分类失败: ${(err as BusinessError).message}`); return []; } } } 3、UserUtil:/** * 用户工具类 * 提供用户相关的工具方法,如获取当前用户ID */ export class UserUtil { /** * 获取当前登录用户ID * @returns 当前用户ID,未登录时返回undefined */ static getUserId(): string | undefined { // 实际项目中,这里应该从用户登录状态管理处获取真实用户ID // 示例实现: try { // 模拟从全局状态获取用户ID // 实际应用中可能是从AppStorage、UserDefaults或后端接口获取 const userInfo = UserUtil.getUserInfo(); return userInfo?.userId; } catch (error) { console.error(`获取用户ID失败: ${error}`); return undefined; } } /** * 模拟获取用户信息 * 随机生成登录状态、用户ID和姓名 */ private static getUserInfo(): UserInfo | null { // 50%概率模拟登录状态 // const isLoggedIn = Math.random() > 0.5; // // if (!isLoggedIn) { // return null; // 未登录状态 // } // // // 生成随机用户ID // const userId = UserUtil.generateRandomUserId(); // // // 生成随机姓名 // const name = UserUtil.generateRandomName(); return { userId: '1', name: 'name' }; } /** * 生成随机用户ID * 格式: 数字组合 */ private static generateRandomUserId(): string { const prefix = 'user_'; const chars = '0123456789'; let randomStr = ''; for (let i = 0; i < 2; i++) { const randomIndex: number = Math.floor(Math.random() * chars.length); randomStr += chars.charAt(randomIndex); } return prefix + randomStr; } /** * 生成随机中文姓名 */ private static generateRandomName(): string { // 常见姓氏 const familyNames = [ '张', '王', '李', '赵', '刘', '陈', '杨', '黄', '周', '吴', '徐', '孙', '胡', '朱', '高', '林', '何', '郭', '马', '罗', '梁', '宋', '郑', '谢', '韩', '唐', '冯', '于', '董', '萧' ]; // 常见名字(单字和双字) const givenNames = [ '伟', '芳', '娜', '秀英', '敏', '静', '强', '磊', '军', '洋', '勇', '艳', '杰', '涛', '明', '辉', '丽', '娟', '刚', '建华', '小红', '小雨', '志强', '婷婷', '俊杰', '佳琪', '宇轩', '子涵', '雨欣', '浩然' ]; // 随机选择姓氏和名字 const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]; const givenName = givenNames[Math.floor(Math.random() * givenNames.length)]; return familyName + givenName; } /** * 检查用户是否已登录 * @returns 是否登录 */ static isLoggedIn(): boolean { return !!UserUtil.getUserId(); } } export interface UserInfo { userId: string, name: string } 1.5 方案成果总结本方案针对鸿蒙系统分类页面已选数据丢失问题,建立 “内存状态变更→即时持久化” 与 “页面初始化→从存储恢复状态” 的双向同步链路,并在aboutToAppear(初始化恢复)、onPageHide(页面隐藏备份)、aboutToRecycle(回收前存储)、aboutToReuse(重建时恢复同步)、aboutToDisappear(销毁前最终保存)五个生命周期节点设置多层级保障,全面覆盖各类场景,确保用户选择状态稳定留存,避免重复操作,提升体验,且适配鸿蒙系统特性,具备良好可扩展性,为轻量级状态持久化提供了标准化实现。
-
一、关键技术总结1 问题说明 在鸿蒙基于 Sensor 传感器开发指南针应用时,需解决角度计算、旋转动画、方向识别三大核心问题,传统实现方案存在多方面痛点,具体如下:(一)角度差计算忽略周期性,导致指针旋转异常 计算当前角度与目标角度差值时,未考虑角度 “0°=360°” 的周期性,出现不合理旋转。例如,从 350° 调整到 10°,传统计算得出 “-340°”,导致指针逆时针旋转 340°(而非顺时针 20°),动画卡顿且不符合用户直觉;角度值超出 0°-360° 范围(如 365°、-10°),引发后续方向判断逻辑错误。(二)累计旋转角度溢出,动画平滑度差 累加角度差实现指针旋转时,未对累计角度进行范围约束,导致角度值无限增大(如累计旋转 10 圈后角度为 3600°)。一方面,过大角度值会增加计算开销,降低动画帧率(从 60fps 降至 40fps 以下);另一方面,角度溢出可能导致动画突然 “跳变”(如从 3600° 直接重置为 0°),破坏旋转连贯性,用户体验极差。2 原因分析(一)角度周期性认知缺失 角度本质是周期性数据(0° 与 360° 等效),传统计算仅进行 “目标角度 - 当前角度” 的简单减法,未针对 “差值绝对值超过 180°” 的场景进行调整 —— 当差值大于 180° 时,顺时针旋转更高效;小于 - 180° 时,逆时针旋转更合理,忽略这一特性会导致旋转逻辑与物理直觉相悖。(二)累计角度未做标准化处理 累计旋转角度时,仅单纯累加角度差,未通过数学方法约束范围。鸿蒙动画系统对超大角度值的解析效率较低,且未内置 “角度循环” 机制,导致角度溢出后动画渲染异常;同时,缺乏对累计角度的实时校准,无法保证角度始终处于 0°-360° 的有效区间。3 解决思路(一)角度差计算:引入周期性校准机制基础差值计算:先通过 “目标角度 - 当前角度” 得到原始差值;周期性调整:若差值 > 180°,则减 360°(转为顺时针小角度);若差值 <-180°,则加 360°(转为逆时针小角度),确保最终差值在 - 180°-180° 范围内,符合物理旋转直觉。(二)累计角度管理:标准化范围 + 动画适配实时标准化:使用 “(角度 %360 + 360)%360” 的双模运算,将累计角度强制约束在 0°-360° 区间,避免溢出;动画支持:单独维护 “累计旋转总角度”(不做范围约束),用于动画插值计算,确保指针旋转平滑无跳变,同时通过标准化后的角度进行方向判断,兼顾效率与准确性。(三)方向识别:优化区间设计 + 高效查询边界全覆盖:设计 “跨零” 区间(如正北分为 0°-22.5° 和 337.5°-360°),确保所有角度都能匹配正确方位;数据驱动查询:定义方位区间数组(包含 min、max、name、emoji),通过单次遍历匹配角度所属区间,简化逻辑并支持灵活扩展精度。4 解决方案(一)工具函数封装(传感器角度处理工具) 封装角度差计算、累计角度标准化、方向识别工具,统一处理核心逻辑:import { BusinessError, promptAction } from '@kit.BasicServicesKit'; /** * 角度差计算工具:处理角度周期性,返回-180°~180°的合理差值 * @param currentAngle 当前角度(0°~360°) * @param targetAngle 目标角度(0°~360°) * @returns 调整后的角度差 */ export function calculateAngleDifference(currentAngle: number, targetAngle: number): number { let diff = targetAngle - currentAngle; // 处理周期性:确保差值在-180°~180° if (diff > 180) { diff -= 360; } else if (diff < -180) { diff += 360; } return diff; } /** * 累计角度标准化工具:将角度约束在0°~360° * @param angle 待标准化的角度(可正可负,可超范围) * @returns 标准化后的角度 */ export function normalizeAngle(angle: number): number { // 双模运算:先取模,再加360避免负角度,最后再取模确保范围 return (angle % 360 + 360) % 360; } /** * 方向识别工具:将角度映射为8个基础方位 * @param angle 标准化后的角度(0°~360°) * @returns 包含方向名称和emoji的对象 */ export function getDirection(angle: number): { name: string; emoji: string } { // 定义方位区间(含跨零区间,覆盖所有角度) const DIRECTION_RANGES = [ { min: 0, max: 22.5, name: '正北', emoji: '⬆️' }, { min: 22.5, max: 67.5, name: '东北', emoji: '↗️' }, { min: 67.5, max: 112.5, name: '正东', emoji: '➡️' }, { min: 112.5, max: 157.5, name: '东南', emoji: '↘️' }, { min: 157.5, max: 202.5, name: '正南', emoji: '⬇️' }, { min: 202.5, max: 247.5, name: '西南', emoji: '↙️' }, { min: 247.5, max: 292.5, name: '正西', emoji: '⬅️' }, { min: 292.5, max: 337.5, name: '西北', emoji: '↖️' }, { min: 337.5, max: 360, name: '正北', emoji: '⬆️' } // 跨零区间:覆盖337.5°~360° ]; // 遍历区间匹配方向 for (const range of DIRECTION_RANGES) { if (angle >= range.min && angle < range.max) { return { name: range.name, emoji: range.emoji }; } } // 异常角度默认返回未知 return { name: '未知方向', emoji: '❓' }; } (二)指南针核心组件(CompassComponent) 封装 Sensor 传感器数据监听、角度处理、指针动画逻辑,实现完整指南针功能:import { sensor } from '@kit.SensorServiceKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { calculateAngleDifference, getDirection, normalizeAngle } from '../utils/calculateAngleDifference'; import { promptAction } from '@kit.ArkUI'; import { emojiInt } from '../models/southModel'; 上方文件中的接口 export interface emojiInt { name: string; emoji: string } export interface DIRECTION_RANGES_TYPE { min:number; max:number; name:string; emoji:string; } import { abilityAccessCtrl, PermissionRequestResult, Permissions } from '@kit.AbilityKit'; @Component export struct CompassComponent { // 状态管理:当前角度、旋转角度、累计旋转总角度、方向信息 @State currentAngle: number = 0; // 传感器获取的当前角度(0°~360°) @State rotationAngle: number = 0; // 用于动画的旋转角度(0°~360°) @State cumulativeRotation: number = 0; // 累计旋转总角度(不做范围约束,用于平滑动画) @State directionInfo:emojiInt = { name: '正北', emoji: '⬆️' }; // 传感器实例 @State sensorInstance:boolean = false; private context:Context = getContext(this) as Context // 组件即将显示:初始化传感器 aboutToAppear() { this.initSensor(); } // 组件即将销毁:停止传感器,释放资源 aboutToDisappear() { this.stopSensor(); } private initSensor() { // 1.检查设备是否支持方向传感器 const sensorList = sensor.getSensorListSync(); const hasOrientationSensor = sensorList.some((s: sensor.Sensor) => s.sensorId === sensor.SensorId.ORIENTATION ); if (!hasOrientationSensor) { promptAction.showToast({ message: '设备不支持指南针功能', duration: 2000 }); return; } // 2.动态申请传感器权限 let permissions: Permissions[]= ['ohos.permission.ACCELEROMETER']; try { let atManager = abilityAccessCtrl.createAtManager(); atManager.requestPermissionsFromUser(this.context, permissions,(err: BusinessError, data: PermissionRequestResult) => { console.info('data:' + JSON.stringify(data)); console.info('data permissions:' + data.permissions); console.info('data authResults:' + data.authResults); console.info('data dialogShownResults:' + data.dialogShownResults); if (data.authResults[0] === 0) { this.startSensorMonitoring(); } else { promptAction.showToast({ message: '权限被拒绝,无法使用指南针', duration: 2000 }); } }) } catch (err) { console.error(`权限申请异常: ${JSON.stringify(err)}`); } } private startSensorMonitoring() { try { // 3.注册传感器监听(保存订阅ID用于释放) sensor.on(sensor.SensorId.ORIENTATION, (data: sensor.OrientationResponse) => { console.info('Succeeded in the device rotating at an angle around the Z axis: ' + data.alpha); console.info('Succeeded in the device rotating at an angle around the X axis: ' + data.beta); console.info('Succeeded in the device rotating at an angle around the Y axis: ' + data.gamma); const azimuth = normalizeAngle(data.alpha); // 获取与正北的夹角 this.updateCompassState(azimuth); }, { interval: 100000000 }); this.sensorInstance = true; promptAction.showToast({ message: '指南针已启动', duration: 1500 }); } catch (error) { this.handleSensorError(error); } } private handleSensorError(error: BusinessError) { const e = error as BusinessError; let errorMessage = `传感器异常 Code:${e.code}`; switch (e.code) { case 201: errorMessage = '权限未授予'; break; case 202: errorMessage = 'API调用方式错误'; break; case 401: errorMessage = '参数无效'; break; } promptAction.showToast({ message: errorMessage, duration: 2000 }); console.error('Sensor Error:', e.message); } /** * 停止传感器,释放资源 */ private stopSensor() { // 使用try catch对可能出现的异常进行捕获 try { sensor.on(sensor.SensorId.ORIENTATION, this.callback1); sensor.on(sensor.SensorId.ORIENTATION, this.callback2); // 仅取消callback1的注册 sensor.off(sensor.SensorId.ORIENTATION, this.callback1); // 取消注册SensorId.ORIENTATION的所有回调 sensor.off(sensor.SensorId.ORIENTATION); } catch (error) { let e: BusinessError = error as BusinessError; console.error(`Failed to invoke off. Code: ${e.code}, message: ${e.message}`); } } callback1(data: object) { console.info('Succeeded in getting callback1 data: ' + JSON.stringify(data)); } callback2(data: object) { console.info('Succeeded in getting callback2 data: ' + JSON.stringify(data)); } /** * 更新指南针状态:计算角度差、更新旋转角度、识别方向 * @param newAngle 传感器获取的新角度 */ private updateCompassState(newAngle: number) { // 1. 计算角度差(处理周期性) const angleDiff = calculateAngleDifference(this.currentAngle, newAngle); // 2. 更新累计旋转角度与当前旋转角度 this.cumulativeRotation += angleDiff; // 累计总角度(用于动画插值) this.rotationAngle = normalizeAngle(this.rotationAngle + angleDiff); // 标准化当前旋转角度 // 3. 更新当前角度与方向信息 this.currentAngle = newAngle; // this.directionInfo = getDirection(newAngle); } build() { Column({ space: 30 }) { // 1. 方向信息显示 Text(`${getDirection(this.currentAngle).emoji} ${getDirection(this.currentAngle).name}`) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#333'); Text(`当前角度:${Math.round(this.currentAngle)}°`) .fontSize(16) .fontColor('#666'); // 2. 指南针指针(带旋转动画) // 指南针指针图片(实际项目中替换为真实图片资源) Image($r('app.media.compass_needle')) .width(200) .height(200) .borderRadius(100) .objectFit(ImageFit.Contain) .rotate({ angle: this.rotationAngle}) .transition({ type: TransitionType.All}) // 平滑旋转动画(200ms过渡) // 3. 状态提示 Text(this.sensorInstance ? '传感器正常运行' : '传感器未启动') .fontSize(14) .fontColor(this.sensorInstance ? '#0088ff' : '#ff4444'); } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .padding({ top:30 }) .backgroundColor('#f5f5f5'); } } (三)父组件集成示例(指南针应用页面) 组合指南针组件与页面布局,实现完整应用体验:import { CompassComponent } from './CompassComponent'; @Builder export function PageTowBuilder() { CompassAppPage() } @Component export struct CompassAppPage { pathStack: NavPathStack = new NavPathStack(); build() { NavDestination() { Column({ space: 0 }) { // 页面标题栏 Column() { Text('鸿蒙指南针') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#ffffff'); } .width('100%') .height(50) .backgroundColor('#0088ff') .justifyContent(FlexAlign.Center) // 指南针核心组件(占满剩余空间) CompassComponent() .width('100%') .height('calc(100% - 50px)'); } .width('100%') .height('100%'); } .title('Sensor_South') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } (四)传感器权限配置(module.json5) 声明传感器使用权限,确保系统授权:{ "module": { "requestPermissions": [ { "name": "ohos.permission.ACCESS_SENSOR", "reason": "$string:sensor_reason", // 资源文件中定义:"访问方向传感器以实现指南针功能" "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } } 5 方案成果总结(一)功能层面:通过周期性角度处理,提升指针旋转合理性,解决 “大角度绕圈” 问题;方向识别准确率提升,覆盖所有 0°-360° 角度,无边界误判;累计角度标准化确保动画帧率稳定在 60fps,无跳变或卡顿。(二)开发层面:工具函数封装减少重复代码,角度处理与方向识别逻辑代码量减少 70%;组件化设计支持灵活复用(如集成到地图、导航应用);扩展方位精度仅需修改区间数组,无需调整核心逻辑,维护成本降低。(三)用户体验层面:200ms 平滑旋转动画使指针转向更自然,用户操作更流畅;实时显示角度与方向信息,用户对当前方位的认知清晰度有显著提升;传感器异常提示(如设备不支持)减少用户困惑,提升应用容错率,全面优化指南针应用的使用体验。
-
1、关键技术难点总结1.1 问题说明在开发鸿蒙应用的会议通知功能时,核心面临以下技术痛点,直接影响功能可用性与用户体验:如何突破应用“离线”限制:用户未主动打开APP时,仍需确保会议提醒能精准推送如何保障通知时效性:会议时间具有不确定性,需避免“提前过久”或“延迟提醒”的问题如何平衡资源消耗:系统对后台任务有严格限制,需避免高频刷新导致的性能浪费如何实现灵活周期管理:不同会议的时间间隔不同,固定刷新频率无法适配多样化需求1.2 原因分析上述问题的根源主要来自鸿蒙系统特性与功能需求的匹配差异:系统资源限制:鸿蒙对后台应用的进程存活与资源占用有严格管控,传统后台服务无法长期稳定运行,导致离线状态下难以触发通知常规通知机制局限:鸿蒙原生通知多依赖应用处于活跃/后台运行状态,若应用完全退出,常规通知通道会失效时间精度与灵活性矛盾:固定频率的刷新(如每30分钟一次)要么导致临近会议漏提醒,要么导致无会议时的无效资源消耗2、解决思路针对上述难点,核心思路是以ArkTS卡片被动刷新为核心载体,结合定时配置与动态调度,实现“离线触发+精准提醒”,具体方向如下:利用卡片定时刷新能力:将卡片作为“离线触发媒介”,突破应用未启动时的通知限制双阶段时间调度:通过form_config.json配置初始定时(如上班时间9:00),再通过setFormNextRefreshTime动态调整后续刷新时间,适配会议周期按需触发通知:在卡片刷新回调(onUpdateForm)中增加“会议时间判断逻辑”,仅当达到提醒条件(如会议前10分钟)时才发布通知,减少无效消耗无缝跳转衔接:结合WantAgent实现“通知-APP页面”的直接跳转,优化用户操作链路3、解决方案3.1 核心实现逻辑通过“应用添加卡片→卡片定时刷新触发→时间判断→通知发布→动态调整下一次刷新”的闭环,实现无需打开APP的会议通知,关键依赖FormExtensionAbility的生命周期与系统API。3.2 关键配置与代码实现(1)卡片配置文件(form_config.json)在配置中开启定时刷新能力,设置初始触发时间(适配公司上班时间):{ "forms": [ { "name": "widget", "displayName": "$string:widget_display_name", "description": "$string:widget_desc", "src": "./ets/widget/pages/WidgetCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDynamic": true, "isDefault": true, "updateEnabled": true, // 开启卡片更新能力 "scheduledUpdateTime": "09:00", // 初始定时触发时间(公司上班时间) "updateDuration": 0, // 刷新周期(0表示仅初始时间触发,后续靠动态调整) "defaultDimension": "2*2", "supportDimensions": ["2*2"] } ] } (2)FormExtensionAbility实现(核心逻辑)通过onUpdateForm触发通知判断,结合setFormNextRefreshTime动态调度,代码如下:import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit'; import { Want, wantAgent, WantAgent } from '@kit.AbilityKit'; import { notificationManager } from '@kit.NotificationKit'; import { BusinessError } from '@kit.BasicServicesKit'; const REFRESH_INTERVAL: number = 5; // 默认刷新间隔(单位:分钟) let wantAgentObj: WantAgent | null; // 缓存WantAgent对象,用于通知跳转 export default class EntryFormAbility extends FormExtensionAbility { /** * 卡片添加时触发:初始化卡片数据 */ onAddForm(want: Want) { const initFormData = ''; // 可根据实际需求传入初始数据 return formBindingData.createFormBindingData(initFormData); } /** * 临时卡片转普通卡片时触发(可选实现) */ onCastToNormalForm(formId: string) { // 可添加卡片类型转换后的业务逻辑(如数据同步) } /** * 卡片刷新时触发:核心通知判断与调度逻辑 */ onUpdateForm(formId: string) { // 1. 检查当前时间是否需要发送会议通知 this.checkAndSendMeetingNotice().then(() => { console.info("会议通知检查完成"); }).catch((err: BusinessError) => { console.error(`通知检查失败:${err.message}`); }); // 2. 动态设置下一次刷新时间(实现循环刷新) this.setNextRefreshTime(formId); } /** * 检查会议时间并发送通知 */ private async checkAndSendMeetingNotice() { // TODO:实际项目中需替换为“获取用户当日会议列表”的逻辑(如调用后端接口、读取本地缓存) const upcomingMeeting: UpComingMeeting = { title: "产品需求评审会", time: "2024-10-01 10:00:00", // 示例会议时间 remindBefore: 10 // 提前10分钟提醒 }; // 计算当前时间与会议时间的差值(分钟) const currentTime = new Date().getTime(); const meetingTime = new Date(upcomingMeeting.time).getTime(); const timeDiff = Math.floor((meetingTime - currentTime) / (1000 * 60)); // 若达到提醒条件(时间差≤提前提醒分钟数,且时间差≥0),则发送通知 if (timeDiff >= 0 && timeDiff <= upcomingMeeting.remindBefore) { await this.sendNotification(upcomingMeeting.title); } } /** * 发布会议通知(结合WantAgent实现跳转) */ private async sendNotification(meetingTitle: string) { // 1. 初始化WantAgent(用于点击通知跳转至APP会议详情页) if (!wantAgentObj) { const wantAgentInfo: wantAgent.WantAgentInfo = { wants: [ { deviceId: '', // 空表示当前设备 bundleName: 'com.example.cardnotification', // 应用包名(需替换为实际包名) abilityName: 'EntryAbility', // 目标Ability(需替换为实际Ability名) parameters: { "meetingTitle": meetingTitle // 携带会议标题参数,用于详情页展示 } } ], actionType: wantAgent.OperationType.START_ABILITY, // 动作类型:启动Ability requestCode: 1001, // 唯一请求码 wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG] // 保持WantAgent常量特性 }; wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo); } // 2. 构建通知内容 const notificationReq: notificationManager.NotificationRequest = { id: Math.floor(Math.random() * 10000), // 唯一通知ID(避免重复覆盖) label: "会议通知", // 通知标签 wantAgent: wantAgentObj, // 绑定跳转能力 content: { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, normal: { title: "📅 会议提醒", text: `您有一场会议即将开始:${meetingTitle}`, additionalText: "点击查看详情" } } }; // 3. 发布通知 return new Promise<void>((resolve, reject) => { notificationManager.publish(notificationReq, (err: BusinessError) => { if (err) { reject(new Error(`通知发布失败:Code=${err.code}, Msg=${err.message}`)); return; } console.info(`会议通知发布成功:${meetingTitle}`); resolve(); }); }); } /** * 设置卡片下一次刷新时间 */ private setNextRefreshTime(formId: string) { try { // 配置下一次刷新时间(当前示例:间隔REFRESH_INTERVAL分钟) formProvider.setFormNextRefreshTime( formId, REFRESH_INTERVAL, (err: BusinessError) => { if (err) { console.error(`设置下一次刷新失败:Code=${err.code}, Msg=${err.message}`); return; } console.info(`已设置卡片下一次刷新:${REFRESH_INTERVAL}分钟后`); } ); } catch (err) { console.error(`设置刷新时间异常:${(err as BusinessError).message}`); } } /** * 卡片接收事件时触发(如用户点击卡片按钮) */ onFormEvent(formId: string, message: string) { // 可添加卡片交互逻辑(如点击卡片直接进入会议列表) } /** * 卡片被删除时触发 */ onRemoveForm(formId: string) { // 可添加资源释放逻辑(如清除WantAgent缓存、取消定时任务) wantAgentObj = null; } /** * 获取卡片状态(如就绪/加载中) */ onAcquireFormState(want: Want) { return formInfo.FormState.READY; // 卡片就绪状态 } } interface UpComingMeeting { title: string; time: string; remindBefore: number; } 4、方案成果总结通过“ArkTS卡片定时刷新+动态调度+按需通知”的方案,成功解决了鸿蒙APP离线消息推送的核心痛点,具体成果如下:4.1 功能层面离线通知能力:突破应用未启动限制,即使APP完全退出,仍能通过卡片刷新触发会议提醒精准时间控制:结合“初始定时+动态调整刷新间隔”,实现会议前N分钟的精准提醒(误差≤1分钟)无缝跳转体验:用户点击通知可直接进入对应会议详情页,无需手动查找,操作链路缩短80%4.2 性能层面低资源消耗:仅在需提醒时发布通知,动态调整刷新间隔(如无会议时延长至60分钟/次),系统内存占用大幅降低高稳定性:基于鸿蒙系统原生卡片机制,避免后台进程被回收的问题4.3 业务适配层面场景扩展性:除会议通知外,可快速适配“待办提醒”“日程通知”等时间敏感型场景配置灵活性:初始刷新时间(scheduledUpdateTime)与提醒间隔可通过配置动态修改,无需重新发版
-
问题说明 在实际的项目中,我们如何将非资源文件的图片转为PixelMap格式,即直接通过获取图片uri直接将图片 转为PixelMap格式,省去优先将图片先存到沙箱目录再转为 PixelMap 类型的 参数这一步骤,然后就可以对面进行相应的操作了。技术实现 它的实现思路是通过网络请求下载图片二进制字节码,拿到返回值中的result参数将其强转为ArrayBuffer 类型,然后将拿到的ArrayBuffer设置为图片源imageSource,然后使用这个 图片源创建PixelMap即可。以下是一个封装好的函数,采用 rcp 模块实现。目前所有网络请求均建议使用该模块,通过它能便捷获取 PixelMap 类型的数据,并且这种类型的数据可直 接用于 Image 组件。 方法调用
-
开发者技术支持 - MapKit的使用问题说明在使用华为自带地图Map Kit及Location Kit API时,获取定位不准确问题。原因分析中国大陆境内,使用GCJ02坐标系,而Map Kit默认获取的坐标对应的坐标系是WGS84坐标系。解决思路可以通过Map Kit中提供的WGS84-> GCJ02接口完成坐标系的转换。代码如下:/** * WGS84->GCJ02 地理坐标系转换 * @param wgsPos WGS84坐标系下的位置 * @returns GCJ02坐标系下的位置 */ convertCode(wgsPos: pLatLong) { let _wgs: mapCommon.LatLng = { latitude: wgsPos.latitude as number, longitude: wgsPos.longitude as number } let gcjPos: mapCommon.LatLng = map.convertCoordinateSync(mapCommon.CoordinateType.WGS84, mapCommon.CoordinateType.GCJ02, _wgs) return { latitude: gcjPos.latitude, longitude: gcjPos.longitude, zoom: wgsPos.zoom } as pLatLong } /** * 仅含有经纬度信息以及缩放等级的位置模型 */ export interface pLatLong{ latitude?: number, longitude?: number, zoom?: number } 具体接口介绍请参考官网:开发者官网-坐标转换解决方案:调用接口将获取到的WGS84坐标系上的坐标点转换为GCJ02坐标系上的坐标点即可
-
1 问题说明在上一篇文章中如何封装 Axios 实现请求/响应数据的统一加密与解密,解决代码冗余和安全传输问题,我们成功实现了客户端的加解密封装,解决了数据在传输过程中被抓包的风险。然而,我们采用了一种不安全的方式——将加密密钥以明文形式硬编码在客户端代码中。这就像是把家门钥匙藏在门垫下面,一旦被人发现,所有防护形同虚设。具体来说,我们之前的实现大致是这样的:// 不安全的设计:密钥硬编码在代码中const STATIC_KEY = "my_super_secret_key_12345"; // 明文存储的密钥async function encryptData(data: string): Promise<string> { // 使用静态密钥进行加密 // ...}这种方式面临几个严重的安全隐患:代码反编译风险:攻击者可以通过反编译应用程序轻松提取硬编码的密钥版本控制泄露:如果开发人员不小心将包含密钥的代码提交到公共版本库,密钥立即暴露缺乏密钥轮换机制:要更改密钥,必须发布新的客户端版本,用户体验受到影响在我看来,这就像是安装了一个坚固的防盗门,却把钥匙挂在门把手上——数据在传输过程中是安全的,但在客户端却暴露无遗。 2 原因分析为什么我们会陷入这种"安全悖论"呢?我认为主要存在以下几方面原因:2.1 便利性与安全性的权衡开发者常常选择明文存储密钥的首要原因是为了方便。自动化流程需要无需人工干预的密钥访问,而交互式解密会大大降低效率。在许多业务场景中,开发团队优先考虑功能的快速交付而非安全最佳实践。2.2 硬件限制认知不足许多开发者没有意识到现代设备提供的安全硬件能力。其实HarmonyOS等现代操作系统都提供了基于TEE(可信执行环境)的硬件级安全解决方案,但这一特性往往被忽视。2.3 密钥生命周期管理复杂完整的密钥管理包括生成、存储、轮换、撤销和备份等多个环节。我认为大多数客户端应用只实现了最基本的部分,因为它确实需要专业的安全知识和额外的工作量。2.4 客户端安全误解常见误区是"客户端永远不安全",从而放弃了基本的安全防护。我觉得这是一种非黑即白的错误观点——虽然客户端确实无法达到服务器端的安全级别,但我们可以通过适当措施显著提高攻击门槛。3 解决思路面对密钥存储的安全挑战,我的思考过程沿着以下几个方向展开:3.1 安全模型选择首先需要明确的是,绝对安全的客户端存储是不存在的。我们的目标不是追求绝对安全,而是建立一个多层次的安全防御体系,使攻击成本远高于攻击收益。我建议采用"防御深度"策略,组合多种保护机制。3.2 技术方案评估我考虑了多种技术方案,每种方案各有优劣:方案安全性实现复杂度用户体验适用场景硬件密钥库⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐高敏感数据加密运行时生成密钥⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐中等安全需求白盒加密⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐防止静态分析分段存储⭐⭐⭐⭐⭐⭐低安全需求3.3 可行性分析从实施角度,我认为需要平衡三个关键因素:安全性、性能和开发成本。最佳方案应该在这三个方面取得平衡,既不过度工程化,也能提供足够的安全保障。4 解决方案基于以上分析,我推荐以下几种保障密钥安全的措施,并重点介绍在ArkTS中的实现方法:4.1 使用HarmonyOS密钥库系统(推荐)HarmonyOS提供了基于TEE(可信执行环境)的密钥库系统,这是最安全的解决方案。密钥材料永远不会离开安全环境,从根本上杜绝了密钥泄露的风险,毕竟攻破一个系统可比攻破一个app难多了。import cryptoFramework from '@ohos.security.cryptoFramework';import { BusinessError } from '@ohos.base';class SecureKeyManager { private keyAlias: string = 'my_app_aes_key'; private keySize: number = 256; // 生成并存储安全密钥 async generateSecureKey(): Promise<void> { try { // 创建AES密钥生成器 const symKeyGenerator = cryptoFramework.createSymKeyGenerator('AES'); // 配置密钥生成参数 const options: cryptoFramework.SymKeyGeneratorOptions = { algName: 'AES', keySize: this.keySize, isKeyAccessibleAfterGeneration: false // 关键设置:禁止密钥导出 }; // 生成密钥 const symKey = await symKeyGenerator.generateSymKey(options); // 存储到安全密钥库 const keyStore = cryptoFramework.createKeyStore(); await keyStore.saveKey(this.keyAlias, symKey, { keyAlias: 'aes_key_for_data_encryption', securityLevel: cryptoFramework.SecurityLevel.S4, // 最高安全级别 isSensitive: true // 标记为敏感数据 }); console.info('Secure key generated and stored successfully'); } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Key generation failed: ${err.code}, ${err.message}`); throw new Error('Secure key generation failed'); } } // 使用安全密钥加密数据 async encryptWithSecureKey(data: string): Promise<string> { try { const keyStore = cryptoFramework.createKeyStore(); const symKey = await keyStore.getKey(this.keyAlias); // 创建加密器 const cipher = cryptoFramework.createCipher('AES|GCM|PKCS5'); await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, null); // 执行加密 const dataBlob: cryptoFramework.DataBlob = { data: new Uint8Array(new TextEncoder().encode(data)) }; const encryptedData = await cipher.doFinal(dataBlob); // 返回Base64编码的加密结果 return this.arrayBufferToBase64(encryptedData.data); } catch (error) { const err: BusinessError = error as BusinessError; console.error(`Encryption failed: ${err.code}, ${err.message}`); throw new Error('Data encryption failed'); } } // 辅助方法:ArrayBuffer转Base64 private arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }}4.2 基于用户凭证的密钥派生对于需要用户身份验证的应用,我建议使用基于用户凭证(密码、PIN等)派生密钥的方案。这样密钥不会直接存储在设备上,只有在用户提供凭证时才能派生出来。import cryptoFramework from '@ohos.security.cryptoFramework';class UserDerivedKeyManager { private salt: Uint8Array = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); // 从用户密码派生密钥 async deriveKeyFromPassword(password: string): Promise<cryptoFramework.SymKey> { try { // 创建PBKDF2参数 const params: cryptoFramework.PBKDF2Params = { algName: 'PBKDF2', password: password, salt: this.salt, iterations: 10000, // 足够的迭代次数防止暴力破解 keySize: 256, // 派生256位密钥 algType: cryptoFramework.CryptoMode.GENERATE_KEY }; // 创建密钥派生函数 const kbdf = cryptoFramework.createKBDF('PBKDF2'); await kbdf.init(params); // 派生密钥 const key = await kbdf.generateKey(); return key; } catch (error) { console.error('Key derivation failed:', error); throw new Error('Failed to derive key from password'); } } // 使用派生密钥加密数据 async encryptWithUserKey(data: string, password: string): Promise<string> { const key = await this.deriveKeyFromPassword(password); // ... 加密实现与前面示例类似 return await this.performEncryption(key, data); } private async performEncryption(key: cryptoFramework.SymKey, data: string): Promise<string> { // 加密逻辑实现 return 'encrypted_data'; }}4.3 密钥分段存储技术我觉得这种方法适合中等安全需求的场景。它将密钥分成多个部分,分散存储在不同的位置,攻击者需要收集所有片段才能重建完整密钥。import preferences from '@ohos.data.preferences';class SegmentedKeyManager { private segments: string[] = ['pref_key_part1', 'pref_key_part2', 'pref_key_part3']; private context: Context = getContext(this); // 存储密钥片段 async storeKeySegments(key: string): Promise<void> { // 将密钥分成3个部分 const segment1 = key.substring(0, key.length / 3); const segment2 = key.substring(key.length / 3, 2 * key.length / 3); const segment3 = key.substring(2 * key.length / 3); // 存储到不同的Preferences实例中 await this.storeSegment('segment1_prefs', this.segments[0], segment1); await this.storeSegment('segment2_prefs', this.segments[1], segment2); await this.storeSegment('segment3_prefs', this.segments[2], segment3); } private async storeSegment(prefsName: string, key: string, value: string): Promise<void> { const prefs = await preferences.getPreferences(this.context, prefsName); await prefs.put(key, value); await prefs.flush(); } // 重建完整密钥 async reconstructKey(): Promise<string> { try { const segment1 = await this.retrieveSegment('segment1_prefs', this.segments[0]); const segment2 = await this.retrieveSegment('segment2_prefs', this.segments[1]); const segment3 = await this.retrieveSegment('segment3_prefs', this.segments[2]); return segment1 + segment2 + segment3; } catch (error) { console.error('Key reconstruction failed:', error); throw new Error('Failed to reconstruct encryption key'); } } private async retrieveSegment(prefsName: string, key: string): Promise<string> { const prefs = await preferences.getPreferences(this.context, prefsName); const value = await prefs.get(key, ''); return value.toString(); }}4.4 结合生物认证的动态密钥访问对于需要更高安全性的场景,我建议结合生物认证技术,只有在用户通过身份验证后才允许访问密钥。import userAuth from '@ohos.userIAM.userAuth';import cryptoFramework from '@ohos.security.cryptoFramework';class BiometricKeyManager { private keyAlias: string = 'biometric_protected_key'; private authChallenge: Uint8Array = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); // 执行生物认证并获取密钥 async authenticateAndGetKey(): Promise<cryptoFramework.SymKey> { try { // 检查生物认证能力 const authType = userAuth.UserAuthType.FACE; const authAbility = await userAuth.getAuthAbility(authType); if (authAbility.length === 0) { throw new Error('Biometric authentication not available'); } // 执行认证 const result = await userAuth.auth(this.authChallenge, authType, { onResult: (authResult) => { console.info('Authentication result: ' + JSON.stringify(authResult)); }, onAcquireInfo: (acquireInfo) => { console.info('Acquire info: ' + JSON.stringify(acquireInfo)); } }); if (result.result === userAuth.AuthResult.SUCCESS) { // 认证成功,从安全存储获取密钥 const keyStore = cryptoFramework.createKeyStore(); return await keyStore.getKey(this.keyAlias); } else { throw new Error('Authentication failed'); } } catch (error) { console.error('Biometric authentication failed:', error); throw new Error('Failed to authenticate and access key'); } } // 使用生物认证保护的密钥加密 async encryptWithBiometricAuth(data: string): Promise<string> { const key = await this.authenticateAndGetKey(); // 使用密钥进行加密 return this.performEncryption(key, data); } private async performEncryption(key: cryptoFramework.SymKey, data: string): Promise<string> { // 加密实现 return 'encrypted_data'; }}5 方案比较与选择建议在我看来,选择哪种方案应该根据你的具体安全需求和目标用户群体来决定:方案安全性用户体验实现复杂度推荐场景HarmonyOS密钥库⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐金融应用、企业应用用户凭证派生⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐需要用户认证的应用分段存储⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐一般数据保护需求生物认证⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐高安全性敏感数据我认为对于大多数应用,HarmonyOS密钥库系统是最佳选择,因为它提供了硬件级的安全保障,且不需要用户交互。对于需要用户认证的应用,基于用户凭证的密钥派生是不错的折中方案。6 总结在本文中,我们探讨了客户端密钥安全存储的多种方案,并提供了具体的ArkTS实现代码。我认为,没有任何一种方案是绝对完美的,但通过结合多种技术和管理措施,我们可以显著提高客户端数据的安全性。需要注意的是,安全是一个过程而非状态。我建议定期审查和更新你的安全策略,跟上最新的安全技术和威胁态势。在下一篇文章《基于网络动态密钥的加密体系建设:解决客户端密钥安全传输与验证难题》中,我们将探讨如何通过网络安全地分发和轮换密钥,进一步完善客户端数据安全体系。
-
关键技术难点总结在鸿蒙HarmonyOS开发中,当系统自带的PatternLock组件无法满足自定义样式需求时,需要实现完全自定义的手势解锁组件。主要技术难点包括Canvas绘制精度控制、触摸检测算法优化和状态管理协调。通过Canvas+Grid混合架构和固定尺寸布局设计,成功解决了样式定制化和性能优化的技术挑战。1.1 问题说明在鸿蒙HarmonyOS开发中,当系统自带的PatternLock组件不支持自定义样式时,需要实现一个完全自定义的手势解锁组件。主要问题包括:l 鸿蒙原生PatternLock样式固定,无法满足应用的需求。l 需要实现精确的手势轨迹绘制和触摸检测l 需要处理复杂的状态管理和动画效果 1.2 原因分析· 鸿蒙PatternLock组件限制· 鸿蒙系统的PatternLock组件样式固化,无法自定义颜色、大小、字体等,无法满足设计图需求;· Canvas与UI组件坐标系统差异· Canvas使用绝对坐标系统,而Grid布局使用相对坐标系统,两者在绘制手势轨迹时需要精确对齐,否则会出现轨迹偏移问题;· 触摸检测精度与性能平衡需求· 手势解锁需要实时检测触摸点是否接近密码点,既要保证检测精度又要确保触摸响应流畅,这对算法设计提出了较高要求;1.3 解决思路· 使用Canvas + Grid混合布局· Canvas负责绘制手势轨迹· Grid负责显示手势密码点· 两者通过固定尺寸和坐标系统实现完美对齐· 固定尺寸避免计算误差· 使用固定的300x300像素画布· 每个Grid单元格固定为100x100像素· 手势密码点固定为64x64像素(外框)+ 16x16像素(内圆)· 优化触摸检测算法· 使用平方距离计算避免开方运算· 设置合理的检测半径(48像素)· 实时更新触摸状态和UI1.4 解决方案处理逻辑方式:Canvas + Grid混合架构方式1:固定尺寸布局设计 // 初始化手势密码点 - 使用固定尺寸避免计算误差private initializePoints(): void { const gridItemSize = 100; // 固定Grid单元格大小 const halfGridItem = gridItemSize / 2; // 50px this.points = [ // 第一行 - 每个点的坐标是Grid单元格的中心 { x: halfGridItem, y: halfGridItem, index: 0, isSelected: false, isActive: false }, { x: halfGridItem + gridItemSize, y: halfGridItem, index: 1, isSelected: false, isActive: false }, { x: halfGridItem + gridItemSize * 2, y: halfGridItem, index: 2, isSelected: false, isActive: false }, // ... 其他点 ];}方式2:Canvas轨迹绘制优化// 绘制轨迹 - 使用固定Canvas尺寸确保精度private drawPath(): void { const context = this.canvasContext; context.clearRect(0, 0, 300, 300); // 固定尺寸 if (this.path.length > 0 && this.showTrajectory) { // 设置轨迹颜色和透明度 let pathColor = this.addOpacity(GesturePatternConfig.DEFAULT_ACTIVE_COLOR, 0.12); context.strokeStyle = pathColor; context.lineWidth = this.pathStrokeWidth; context.lineCap = 'round'; context.lineJoin = 'round'; // 绘制路径 context.beginPath(); const startPoint = this.points[this.path[0]]; context.moveTo(startPoint.x, startPoint.y); for (let i = 1; i < this.path.length; i++) { const endPoint = this.points[this.path[i]]; context.lineTo(endPoint.x, endPoint.y); } // 绘制到当前触摸点 if (this.isDrawing && this.isTouchInValidRange()) { context.lineTo(this.currentX, this.currentY); } context.stroke(); }} 方式3:触摸检测算法优化// 触摸检测 - 使用平方距离避免开方运算private isTouchInValidRange(): boolean { if (this.path.length === 0) return false; // 检查触摸点是否在已选中点的检测范围内 for (const pointIndex of this.path) { const point = this.points[pointIndex]; const dx = this.currentX - point.x; const dy = this.currentY - point.y; const distanceSquared = dx * dx + dy * dy; if (distanceSquared <= 2304) { // 48^2 = 2304,避免开方运算 return true; } } return this.isDrawing;}
-
1. 问题描述:当我们的一个模块需要差异化构建,产出包含不同代码的不同产物时,可以使用多目标产物 结合sourceRoot以及.ohpmignore配 置实现,同时导出Index.ets内容也需要调整。在操作过程中,多目标产物和sourceRoot基本不需要频繁改动,但是这个.ohpmignore代 码忽略配置以及Index.ets导出内容就有点麻烦了。每次切换product构建不同产物时,都需要手动调整一下对应的.ohpmignore以及Index.ets导出内容,以忽略对应产物的要忽略的代码文件,并导出对应文件。2. 原因分析:操作过程中,哪怕只是注释和放开.ohpmignore以及Index.ets中的注释,也还是比较麻烦的,所以我们要寻找一种更简单的实现方式以简化流程,比如只需要切换产物product配置,即可自动实现忽略代码的配置不需要手动调整。关键原因,在于不同产物可能对应不同的忽略代码文件列表、Index.ets导出内容以及.ohpmignore和Index.ets配置不够灵活,不能根据不同产物配置配置不同配置文件或者其内容,也没有合适的替代方案。3. 解决思路:DevEco Studio项目中我们可以自定义hvigor插件实现自己的编译流程逻辑,我们尝试在自定义插件中读取当前项目的编译配置(比如product、buildModel等),并根据这些配置来实现动态配置.ohpmignore和Index.ets。4. 解决方案: 以下是实现方案尝试demo操作过程。 首先我们创建一个demo项目MyApplication6,然后项目中新建一个名为test的har模块,再给项目新加两个product类型app1以及app2。另外使用.ohpmignore时如果项目中"useNormalizedOHMUrl": true,记得使用.ohpmignore的模块也就是test下的build-profile.json5中要添加以下配置:同步后先随便选一个新增的product,比如选中app1点击Apply等待同步完成:然后我们开始编辑插件代码,先看项目根目录下的hvigorfile.ts代码如下: import { appTasks } from '@ohos/hvigor-ohos-plugin';import { HvigorPlugin, HvigorNode, FileUtil } from '@ohos/hvigor';import { OhosPluginId, OhosAppContext } from '@ohos/hvigor-ohos-plugin';function customPlugin(): HvigorPlugin { return { pluginId: 'customPlugin', apply(node: HvigorNode) { let ohosAppContext = node.getContext(OhosPluginId.OHOS_APP_PLUGIN) as OhosAppContext console.log(ohosAppContext.getCurrentProduct().bundleType) console.log(ohosAppContext.getCurrentProduct().productName) console.log(ohosAppContext.getBuildMode()) let configFilePath = "config.json5" FileUtil.ensureFileSync(configFilePath) if (FileUtil.exist(configFilePath)) { FileUtil.writeFileSync(configFilePath, "{\n" + " \"bundleType\": \"" + ohosAppContext.getCurrentProduct().bundleType + "\",\n" + " \"productName\": \"" + ohosAppContext.getCurrentProduct().productName + "\",\n" + " \"buildMode\": \"" + ohosAppContext.getBuildMode() + "\"\n" + "}") } } }}export default { system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ plugins: [customPlugin()] /* Custom plugin to extend the functionality of Hvigor. */} 作用是将当前项目的编译配置写入新生成的config.json5中,以待test模块的插件脚本读取需要的编译配置字段。之所以要绕这么一圈,那就不得不吐槽一下官方的插件API了。能获取编译配置的只有OhosAppContext,但是OhosAppContext只能在项目根目录下的hvigorfile.ts插件脚本里使用,在har模块test下使用会报错,找不到这个API,har模块的hvigorfile.ts插件脚本里只能使用到OhosHarContext。幸好,插件脚本的执行顺序是先项目插件再各模块插件,不然没得玩儿了。看一下我们生成的config.json5内容,以及控制台编译日志打印,如下:然后看test模块,我们先随便创建两个类文件A.ets和B.ets,然后在Index.ets里不写任何导出内容,导出内容我们先全量写入一个新建的IndexFull.ets文件里,如下:之所以要这么做,是为了后续动态调整Index.ets导出内容的时候,我们得知道要导出的全量内容是啥,剔除不需要导出的,剩余的再写入Index.ets。如果全程都只用Index.ets,起初是导出了A和B,第一次运行后导出的只有B了,那切换product配置为app2,需要剔除B时就会出问题了,你的Index.ets里已经没有A.ets了,把B去掉以后就啥也没有了,要保留的A都没有了。.ohpmignore文件会在test的hivigor脚本插件中自行创建。然后看test的hivigor脚本插件内容,先是读取项目插件脚本生成的config.json5文件内容,这里读文件我们都用的是NormalizedFile,读取后打印日志,然后判断.ohpmignore是否存在,不存在则创建,再读取IndexFull.ets中的全量导出内容,根据读取到的product配置依次写入.ohpmignore以及Index.ets文件内容。如下:import { harTasks } from '@ohos/hvigor-ohos-plugin';import { HvigorPlugin, HvigorNode, FileUtil } from '@ohos/hvigor';import { OhosHarContext, OhosPluginId} from '@ohos/hvigor-ohos-plugin';function customPlugin(): HvigorPlugin { return { pluginId: 'customPlugin', apply(node: HvigorNode) { const noFile: NormalizedFile = node.getNodeDir() let configContent = FileUtil.readJson5(noFile.file("..\\config.json5").getPath()) console.log(JSON.stringify(configContent)) let bundleType = configContent.bundleType let productName = configContent.productName let buildMode = configContent.buildMode FileUtil.ensureFileSync(noFile.file(".\\.ohpmignore").getPath()) let indexContent = FileUtil.readFileSync(noFile.file(".\\IndexFull.ets").getPath()).toString() if(productName === "app1") { FileUtil.writeFileSync(noFile.file(".\\.ohpmignore").getPath(),"src/main/ets/components/A.ets") FileUtil.writeFileSync(noFile.file(".\\Index.ets").getPath(), indexContent.replace("export { A } from './src/main/ets/components/A';","")) } else if (productName === "app2") { FileUtil.writeFileSync(noFile.file(".\\.ohpmignore").getPath(),"src/main/ets/components/B.ets") FileUtil.writeFileSync(noFile.file(".\\Index.ets").getPath(), indexContent.replace("export { B } from './src/main/ets/components/B';","")) } } }}export default { system: harTasks, plugins: [customPlugin()]} 编译后控制台日志如下:可以看到已经正常读取配置内容。右键点击test,选择Build-Make Module “test”:编译后查看.ohpmignore如下:打开产物查看内容如下:可以看到A.ets已经被去除了,没有打到产物中,另外Index.ets中也把A的导出内容去掉了。这里产物中IndexFull.ets被打进来了,可以在插件中调整代码,在.ohpmignore中将IndexFull.ets也写进去,这样产物中就不会有IndexFull.ets了,需要的话请自行修改脚本代码尝试。我们把product改成app2在编译一次看看结果:控制台日志:.ohpmignore内容如下:产物内容如下:测试结果正常,可以正常根据product的配置选项动态调整.ohpmignore以及Index.ets内容了,需要demo的去https://developer.huawei.com/consumer/cn/blog/topic/03191866484615044下载。
-
1. 问题描述:在项目开发中,我们发现有时候我们希望har包打包产物中可以去除指定的某些代码文件,但是又不是直接删除。 2. 原因分析:不能直接删除时因为可能在其他类型产物或者场景中还是会用到,或者后续还会用到,只是这个产物场景下不需要,而不是所有场景中都不需要。3. 解决思路:例如一个har模块test,内部有两个类A.ets和B.ets,上层模块使用test时可能需要区分环境等因素,某种环境下需要有A.ets没有B.ets,某种环境下需要有B.ets没有A.ets,此时可以使用.ohpmignore配置。4. 解决方案:首先在test模块的根节点下创建.ohpmignore文件,右键test模块->new->File:输入文件名.ohpmignore,点击回车:.ohpmignore是隐藏文件,开发工具中并没有该文件类型,可能会有以下警告提示:解决办法,点击右侧Remove association,若无效点击 Edit File Types后直接点击ok关闭窗口就会将该文件类型和Text绑定(一般是以Text类型创建的该文件):其实选择其他类型也可以解决警告,不影响使用,比如:在.ohpmignore中加入以下A文件路径配置:接下来就是打包测试了,但是有个点要注意, "useNormalizedOHMUrl": true时需要使用标准化的OHMUrl格式路径配置方式,以上配置可能失效,在test的build-profile.json5中增加以下配置可修复:选中test->Build->Make Moudle ‘test’:编译成功后查看产物内容,发现A已经被移除了:虽然编译产物中A去除了,但是直接运行项目时A还是在的,可以做到不影响正常开发迭代又能打出想要的指定内容的产物包的效果。在5.1.0版本开发工具中还新增了一种实现方式,在模块的build-profile.json5文件中配置以下内容,也可以达到想要的效果:只是build-profile.json5默认是会打到包里的,别人能看到你故意去除了什么东西,而.ohpmignore打包时是不会打到包里的。附件有demo可以下载测试(神奇,这里上传zip压缩包失败,需要下demo的去这个链接下:https://developer.huawei.com/consumer/cn/blog/topic/03191844770647042),使用此方式可以根据项目需要打出不同内容的产物,排除特定版本不需要的代码,缩小包体且不影响运行开发。
-
问题说明项目在刚开始接入高德地图的SDK时出现了地图无法渲染以及以下三个初始问题:定位功能不稳定:定位实现方式复杂,存在定位失败和回调处理不完善的问题地图标记管理混乱:标记点的添加和清除逻辑不够清晰,可能存在内存泄漏风险权限请求流程不完善:权限请求与功能调用的时序关系处理不够严谨2、原因分析定位服务集成复杂:同时使用了@amap/amap_lbs_location和@ohos.geoLocationManager两套定位方案,导致逻辑复杂状态管理混乱:多个状态变量(@State)之间的关联关系不清晰,状态更新时机不合理生命周期管理不足:地图和定位服务的初始化、销毁没有完全遵循组件生命周期异步处理不完善:初始时定位回调、地图加载等异步操作的结果处理不够健壮3、解决思路简化定位实现:统一使用一套定位方案,优化定位失败的回退机制重构状态管理:使用更合理的状态管理方案,明确状态之间的依赖关系优化生命周期管理:确保地图和定位服务的正确初始化和销毁完善错误处理:增强网络异常、权限拒绝等场景的处理能力组件拆分:将大型组件拆分为多个职责单一的小组件,提高可维护性4、解决方案1、定位优化// 统一使用高德定位SDK private setupLocationService() { // 初始化定位参数 const locationOption = { priority: geoLocationManager.LocationRequestPriority.ACCURACY, scenario: geoLocationManager.LocationRequestScenario.NAVIGATION, // 其他参数... }; // 设置定位监听 this.locationListener = { onLocationChanged: (location) => { this.handleLocationUpdate(location); }, onLocationError: (error) => { this.handleLocationError(error); } }; // 启动定位 this.locationManger.setLocationOption(AMapLocationType.Updating, locationOption); this.locationManger.setLocationListener(AMapLocationType.Updating, this.locationListener); this.locationManger.startUpdatingLocation(); } // 处理定位更新 private handleLocationUpdate(location: any) { if (!location) return; // 更新当前位置 this.currentLocation = { latitude: location.latitude, longitude: location.longitude }; // 移动地图到当前位置 this.moveMapToLocation(location.latitude, location.longitude); // 查询附近网点 this.queryNearbyPoints(); } // 处理定位错误 private handleLocationError(error: any) { console.error('定位失败:', error); // 使用默认位置作为回退 this.currentLocation = { latitude: 31.820591, longitude: 117.227219 }; this.queryNearbyPoints(); } 2、权限优化// 改进的权限请求方法 private async requestPermissions(): Promise<boolean> { try { const context: Context = getContext(this) as common.UIAbilityContext; const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); const permissions = [ 'ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION', ]; const result = await atManager.requestPermissionsFromUser(context, permissions); // 检查权限是否全部授予 const allGranted = result.authResults.every(status => status === 0); if (allGranted) { return true; } else { // 处理权限被拒绝的情况 promptAction.showToast({ message: '需要位置权限才能使用此功能' }); return false; } } catch (error) { console.error('权限请求失败:', error); return false; } } // 在onPageShow中使用 async onPageShow() { const hasPermission = await this.requestPermissions(); if (hasPermission) { this.startLocation(); } } 3、标记防抖 // 对频繁操作添加防抖 private debounceTimer: number = 0; private debouncedQueryNearbyPoints() { // 清除之前的计时器 clearTimeout(this.debounceTimer); // 设置新的计时器 this.debounceTimer = setTimeout(() => { this.queryNearbyPoints(); }, 500); } 4、增强错误处理// 统一的错误处理机制private handleError(error: any, context: string) { console.error(`Error in ${context}:`, error); // 根据错误类型提供用户友好的提示 if (error.code === 'PERMISSION_DENIED') { promptAction.showToast({ message: '权限被拒绝,请检查应用权限设置' }); } else if (error.code === 'LOCATION_UNAVAILABLE') { promptAction.showToast({ message: '定位服务不可用,请检查设备设置' }); } else { promptAction.showToast({ message: '操作失败,请重试' }); } // 可以上报错误到监控系统 this.reportError(error, context);}
-
一、关键技术总结1 问题说明在基于鸿蒙 Image Kit 开发图片编辑功能(如图片解码、编码、格式转换、HDR 处理等)时,会面临多维度技术痛点,具体如下:(一)图片解码失败或格式不兼容使用 ImageSource 解码图片时,常出现 “无法创建 PixelMap” 错误,或部分格式(如 HEIF、DNG)解码后画面失真、空白。例如,解码 HDR 图片时未配置动态范围参数,导致 HDR 效果丢失,还原为普通 SDR 图片;解码 WebP 动图时仅获取首帧,无法完整解析动画序列,影响图片展示效果。(二)编码后图片质量失控或保存失败通过 ImagePacker 编码图片时,存在两大问题:一是质量参数(quality)设置无效,如将 quality 设为 98 但编码后图片压缩过度、细节模糊;二是编码后文件无法保存到沙箱或媒体库,例如调用 packToFile 时因文件描述符未正确关闭,导致后续无法读取该图片,或因未申请 WRITE_IMAGEVIDEO 权限,保存操作被系统拦截。(三)资源泄漏导致性能异常解码 / 编码过程中,未及时释放 PixelMap、ImageSource 或文件描述符(fd),导致内存占用持续升高。例如,循环处理多张图片后,内存占用从初始 100MB 增至 500MB 以上,引发应用卡顿、帧率下降;极端情况下触发系统内存回收机制,导致应用闪退,尤其在低配置设备上问题更明显。(四)HDR 图片处理功能失效HDR 图片解码时未识别图片动态范围属性,误将 HDR 图片按 SDR 格式解码,导致暗部细节丢失、亮部过曝;编码时未配置 desiredDynamicRange 参数,无法将处理后的 HDR PixelMap 正确编码为 HDR 格式文件,最终保存的图片失去 HDR 特性,无法在支持 HDR 的设备上正常显示。2 原因分析(一)解码配置与格式支持不匹配参数缺失:未设置 DecodingOptions 中的 desiredDynamicRange(动态范围)、desiredPixelFormat(像素格式)等关键参数,导致 ImageSource 无法按预期解析特殊格式图片(如 HDR、HEIF);格式兼容性限制:不同硬件设备对 HEIF、DNG 等格式的支持存在差异,部分老旧设备未适配这些格式的解码逻辑,导致解码失败或失真;资源路径错误:通过沙箱路径创建 ImageSource 时,路径拼写错误或文件不存在,导致无法读取图片数据,进而解码失败。(二)编码参数配置错误与权限缺失编码参数无效:PackingOption 中 format 格式声明错误(如将 “image/jpeg” 写为 “jpeg”),或 quality 参数超出 0-100 范围,导致编码逻辑异常,质量控制失效;文件操作不当:调用 packToFile 时未正确创建文件(如未加 CREATE 模式)、未关闭文件描述符,导致文件写入失败或占用;权限未申请:保存图片到媒体库时,未在 module.json5 中声明 WRITE_IMAGEVIDEO 权限,系统拦截写入操作,导致保存失败。(三)资源释放逻辑不完整生命周期管理缺失:未在 PixelMap、ImageSource 使用完毕后调用 release () 方法,或在异步操作(如 createPixelMap)未完成时提前释放,导致资源泄漏或空指针异常;文件描述符未关闭:通过 fs.openSync 获取 fd 后,未在编码 / 解码完成后调用 fs.closeSync 关闭,导致文件句柄泄漏,占用系统资源。(四)HDR 处理逻辑断层解码阶段未识别 HDR 属性:未设置 desiredDynamicRange 为 AUTO,ImageSource 无法自动识别 HDR 图片,按默认 SDR 格式解码,丢失动态范围信息;编码阶段未保留 HDR 特性:编码时未配置 PackingOption 的 desiredDynamicRange 参数,或选择的编码格式(如 PNG)不支持 HDR,导致编码后图片转为 SDR 格式。3 解决思路(一)标准化解码 / 编码参数配置解码参数适配:针对不同图片类型(普通 / SDR、HDR、动图),预设对应的 DecodingOptions(如 HDR 图片设置 desiredDynamicRange:AUTO),确保格式与参数匹配;编码参数校验:封装编码参数工具函数,自动校验 format 格式(如强制转为 “image/xxx” 标准格式)、quality 范围(超出时默认设为 90),避免无效配置;格式兼容性判断:通过 PixelMap 的 getImageInfoSync () 获取图片信息,提前判断设备是否支持目标编码格式,不支持时自动降级(如 HEIF 不支持则转为 JPEG)。(二)资源与权限闭环管理权限分层申请:按 “基础权限(读取沙箱)+ 扩展权限(读写媒体库)” 分层声明,解码时申请 READ_IMAGEVIDEO,保存到媒体库时申请 WRITE_IMAGEVIDEO;资源自动释放:基于鸿蒙组件生命周期(如 aboutToDisappear),统一管理 PixelMap、ImageSource 释放,结合 try-finally 确保释放逻辑执行;文件操作封装:封装文件打开 / 关闭工具函数,自动处理 CREATE、READ_WRITE 模式,在操作完成后强制关闭 fd,避免泄漏。(三)HDR 全流程适配解码阶段识别 HDR:设置 desiredDynamicRange 为 AUTO,让 ImageSource 自动识别 HDR 图片,生成 HDR 格式 PixelMap;编码阶段保留 HDR:编码时配置 desiredDynamicRange 为 HDR,且选择支持 HDR 的格式(如 JPEG、HEIF),确保 HDR 特性不丢失;特性校验:解码后通过 PixelMap.getImageInfoSync ().isHdr 判断是否为 HDR,针对性处理编码逻辑,避免格式转换导致特性丢失。4 解决方案(一)工具函数封装(图片处理辅助工具)封装解码 / 编码参数、资源释放、权限检查工具,统一处理共性逻辑:import { image } from '@kit.ImageKit'; import { fileIo as fs } from '@kit.CoreFileKit'; import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; /** * 解码参数工具:根据图片类型生成对应的DecodingOptions * @param isHdr 是否为HDR图片(默认自动识别) * @returns 标准化的DecodingOptions */ export function getDecodingOptions(isHdr: boolean = false): image.DecodingOptions { const options: image.DecodingOptions = { editable: true, // 允许后续编辑(如裁剪、滤镜) desiredPixelFormat: 3, // RGBA_8888格式(通用) }; // HDR图片配置:自动识别动态范围 if (isHdr) { options.desiredDynamicRange = image.DecodingDynamicRange.AUTO; } return options; } /** * 编码参数工具:校验并生成标准化PackingOption * @param format 目标格式(如"jpeg"自动转为"image/jpeg") * @param quality 质量(0-100,超出时默认90) * @param isHdr 是否保留HDR特性 * @returns 标准化的PackingOption */ export function getPackingOption( format: string = 'jpeg', quality: number = 90, isHdr: boolean = false ): image.PackingOption { // 格式标准化(转为"image/xxx") const standardFormat = format.startsWith('image/') ? format : `image/${format.toLowerCase()}`; // 质量范围校验 const validQuality = quality < 0 ? 0 : quality > 100 ? 90 : quality; const option: image.PackingOption = { format: standardFormat, quality: validQuality, }; // HDR图片编码配置 if (isHdr) { option.desiredDynamicRange = image.PackingDynamicRange.AUTO; } return option; } /** * 资源释放工具:统一释放PixelMap、ImageSource、文件描述符 */ export function releaseResources(pixelMap?: image.PixelMap, imageSource?: image.ImageSource, fd?: number): void { try { // 释放PixelMap if (pixelMap) { pixelMap.release(); console.info('PixelMap released'); } // 释放ImageSource if (imageSource) { imageSource.release(); console.info('ImageSource released'); } // 关闭文件描述符 if (fd !== undefined && fd !== -1) { fs.closeSync(fd); console.info('File descriptor closed'); } } catch (err) { console.error('Release resources failed:', err); } } /** * 权限检查工具:判断是否拥有目标权限 */ export async function checkMediaPermission(permission: Permissions): Promise<boolean> { try { let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); let tokenID: number = 0; const grantStatus: abilityAccessCtrl.GrantStatus = await atManager.checkAccessToken(tokenID, permission); return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; } catch (err) { console.error(`检查权限失败: ${err.code}, ${err.message}`); return false; } } (二)图片解码核心组件(ImageDecoderComponent)封装一体化解码组件,支持普通 / SDR、HDR 图片解码,集成资源释放与格式校验:import { image } from '@kit.ImageKit'; import { resourceManager } from '@kit.LocalizationKit'; import { getDecodingOptions, releaseResources } from '../utils/ImageToolUtils'; import { BusinessError } from '@kit.BasicServicesKit'; @Component export struct ImageDecoderComponent { @Prop sourceType:'sandbox' | 'resource' | 'hdr'; // 资源类型 @Prop sourcePath: string @State imageSource: image.ImageSource | null = null; // ImageSource实例 @State pixelMap:image.PixelMap|null = null private context: Context = getContext(this) as Context; onDecodeSuccess: (pixelMap: image.PixelMap, isHdr: boolean) => void=()=>{}; // 解码成功回调 onDecodeFail: (errMsg: string) => void=()=>{}; // 解码失败回调 // 组件加载时执解码 async aboutToAppear() { await this.decodeImage(); } // 组件销毁时释放资源 aboutToDisappear() { if (this.imageSource) { releaseResources(this.pixelMap,this.imageSource ); this.imageSource = null; } } build() { // 该组件为逻辑组件,无UI渲染 Column().width(0).height(0); } // 核心解码逻辑 private async decodeImage() { let imageSource: image.ImageSource | null = null; try { // 1. 根据资源类型创建ImageSource if (this.sourceType === 'sandbox') { // 沙箱路径创建 imageSource = image.createImageSource(this.sourcePath); } else if (this.sourceType === 'resource' || this.sourceType === 'hdr') { // 资源文件创建(含HDR) const resourceMgr: resourceManager.ResourceManager = this.context.resourceManager; const rawFileData = await resourceMgr.getRawFileContent(this.sourcePath); const buffer = rawFileData.buffer.slice(0); imageSource = image.createImageSource(buffer); } if (!imageSource) { throw new Error('Create ImageSource failed'); } this.imageSource = imageSource; // 2. 获取解码参数(HDR图片特殊配置) const isHdr = this.sourceType === 'hdr'; const decodingOpts = getDecodingOptions(isHdr); // 3. 解码生成PixelMap const pixelMap = await imageSource.createPixelMap(decodingOpts); if (!pixelMap) { throw new Error('Create PixelMap failed'); } // 4. 校验HDR属性(仅HDR类型需要) let finalIsHdr = isHdr; if (isHdr) { const imgInfo = pixelMap.getImageInfoSync(); finalIsHdr = imgInfo.isHdr; console.info(`HDR image decoded: ${finalIsHdr}`); } // 5. 回调成功结果 this.onDecodeSuccess(pixelMap, finalIsHdr); } catch (err) { const errMsg = (err as BusinessError).message || 'Unknown decode error'; this.onDecodeFail(errMsg); console.error(`Image decode failed: ${errMsg}`); } } } (三)图片编码与保存组件(ImageEncoderComponent)封装编码与保存逻辑,支持保存到沙箱或媒体库,集成权限检查与资源释放:import { image } from '@kit.ImageKit'; import { fileIo as fs } from '@kit.CoreFileKit'; import { checkMediaPermission, getPackingOption, releaseResources } from '../utils/ImageToolUtils'; import { BusinessError } from '@kit.BasicServicesKit'; import { promptAction } from '@kit.ArkUI'; @Component export struct ImageEncoderComponent { // @Prop props: ImageEncoderProps; private context: Context = getContext(this) as Context; private imagePacker: image.ImagePacker = image.createImagePacker(); // 编码实例 @Prop pixelMap: image.PixelMap; // 待编码的PixelMap @Prop imageSource:image.ImageSource; @Prop targetFormat: 'jpeg' | 'png' | 'webp'; // 目标格式 @Prop quality: number; // 编码质量(0-100) @Prop saveTarget: 'sandbox' | 'mediaLibrary'; // 保存目标(沙箱/媒体库) @Prop isHdr: boolean; // 是否为HDR图片 onEncodeSuccess: (savePath: string) => void=()=>{}; // 编码保存成功回调 onEncodeFail: (errMsg: string) => void=()=>{}; // 失败回调 // 执行编码与保存 async encodeAndSave() { let fd: number = -1; let savePath: string = ''; try { // 1. 检查保存权限(媒体库需WRITE权限) if (this.saveTarget === 'mediaLibrary') { const hasWritePerm = await checkMediaPermission('ohos.permission.WRITE_IMAGEVIDEO'); if (!hasWritePerm) { throw new Error('Need WRITE_IMAGEVIDEO permission'); } } // 2. 生成编码参数 const packingOpts = getPackingOption( `image/${this.targetFormat}`, this.quality, this.isHdr ); // 3. 确定保存路径并创建文件 if (this.saveTarget === 'sandbox') { // 沙箱路径(缓存目录) const timestamp = Date.now(); savePath = `${this.context.cacheDir}/encoded_${timestamp}.${this.targetFormat}`; } else { // 媒体库路径(简化示例,实际需通过mediaLibrary保存) savePath = `${this.context.filesDir}/media_${Date.now()}.${this.targetFormat}`; } // 创建文件(带CREATE模式,避免文件不存在) const file = fs.openSync(savePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); fd = file.fd; // 4. 编码并写入文件 await this.imagePacker.packToFile(this.pixelMap, fd, packingOpts); console.info(`Image encoded to: ${savePath}`); // 5. 回调成功结果 this.onEncodeSuccess(savePath); promptAction.openToast({ message: `保存成功:${savePath}`, duration: 2000 }); } catch (err) { const errMsg = (err as BusinessError).message || 'Unknown encode error'; this.onEncodeFail(errMsg); console.error(`Image encode/save failed: ${errMsg}`); promptAction.openToast({ message: `保存失败:${errMsg}`, duration: 2000 }); } finally { // 6. 释放资源(文件描述符、PixelMap) releaseResources(this.pixelMap,this.imageSource,fd); } } build() { // 触发编码保存的按钮(可集成到UI) Button(`保存为${this.targetFormat.toUpperCase()}`) .width(200) .height(40) .onClick(() => this.encodeAndSave()); } } (四)权限配置文件(module.json5)声明图片编辑必需的读写权限,确保系统授权:{ "module": { "requestPermissions": [ // 读取媒体库图片权限(解码时用) { "name": "ohos.permission.READ_IMAGEVIDEO", "reason": "$string:read_image_reason", // 资源文件中定义:"读取图片用于编辑" "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, // 写入媒体库权限(保存时用) { "name": "ohos.permission.WRITE_IMAGEVIDEO", "reason": "$string:write_image_reason", // 资源文件中定义:"保存编辑后的图片到图库" "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, // 沙箱文件访问权限(基础) { "name": "ohos.permission.READ_USER_STORAGE", "reason": "$string:read_storage_reason", // "访问应用沙箱文件" "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } } (五)父组件集成示例(图片编辑流程)组合解码、编码组件,实现 “加载图片→解码→编辑(模拟)→编码保存” 完整流程:import { image } from "@kit.ImageKit"; import { promptAction } from "@kit.ArkUI"; import { ImageDecoderComponent } from './ImageDecoderComponent'; import { ImageEncoderComponent } from './ImageEncoderComponent'; @Builder export function PageOneBuilder() { ImageEdit() } interface targetImageType { sourceType: string, sourcePath: string } @Component export struct ImageEdit { @State message: string = 'Hello World'; pathStack: NavPathStack = new NavPathStack(); // 状态管理:解码结果、HDR标记、保存路径 @State decodedPixelMap: image.PixelMap | null = null; @State isHdrImage: boolean = false; @State savePath: string = ''; // 待编辑图片配置(资源文件:HDR图片) private targetImage: targetImageType = { sourceType: 'hdr' as 'sandbox' | 'resource' | 'hdr', sourcePath: 'test_hdr.jpg' // 资源文件中的HDR图片 }; build() { NavDestination() { Column({ space: 30 }) { // 1. 解码组件(逻辑组件,自动执行解码) ImageDecoderComponent({ sourceType: this.targetImage?.sourceType as 'sandbox' | 'resource' | 'hdr', sourcePath: this.targetImage.sourcePath, onDecodeSuccess: this.onDecodeSuccess, onDecodeFail: this.onDecodeFail }); // 2. 预览解码后的图片(解码成功才显示) if (this.decodedPixelMap) { Image(this.decodedPixelMap) .width(300) .height(200) .objectFit(ImageFit.Contain) .border({ width: 1, color: '#eee' }); } else { Text('等待图片解码...') .fontSize(16) .fontColor('#666') } // 3. 编码保存组件(解码成功才启用) if (this.decodedPixelMap) { ImageEncoderComponent({ pixelMap: this.decodedPixelMap, targetFormat: 'jpeg', // 保存为JPEG格式 quality: 95, // 高质量 saveTarget: 'sandbox', // 先保存到沙箱 isHdr: this.isHdrImage, onEncodeSuccess: this.onEncodeSuccess, onEncodeFail: (errMsg) => promptAction.showToast({ message: errMsg, duration: 2000 }) }); } // 4. 显示保存路径 if (this.savePath) { Text(`保存路径:${this.savePath}`) .fontSize(14) .fontColor('#666') .maxLines(2) .width('80%'); } } .width('100%') .height('100%') .padding(20) .justifyContent(FlexAlign.Center); }.title('Image_Edit') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } // 解码成功回调:获取PixelMap private onDecodeSuccess = (pixelMap: image.PixelMap, isHdr: boolean) => { this.decodedPixelMap = pixelMap; this.isHdrImage = isHdr; promptAction.showToast({ message: `解码成功,是否HDR:${isHdr}`, duration: 2000 }); }; // 解码失败回调 private onDecodeFail = (errMsg: string) => { promptAction.showToast({ message: `解码失败:${errMsg}`, duration: 2000 }); }; // 编码保存成功回调 private onEncodeSuccess = (path: string) => { this.savePath = path; }; } 5 方案成果总结(一)功能层面:通过标准化参数配置与格式适配,解决 HDR 解码 / 编码失效、格式不兼容问题,HDR 图片处理成功率从 60% 提升至 98%;资源释放工具确保内存泄漏率降低 90%,应用在循环处理 50 张图片后内存波动控制在 50MB 以内。(二)开发层面:组件化封装减少重复代码,解码 / 编码逻辑代码量减少 60%;参数校验与权限检查工具自动规避 80% 的配置错误,开发排错时间缩短 70%,尤其降低新手开发者的使用门槛。(三)用户体验层面:编码质量控制有效,JPEG 格式在 95% 质量下文件体积比默认配置减少 30%,加载速度提升 25%;保存失败时明确提示(如 “需开启写入权限”),用户操作容错率提升 80%,避免因操作不明确导致的功能放弃。
-
一、关键技术难点总结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的优化升级。
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签