• [技术干货] 让服务来“敲门”!HarmonyOS近场能力激活服务找人新价值
           在万物互联时代,用户需求正从“人找服务”逐步向“服务找人”转变。HarmonyOS 以用户为中心,依托POI、信标、鸿蒙标签、NFC iTAP等技术打造近场服务能力,将近场服务融入用户日常生活场景,悄然改变众多领域的服务体验。本期近场服务聚焦商超、文旅、餐饮三大行业的典型应用场景,带你感受HarmonyOS近场服务带来的体验提升。一、智慧商超:为商铺装上“智能导购”       在传统商超综合体中,商铺客流大多依赖品牌影响力和区位优势,普通商铺难以有效吸引顾客驻足。       而当商铺部署信标设备后,用户进入信标连接范围即可收到传输信号,通过“小艺建议”获取门店活动、特色服务等推荐,助力商家在用户消费决策前实现精准曝光,显著提升店铺引流能力,为会员转化和成交率带来新增长点。 二、智慧文旅:打造沉浸式游览体验       假期出游高峰时,排队购票导致入园拥堵、景区导览设置不清导致错过打卡点等都会影响游客的游览体验。       近场服务基于POI位置推荐可在游客靠近景区附近时通过小艺建议获取购票服务卡片推荐,一键直达购票页面,比传统线上购票软件减少约50%操作步骤。进入景区游览时,游客也可以基于景区内不同景点的POI点位推荐一键跳转至景区元服务详情页,当前景点讲解、后续景点推荐、游览路线推荐等一目了然,告别盲目寻找和人工问询。 三、智慧餐饮:一碰直达,极速点餐       餐饮门店可在餐桌或入口处设置HarmonyOS标签,用户通过手机“碰一碰”即可快速直达商家元服务页面。       消费者无需排队点单,手机“碰一碰”即可实现会员一键入会、获取优惠套餐、快速点餐等。不仅大大缩短用户操作步骤,提升了用户体验,也帮助商家大幅提升会员转化与订单效率,实现用户与商家的双赢。         HarmonyOS近场服务在以上行业应用场景中展示了强大的适配性和创新价值。除上述典型案例场景之外,还广泛应用在智慧办公、运动健康、本地生活、政务民生等领域。欢迎开发者点击下方链接了解并接入使用,与HarmonyOS一起共建共享鸿蒙新世界!       👉 点击了解更多并申请接入​:申请开通权限-近场服务 - 华为HarmonyOS开发者 (huawei.com)       AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。       如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
  • [技术干货] H5页面加载终于不转圈了!FastWeb组件让加载快到起飞
    对H5页面占比高的APP而言,“加载慢”是用户体验的“头号杀手”——转圈的加载动画、迟迟不显示的内容,很容易让用户直接退出。为解决这一痛点,AppGallery Connect推出高性能Web容器组件FastWeb,专为H5页面提速而生,帮开发者搞定H5优化,让用户告别“加载卡顿”烦恼,体验更丝滑。一、先搞懂:什么是FastWeb组件?​FastWeb是基于OpenHarmony开发的“高性能Web容器”,适用于对H5页面有性能优化需求(加载提速)的场景。像电商APP的商品详情页、资讯新闻列表页、工具类功能操作页等,只要是以H5形式呈现且对页面性能优化有诉求,希望提升加载速度,FastWeb都能派上用场。它聚焦网络大资源的“提速”核心,而非复杂业务逻辑的处理,旨在帮助大家用轻量化开发实现加载优化。二、两种使用方式:按需选择,灵活配置考虑到不同APP的H5开发现状,FastWeb提供两种灵活方案,无论全面改造还是增量式“迭代开发”,都带来了不错的提升效果。​实验数据显示,某APP首次打开且无缓存时,直接加载Web页面需5413.58ms,多次打开有缓存时仍需1345.93ms,这是因为该方式要在页面加载时才拉起渲染进程、发起资源请求,额外增加了加载耗时;而使用FastWeb组件后,首次打开(无缓存)加载页面加载时间缩短49.9%;多次打开(有缓存)页面加载时间缩短39.7%。具体数据如下:       方式一:全面改造,解锁全能力​若想彻底发挥FastWeb的优化实力,即便H5已封装过Web容器,也能通过此方式“全方位提速”。它会调用预启动、预渲染、预编译JavaScript生成字节码缓存、离线资源拦截注入四大能力,从“提前准备”到“资源复用”拉满效率。操作很简单:APP启动时(或合适时机)创建空的ArkWeb组件“预热”,展示H5页面时直接挂载即可。需注意删除原有Web容器,将属性和事件写入FastWeb暴露对象,适合有调整空间的团队。​方式二:增量式“迭代开发”,快速提效​如果已经将H5页面封装成Web容器,并希望在不修改原页面的基础上进行优化,你可以通过FastWeb的预编译JavaScript生成字节码缓存、离线资源拦截注入两大能力,实现提速。操作逻辑同上:提前创建空ArkWeb组件,可以在App启动时创建,或者其他合适的页面创建。展示H5时直接用原有页面,无需额外调整。适合追求“低成本快速优化”的团队,兼顾效果与业务稳定性。​三、实用建议:避坑指南,用得更顺手​想让FastWeb稳定发挥提速效果,这几个细节要注意:​FastWeb组件的核心优势在于网络大资源的预加载能力,而非复杂业务逻辑处理,建议优先用于首页H5、高频核心页等“优化关键路径”,能让提速效果更突出。若应用涉及桥接功能需求,优先选方式二,避免改动原有容器,确保通信稳定的同时,不影响加载速度提升。创建FastWeb组件将占用内存(每个FastWeb组件大约200MB)和计算资源,建议避免一次性创建大量FastWeb组件,按页面访问频率合理规划,避免出现“为了快而牺牲流畅”的情况。​对H5多的APP来说,FastWeb不是“可选优化项”,而是“刚需组件”。它无需复杂适配,两种方式覆盖不同开发场景。​若你正为H5加载慢头疼,不妨试试FastWeb——让用户告别等待,让APP体验再上台阶。AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。  如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
  • [技术干货] 藏不住了!App Linking 这些宝藏技巧,解锁服务直达新路径
           在用户注意力稀缺的今天,如何让每一次触达都精准转化为应用内的活跃行为?华为AppGallery Connect(简称AGC)向开发者推出App Linking技术服务,提供“应用链接”和“元服务链接”,可直接跳转HarmonyOS应用或者跳转元服务,有效简化用户访问路径。无论是内容分享、游戏互动还是服务直达,App Linking都能提供有力支持。它不仅能帮助开发者提升应用的竞争力,还能为用户带来更便捷、高效的使用体验。​       今天就来盘点下,App Linking 到底有哪些好用的全场景链接技巧!​一、社交互动篇:2个技巧解锁社交分享新玩法​       社交分享是用户传播的核心场景,但传统分享常因 “操作复杂、跳转卡顿” 流失用户。App Linking 通过2个技巧,让社交分享既有趣又高效,轻松提升裂变转化效果。App Linking+华为分享,助力线上社交裂变       核心功能:依托 HarmonyOS 系统级分享面板,支持直接生成带应用 / 元服务入口的分享链接,可无缝分享至微信、畅联等主流社交 AppApp Linking+碰一碰分享,社交分享新体验​       核心功能:两部设备轻轻一碰即可传递链接,实现 “一碰即传、极简操作”,带来全新的社交互动体验,趣味性与便捷性兼顾。       点击查看场景案例: 华为视频碰一碰,让跨设备视频分享一步到位​ 二、服务触达篇:3 个方案助力服务直达       App Linking 通过3种针对性方案,实现无需提前打开 App,没有复杂跳转过程,就可直达服务。App Linking+系统扫码,一扫直达目标页面       核心功能:多渠道扫码,负一屏、控制中心、系统相机均可通过扫码,无需用户打开App,通过系统扫码直达应用的核心页面。​App Linking+智能消息,一步直达服务页面       核心功能:智能消息作为营销活动的优秀载体。从消息一键直达服务,体验友好。可以提高营销转化率。App Linking+鸿蒙标签,服务一碰即达       核心功能:即碰即走,方便快捷;碰扫合一,多样化体验。便捷使用,需要碰一碰服务标签即可获取服务信息。       点击查看场景案例:美团一扫即达,服务快人一步,操作效率提升30%以上 三、进阶攻略篇:2 个工具让分享链路精准触达直达应用市场:目标应用 “点击即达”,减少流量流失       核心功能:当成功配置App Linking应用链接后,可以构建App Linking直达链接。当应用已安装时,点击链接直接跳转应用;当应用未安装时,点击链接跳转应用市场下载详情页,引导用户下载应用。延迟链接:跳转 “不跑偏”,提升转化效率       核心功能:当被分享用户未安装应用时,通过延迟链接能力,应用首次打开时,系统仍能获取用户之前点击的应用相关链接。在获取链接后,应用可直接跳转至对应的详情页,无需先跳转至应用首页,从而提升用户体验和链接的转化率。       点击查看场景案例: App Linking助力华为阅读分享链路精准触达,操作步骤减43%!       对于开发者而言,App Linking 不只是简单的链接工具,更是提升用户使用体验的核心利器。它打通 “用户触达” 与 “服务落地”,让应用与用户连接更高效。点击下方链接,即刻开启鸿蒙生态场景化运营新篇章 ——App Linking 。       AppGallery Connect致力于为应用的创意、开发、分发、运营、经营各环节提供一站式服务,构建全场景智慧化的应用生态体验。为给你带来更好服务,请扫描下方二维码或者点击此处免费咨询。       如有任何疑问,请发送邮件至agconnect@huawei.com咨询,感谢你对HUAWEI AppGallery Connect的支持!
  • [技术交流] 开发者技术支持--HarmonyOS Next 卡片开发
    1.问题说明:如何进行卡片开发2.原因分析:创建卡片类型,3.解决思路:熟悉卡片的概念,开发流程,部署测试4.解决方案:如下HarmonyOS Next 中的卡片(Service Widget)是一种轻量级应用组件,可直接在桌面展示关键信息并支持快速交互,提升用户体验。以下是卡片开发的核心要点和实现流程:一、卡片基本概念类型:静态卡片:内容固定,通过配置文件定义布局和数据。动态卡片:内容可实时更新,通过后台服务(Ability)刷新数据。交互卡片:支持用户点击、滑动等操作,触发页面跳转或数据处理。尺寸:系统提供多种预设尺寸(如 2x2、4x2 等),需适配不同规格。二、开发流程1. 创建卡片工程在 DevEco Studio 中,新建 HarmonyOS 项目时选择 Application Widget 模板,自动生成基础结构:widgets 目录:存放卡片布局和配置文件。entry 目录:主应用逻辑(可选,用于卡片交互)。2. 定义卡片布局(XML/ArkTS)使用 XML 或 ArkTS 声明式 UI 定义卡片界面,示例如下:ArkTS 布局:   // widgets/CardWidget.ets@Entry@Componentstruct CardWidget { // 卡片数据(静态或从服务获取) @State message: string = 'Hello Card' build() { Column() { Text(this.message) .fontSize(16) .margin(10) Button('点击更新') .onClick(() => { // 触发卡片交互(需配置路由) postCardAction(this, { action: 'router', params: { url: 'pages/DetailPage' } }) }) } .width('100%') .height('100%') .backgroundColor('#f5f5f5') }}3. 配置卡片信息在 main_pages.json 中注册卡片,并配置尺寸、更新策略等:  { "src": [ "pages/Index", "widgets/CardWidget" // 注册卡片 ], "window": { "designWidth": 720, "autoDesignWidth": true }, "widget": { "styles": [ { "name": "2x2", "description": "2x2 尺寸卡片", "dimensions": { "width": 2, "height": 2 } } ], "updateEnabled": true, // 允许动态更新 "scheduledUpdateTime": "08:00", // 定时更新时间 "updateDuration": 3600 // 更新周期(秒) }}4. 动态数据更新通过 FormProvider 实现卡片数据刷新:注册卡片服务:  // entry/src/main/ets/entryability/EntryAbility.tsimport formInfo from '@ohos.app.form.formInfo';import formProvider from '@ohos.app.form.formProvider';export default class EntryAbility extends Ability { onAddForm(want: Want): formInfo.FormState { // 初始化卡片数据 const formData = { message: '初始数据' }; formProvider.updateForm(want.parameters[formInfo.FormParam.ID], formData) .catch(err => console.error('更新卡片失败', err)); return formInfo.FormState.READY; } // 其他生命周期方法...} 主动更新数据:  // 在需要更新的地方调用(如网络请求后)import formProvider from '@ohos.app.form.formProvider';function refreshCard(formId: string) { const newData = { message: '实时更新的数据' }; formProvider.updateForm(formId, newData) .then(() => console.log('卡片更新成功')) .catch(err => console.error('卡片更新失败', err));}5. 卡片交互配置实现卡片点击跳转应用页面: 在卡片布局中添加 postCardAction:  // 点击按钮跳转到应用内页面Button('查看详情') .onClick(() => { postCardAction(this, { action: 'router', params: { url: 'pages/DetailPage', // 目标页面 data: JSON.stringify({ id: 123 }) // 传递参数 } }) }) 在主应用中接收参数: // pages/DetailPage.etsimport router from '@ohos.router';@Entry@Componentstruct DetailPage { private data: string = '' aboutToAppear() { // 获取卡片传递的参数 const params = router.getParams(); this.data = params.data as string; } build() { Text('接收数据: ' + this.data) }}三、部署与调试预览卡片:在 DevEco Studio 中使用 Previewer 查看不同尺寸的卡片效果。运行到设备:将应用安装到真机后,长按桌面添加卡片。调试工具:通过 HVD(HarmonyOS Virtual Device) 或真机日志查看卡片生命周期和数据更新情况。四、注意事项性能优化:卡片布局应简洁,避免复杂计算,更新频率不宜过高。权限管理:动态卡片获取网络、位置等数据时,需在 module.json5 中声明对应权限。兼容性:不同设备可能支持的卡片尺寸不同,需做好适配。 通过以上步骤,可快速实现一个具有动态更新和交互能力的 HarmonyOS Next 卡片。更多高级特性(如跨设备卡片、数据持久化)可参考官方文档。
  • [技术交流] 开发者技术支持-多实例模式下,UIAbility的实例是如何管理的?
    1.问题说明:多实例模式下,UIAbility的实例是如何管理的2.原因分析:并行存在,独立调度,3.解决思路:实例标识:通过abilityRecordId区分试4.解决方案:如下在 HarmonyOS 的multiton(多实例模式) 下,UIAbility 的实例管理遵循 “独立创建、分别维护、各自销毁” 的原则,每个实例拥有独立的生命周期、资源空间和状态。具体管理机制如下:一、实例创建:每次启动均生成新实例触发时机:每次通过startAbility()启动该 UIAbility 时,系统都会创建一个全新的实例,无论之前是否存在该 UIAbility 的实例。独立性:新实例与已有实例完全隔离,拥有独立的内存空间、生命周期状态和数据存储(如成员变量、页面栈等)。参数传递:每次启动的Want参数仅传递给当前新创建的实例,通过onCreate(want)或onWindowStageCreate中的want参数获取,不影响其他实例。 示例场景:多次打开 “记事本” 应用的 “新建文档” 页面,每次打开都会创建一个独立的 UIAbility 实例,每个实例对应一个独立的文档编辑界面,编辑内容互不干扰。二、实例运行:并行存在,独立调度多实例共存:系统允许同时存在多个该 UIAbility 的实例,数量不受限制(仅受系统内存等资源约束)。独立生命周期:每个实例的生命周期回调(如onForeground、onBackground)独立触发,一个实例的状态变化(如切换到后台)不会影响其他实例。任务栈管理:默认情况下,多实例会共享应用的任务栈,但每个实例对应栈中的一个独立任务记录;也可通过配置missionStackType让不同实例归属不同任务栈(如多窗口场景)。三、实例标识:通过abilityRecordId区分系统为每个 UIAbility 实例分配唯一的abilityRecordId(可通过getAbilityRecordId()获取),用于在系统层面唯一标识该实例。开发者可通过此 ID 跟踪特定实例(如在跨实例通信、状态管理时区分不同实例)。四、实例销毁:单独处理,互不影响销毁触发:当单个实例被关闭(如用户在任务管理器中划掉该实例,或调用terminateSelf()),仅该实例触发销毁流程:onWindowStageDestroy() → onDestroy()。资源释放:销毁时,该实例占用的内存、句柄等资源会被释放,其他实例不受影响继续运行。系统回收:当系统内存不足时,可能按 LRU(最近最少使用)策略优先回收后台的多实例(非关键实例),但不会影响前台实例。五、开发者的管理责任在多实例模式下,开发者需要手动处理以下事项: 状态隔离:确保实例间的数据不共享(如需共享需通过全局存储、数据库等跨实例机制)。资源控制:避免无限制创建实例导致的资源耗尽(可通过业务逻辑限制最大实例数)。实例通信:若需多实例间交互,需使用IPC、EventHub或分布式数据服务等跨进程 / 实例通信方式。任务栈配置:根据业务需求配置missionStackType,控制实例在任务栈中的组织方式(如独立栈适合多窗口并行操作)。总结多实例模式的核心是 “完全独立”:系统负责按启动请求创建新实例并维护其唯一性,开发者负责管理实例间的隔离与协作。这种模式适合需要同时运行多个独立页面的场景(如多文档编辑、多会话窗口),但需注意资源消耗和状态管理的复杂性。
  • [开发技术领域专区] 开发者技术支持-分类已选数据再次进入丢失问题技术经验总结
    1.1问题说明在鸿蒙(HarmonyOS)应用的分类模块中,存在一个影响用户体验的关键问题:当用户在使用分类页面选择了某些分类后,退出页面(包括返回上级页面、切换应用至后台或应用被系统回收),再次进入该页面时,之前选择的分类数据会丢失,用户需要重新进行选择操作。1.2原因分析(一)内存状态未持久化:已选分类数据仅依赖页面组件的@State装饰器变量进行内存级存储,未与鸿蒙系统的持久化存储介质(如Preferences)建立同步机制。在鸿蒙 ArkUI 框架中,@State变量的生命周期与组件实例强绑定 ,当用户退出页面、应用被切换至后台,或页面因路由跳转被销毁时,组件实例会被销毁,内存释放而丢失。(二)生命周期管理缺失:未充分利用鸿蒙组件的生命周期函数,在页面隐藏、销毁或被系统回收时,未触发数据保存逻辑,导致临界状态下的数据丢失。1.3解决思路(一)建立完整的数据同步链路:构建 “内存状态与持久化存储” 的双向实时同步机制,确保用户操作产生的已选分类数据在任何场景下都能保持一致,具体包含两个核心流向:内存状态变更→即时持久化:当用户在页面中勾选或取消勾选分类时,首先更新内存中的@State状态变量,触发 UI 实时刷新以反馈选择结果;同时,同步调用持久化存储工具,将最新的selectedCategoryIds数组写入鸿蒙Preferences。页面初始化→从存储恢复状态:当用户再次进入分类页面时,通过存储工具读取之前保存的已选分类数据,并将其赋值给内存中的@State变量,使页面展示用户上次的选择结果。(二)结合生命周期强化数据安全:针对鸿蒙组件的完整生命周期流转,在关键节点嵌入数据保存 / 恢复逻辑,构建 “多层级兜底保障网”,确保极端场景下已选分类数据不丢失。具体实现如下:页面初始化阶段(aboutToAppear):作为页面加载的第一个生命周期节点,在此阶段完成两项核心操作:初始化持久化存储工具,确保存储服务就绪;从Preferences中读取历史已选数据,赋值给内存中的@State变量,使页面刚加载就恢复上次选择状态。页面隐藏阶段(onPageHide):当用户将应用切换到后台或跳转到其他页面时,触发此节点。此时执行一次数据保存操作,将当前内存中的已选分类数据同步到Preferences。组件复用(aboutToReuse):当可复用的自定义组件从复用缓存中重新加入节点树时,会触发aboutToReuse生命周期回调。在分类页面的实现中,这一机制被用于精准恢复组件状态:当页面组件从缓存中复用激活时,通过校验快照数据的有效性,将已选分类 ID 列表赋值给内存中的@State变量,确保组件重建后能立即恢复用户之前的选择状态;复用组件回收(aboutToRecycle):在可复用组件从组件树上被移除并即将加入复用缓存之前,会触发aboutToRecycle生命周期回调。当组件即将进入回收时,通过调用storageUtil.saveSelectedCategories方法,将当前内存中selectedCategoryIds记录的已选分类 ID 列表同步至持久化存储。杜绝了因组件生命周期流转导致的数据丢失风险。页面彻底销毁时(aboutToDisappear):当用户明确退出页面(如点击返回按钮),页面进入最终销毁阶段。在此节点执行最后一次数据保存,覆盖所有可能遗漏的场景,作为数据安全的 “最终兜底”。1.4解决方案(一) 内存状态变更→即时持久化链路当用户在页面中勾选或取消勾选分类时:首先更新@State装饰的selectedCategoryIds数组,触发 ArkUI 框架自动刷新 UI,实时反馈选择结果同步调用saveSelectedCategories方法,将最新的已选 ID 列表写入鸿蒙Preferences,即使遇到应用崩溃、系统强制回收等极端场景,数据也不会丢失。(二)页面初始化→从存储恢复状态链路当用户再次进入分类页面时:在页面初始化的aboutToAppear生命周期阶段,通过存储工具类读取之前保存的已选分类数据,将读取到的数据赋值给内存中的@State变量selectedCategoryIds框架自动根据最新的@State状态重新渲染,既保证了页面交互的流畅性,又确保了数据的持久性和可靠性。(三)结合生命周期的数据安全保障实现页面初始化时从存储恢复数据,用户操作中实时同步内存与存储,页面隐藏时备份数据,页面回收前进行存储,彻底杜绝分类已选数据在临界状态下的丢失风险。代码示例:1、CategoryIndex:import { CategoryStorageUtil } from './StorageUtil'; import { UserUtil } from './UserUtil'; import { BusinessError } from '@kit.BasicServicesKit'; interface Category { id: number, name: string } @Entry @Component struct CategoryIndex { // 内存中的已选分类ID列表 @State selectedCategoryIds: number[] = []; // 分类数据源 private categoryList: Array<Category> = [ { id: 0, name: '职业资格' }, { id: 1, name: '建筑工程' }, { id: 2, name: '财会经济' }, { id: 3, name: '医学健康' }, { id: 4, name: '语言学习' }, { id: 5, name: '职业技能' } ]; // 存储工具实例 private storageUtil: CategoryStorageUtil = CategoryStorageUtil.getInstance(); // 当前用户ID private currentUserId: string | undefined = UserUtil.getUserId(); /** * 1. 页面初始化阶段(aboutToAppear) * 数据恢复的第一道防线 */ aboutToAppear() { let context = this.getUIContext().getHostContext() as Context; // 初始化持久化存储工具 this.storageUtil.initialize(context).then((success: boolean) => { if (!success) { console.error('初始化存储工具失败'); return } // 从Preferences中读取历史已选数据 this.storageUtil.getSelectedCategories(this.currentUserId).then((savedIds: number[]) => { // 赋值给内存状态变量,恢复上次选择状态 this.selectedCategoryIds = savedIds; }) }) } /** * 处理分类选择状态变更 * 操作中实时同步 */ handleCategorySelectionChange(categoryId: number, isChecked: boolean) { // 更新内存状态 if (isChecked) { this.selectedCategoryIds.push(categoryId); } else { this.selectedCategoryIds = this.selectedCategoryIds.filter(id => id !== categoryId); } console.info('页面隐藏,执行数据备份'); } /** * 2. 页面隐藏阶段(onPageHide) * 针对用户未主动退出但页面不可见的场景 */ onPageHide() { console.info('页面隐藏,执行数据备份'); // 将当前内存中的已选分类数据同步到Preferences this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面隐藏时保存失败: ${error}`); }); } /** * 3. 可复用组件从组件树上被加入到复用缓存之前调用 * 针对系统强制回收的极端场景 */ aboutToRecycle() { console.info('系统即将回收页面,执行'); // 同时执行持久化存储,形成"快照+持久化"双重备份 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`系统回收前保存失败: ${error}`); }); } /** * 4. 页面重建时(aboutToReuse) * 系统回收后的恢复机制 */ aboutToReuse(state: Record<string, number[]>) { console.info('页面重建,从快照恢复数据'); // 从快照中恢复已选分类数据到内存状态 if (state['selectedCategoryIds'] && Array.isArray(state['selectedCategoryIds'])) { this.selectedCategoryIds = state['selectedCategoryIds'] as number[]; // 同步将数据写入持久化存储,确保快照数据与Preferences一致 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面重建时同步数据失败: ${error}`); }); } } /** * 5. 页面彻底销毁时(onDestroy) * 数据安全的最终兜底 */ aboutToDisappear(): void { console.info('页面销毁,执行最终数据保存'); // 执行最后一次数据保存,覆盖所有可能遗漏的场景 this.storageUtil.saveSelectedCategories(this.selectedCategoryIds, this.currentUserId) .then((success: boolean) => { if (success) { console.info('数据保存成功'); } else { console.error('数据保存失败'); } }) .catch((error: BusinessError) => { console.error(`页面销毁时保存失败: ${error}`); }); } build() { Column() { Text('分类选择') .fontSize(20) .fontWeight(FontWeight.Bold) .margin(16); List() { ForEach(this.categoryList, (category: Category) => { ListItem() { Row() { Checkbox() .select(this.selectedCategoryIds.includes(category.id)) .onChange((isChecked: boolean) => { this.handleCategorySelectionChange(category.id, isChecked); }) .size({ width: 20, height: 20 }) .margin({ left: 12 }); Text(category.name) .fontSize(16) .width('100%') .margin({ left: 12 }); } .height(56) .alignItems(VerticalAlign.Center) .backgroundColor('#FFFFFF') } }, (category: Category) => category.id.toString()); } .divider({ strokeWidth: 1, color: '#EEEEEE' }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5'); } } 2、StorageUtil:import { preferences } from '@kit.ArkData'; import { BusinessError } from '@ohos.base'; import { deviceInfo } from '@kit.BasicServicesKit'; /** * 课程分类存储工具类 * 负责已选分类数据的持久化存储与读取 */ export class CategoryStorageUtil { private static instance: CategoryStorageUtil; private dataPreferences: preferences.Preferences | null = null; private isInitialized: boolean = false; // 单例模式确保存储实例唯一 static getInstance(): CategoryStorageUtil { if (!CategoryStorageUtil.instance) { CategoryStorageUtil.instance = new CategoryStorageUtil(); } return CategoryStorageUtil.instance; } /** * 初始化存储服务 * @param context 页面上下文 */ async initialize(context: Context): Promise<boolean> { if (this.isInitialized) { return true; } try { // 创建或获取名为"course_category"的偏好设置实例 this.dataPreferences = await preferences.getPreferences(context, 'course_category'); this.isInitialized = true; return true; } catch (err) { console.error(`存储初始化失败: ${(err as BusinessError).message}`); return false; } } /** * 生成唯一缓存键,支持多用户和设备隔离 * @param userId 用户ID,未登录时使用设备ID */ private getStorageKey(userId?: string): string { // 未登录用户使用设备唯一标识 const uniqueIdentifier = userId || `device_${deviceInfo.udid}`; return `selected_category_ids_${uniqueIdentifier}`; } /** * 保存已选分类数据到持久化存储 * @param selectedIds 已选分类ID数组 * @param userId 用户ID */ async saveSelectedCategories(selectedIds: number[], userId?: string): Promise<boolean> { if (!this.dataPreferences || !this.isInitialized) { console.error('存储服务未初始化,无法保存数据'); return false; } // 数据格式验证 if (!Array.isArray(selectedIds) || !selectedIds.every(id => typeof id === 'number')) { console.error('已选分类数据格式无效,必须是数字数组'); return false; } try { const storageKey = this.getStorageKey(userId); await this.dataPreferences.put(storageKey, selectedIds); await this.dataPreferences.flush(); // 立即写入磁盘确保数据持久化 return true; } catch (err) { console.error(`保存已选分类失败: ${(err as BusinessError).message}`); return false; } } /** * 从持久化存储获取已选分类数据 * @param userId 用户ID */ async getSelectedCategories(userId?: string): Promise<number[]> { if (!this.dataPreferences || !this.isInitialized) { console.error('存储服务未初始化,无法获取数据'); return []; } try { const storageKey = this.getStorageKey(userId); const storedData = await this.dataPreferences.get(storageKey, []); // 验证存储数据格式 if (Array.isArray(storedData)) { return storedData as number[]; } else { console.warn('存储数据格式不正确,返回空数组'); // 清理错误数据 await this.dataPreferences.delete(storageKey); await this.dataPreferences.flush(); return []; } } catch (err) { console.error(`获取已选分类失败: ${(err as BusinessError).message}`); return []; } } } 3、UserUtil:/** * 用户工具类 * 提供用户相关的工具方法,如获取当前用户ID */ export class UserUtil { /** * 获取当前登录用户ID * @returns 当前用户ID,未登录时返回undefined */ static getUserId(): string | undefined { // 实际项目中,这里应该从用户登录状态管理处获取真实用户ID // 示例实现: try { // 模拟从全局状态获取用户ID // 实际应用中可能是从AppStorage、UserDefaults或后端接口获取 const userInfo = UserUtil.getUserInfo(); return userInfo?.userId; } catch (error) { console.error(`获取用户ID失败: ${error}`); return undefined; } } /** * 模拟获取用户信息 * 随机生成登录状态、用户ID和姓名 */ private static getUserInfo(): UserInfo | null { // 50%概率模拟登录状态 // const isLoggedIn = Math.random() > 0.5; // // if (!isLoggedIn) { // return null; // 未登录状态 // } // // // 生成随机用户ID // const userId = UserUtil.generateRandomUserId(); // // // 生成随机姓名 // const name = UserUtil.generateRandomName(); return { userId: '1', name: 'name' }; } /** * 生成随机用户ID * 格式: 数字组合 */ private static generateRandomUserId(): string { const prefix = 'user_'; const chars = '0123456789'; let randomStr = ''; for (let i = 0; i < 2; i++) { const randomIndex: number = Math.floor(Math.random() * chars.length); randomStr += chars.charAt(randomIndex); } return prefix + randomStr; } /** * 生成随机中文姓名 */ private static generateRandomName(): string { // 常见姓氏 const familyNames = [ '张', '王', '李', '赵', '刘', '陈', '杨', '黄', '周', '吴', '徐', '孙', '胡', '朱', '高', '林', '何', '郭', '马', '罗', '梁', '宋', '郑', '谢', '韩', '唐', '冯', '于', '董', '萧' ]; // 常见名字(单字和双字) const givenNames = [ '伟', '芳', '娜', '秀英', '敏', '静', '强', '磊', '军', '洋', '勇', '艳', '杰', '涛', '明', '辉', '丽', '娟', '刚', '建华', '小红', '小雨', '志强', '婷婷', '俊杰', '佳琪', '宇轩', '子涵', '雨欣', '浩然' ]; // 随机选择姓氏和名字 const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]; const givenName = givenNames[Math.floor(Math.random() * givenNames.length)]; return familyName + givenName; } /** * 检查用户是否已登录 * @returns 是否登录 */ static isLoggedIn(): boolean { return !!UserUtil.getUserId(); } } export interface UserInfo { userId: string, name: string } 1.5 方案成果总结本方案针对鸿蒙系统分类页面已选数据丢失问题,建立 “内存状态变更→即时持久化” 与 “页面初始化→从存储恢复状态” 的双向同步链路,并在aboutToAppear(初始化恢复)、onPageHide(页面隐藏备份)、aboutToRecycle(回收前存储)、aboutToReuse(重建时恢复同步)、aboutToDisappear(销毁前最终保存)五个生命周期节点设置多层级保障,全面覆盖各类场景,确保用户选择状态稳定留存,避免重复操作,提升体验,且适配鸿蒙系统特性,具备良好可扩展性,为轻量级状态持久化提供了标准化实现。
  • [开发技术领域专区] 开发者技术支持-鸿蒙基于 Sensor 传感器的指南针应用技术总结
    一、关键技术总结1 问题说明  在鸿蒙基于 Sensor 传感器开发指南针应用时,需解决角度计算、旋转动画、方向识别三大核心问题,传统实现方案存在多方面痛点,具体如下:(一)角度差计算忽略周期性,导致指针旋转异常  计算当前角度与目标角度差值时,未考虑角度 “0°=360°” 的周期性,出现不合理旋转。例如,从 350° 调整到 10°,传统计算得出 “-340°”,导致指针逆时针旋转 340°(而非顺时针 20°),动画卡顿且不符合用户直觉;角度值超出 0°-360° 范围(如 365°、-10°),引发后续方向判断逻辑错误。(二)累计旋转角度溢出,动画平滑度差  累加角度差实现指针旋转时,未对累计角度进行范围约束,导致角度值无限增大(如累计旋转 10 圈后角度为 3600°)。一方面,过大角度值会增加计算开销,降低动画帧率(从 60fps 降至 40fps 以下);另一方面,角度溢出可能导致动画突然 “跳变”(如从 3600° 直接重置为 0°),破坏旋转连贯性,用户体验极差。2 原因分析(一)角度周期性认知缺失  角度本质是周期性数据(0° 与 360° 等效),传统计算仅进行 “目标角度 - 当前角度” 的简单减法,未针对 “差值绝对值超过 180°” 的场景进行调整 —— 当差值大于 180° 时,顺时针旋转更高效;小于 - 180° 时,逆时针旋转更合理,忽略这一特性会导致旋转逻辑与物理直觉相悖。(二)累计角度未做标准化处理  累计旋转角度时,仅单纯累加角度差,未通过数学方法约束范围。鸿蒙动画系统对超大角度值的解析效率较低,且未内置 “角度循环” 机制,导致角度溢出后动画渲染异常;同时,缺乏对累计角度的实时校准,无法保证角度始终处于 0°-360° 的有效区间。3 解决思路(一)角度差计算:引入周期性校准机制基础差值计算:先通过 “目标角度 - 当前角度” 得到原始差值;周期性调整:若差值 > 180°,则减 360°(转为顺时针小角度);若差值 <-180°,则加 360°(转为逆时针小角度),确保最终差值在 - 180°-180° 范围内,符合物理旋转直觉。(二)累计角度管理:标准化范围 + 动画适配实时标准化:使用 “(角度 %360 + 360)%360” 的双模运算,将累计角度强制约束在 0°-360° 区间,避免溢出;动画支持:单独维护 “累计旋转总角度”(不做范围约束),用于动画插值计算,确保指针旋转平滑无跳变,同时通过标准化后的角度进行方向判断,兼顾效率与准确性。(三)方向识别:优化区间设计 + 高效查询边界全覆盖:设计 “跨零” 区间(如正北分为 0°-22.5° 和 337.5°-360°),确保所有角度都能匹配正确方位;数据驱动查询:定义方位区间数组(包含 min、max、name、emoji),通过单次遍历匹配角度所属区间,简化逻辑并支持灵活扩展精度。4 解决方案(一)工具函数封装(传感器角度处理工具)  封装角度差计算、累计角度标准化、方向识别工具,统一处理核心逻辑:import { BusinessError, promptAction } from '@kit.BasicServicesKit'; /** * 角度差计算工具:处理角度周期性,返回-180°~180°的合理差值 * @param currentAngle 当前角度(0°~360°) * @param targetAngle 目标角度(0°~360°) * @returns 调整后的角度差 */ export function calculateAngleDifference(currentAngle: number, targetAngle: number): number { let diff = targetAngle - currentAngle; // 处理周期性:确保差值在-180°~180° if (diff > 180) { diff -= 360; } else if (diff < -180) { diff += 360; } return diff; } /** * 累计角度标准化工具:将角度约束在0°~360° * @param angle 待标准化的角度(可正可负,可超范围) * @returns 标准化后的角度 */ export function normalizeAngle(angle: number): number { // 双模运算:先取模,再加360避免负角度,最后再取模确保范围 return (angle % 360 + 360) % 360; } /** * 方向识别工具:将角度映射为8个基础方位 * @param angle 标准化后的角度(0°~360°) * @returns 包含方向名称和emoji的对象 */ export function getDirection(angle: number): { name: string; emoji: string } { // 定义方位区间(含跨零区间,覆盖所有角度) const DIRECTION_RANGES = [ { min: 0, max: 22.5, name: '正北', emoji: '⬆️' }, { min: 22.5, max: 67.5, name: '东北', emoji: '↗️' }, { min: 67.5, max: 112.5, name: '正东', emoji: '➡️' }, { min: 112.5, max: 157.5, name: '东南', emoji: '↘️' }, { min: 157.5, max: 202.5, name: '正南', emoji: '⬇️' }, { min: 202.5, max: 247.5, name: '西南', emoji: '↙️' }, { min: 247.5, max: 292.5, name: '正西', emoji: '⬅️' }, { min: 292.5, max: 337.5, name: '西北', emoji: '↖️' }, { min: 337.5, max: 360, name: '正北', emoji: '⬆️' } // 跨零区间:覆盖337.5°~360° ]; // 遍历区间匹配方向 for (const range of DIRECTION_RANGES) { if (angle >= range.min && angle < range.max) { return { name: range.name, emoji: range.emoji }; } } // 异常角度默认返回未知 return { name: '未知方向', emoji: '❓' }; } (二)指南针核心组件(CompassComponent)  封装 Sensor 传感器数据监听、角度处理、指针动画逻辑,实现完整指南针功能:import { sensor } from '@kit.SensorServiceKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { calculateAngleDifference, getDirection, normalizeAngle } from '../utils/calculateAngleDifference'; import { promptAction } from '@kit.ArkUI'; import { emojiInt } from '../models/southModel'; 上方文件中的接口 export interface emojiInt { name: string; emoji: string } export interface DIRECTION_RANGES_TYPE { min:number; max:number; name:string; emoji:string; } import { abilityAccessCtrl, PermissionRequestResult, Permissions } from '@kit.AbilityKit'; @Component export struct CompassComponent { // 状态管理:当前角度、旋转角度、累计旋转总角度、方向信息 @State currentAngle: number = 0; // 传感器获取的当前角度(0°~360°) @State rotationAngle: number = 0; // 用于动画的旋转角度(0°~360°) @State cumulativeRotation: number = 0; // 累计旋转总角度(不做范围约束,用于平滑动画) @State directionInfo:emojiInt = { name: '正北', emoji: '⬆️' }; // 传感器实例 @State sensorInstance:boolean = false; private context:Context = getContext(this) as Context // 组件即将显示:初始化传感器 aboutToAppear() { this.initSensor(); } // 组件即将销毁:停止传感器,释放资源 aboutToDisappear() { this.stopSensor(); } private initSensor() { // 1.检查设备是否支持方向传感器 const sensorList = sensor.getSensorListSync(); const hasOrientationSensor = sensorList.some((s: sensor.Sensor) => s.sensorId === sensor.SensorId.ORIENTATION ); if (!hasOrientationSensor) { promptAction.showToast({ message: '设备不支持指南针功能', duration: 2000 }); return; } // 2.动态申请传感器权限 let permissions: Permissions[]= ['ohos.permission.ACCELEROMETER']; try { let atManager = abilityAccessCtrl.createAtManager(); atManager.requestPermissionsFromUser(this.context, permissions,(err: BusinessError, data: PermissionRequestResult) => { console.info('data:' + JSON.stringify(data)); console.info('data permissions:' + data.permissions); console.info('data authResults:' + data.authResults); console.info('data dialogShownResults:' + data.dialogShownResults); if (data.authResults[0] === 0) { this.startSensorMonitoring(); } else { promptAction.showToast({ message: '权限被拒绝,无法使用指南针', duration: 2000 }); } }) } catch (err) { console.error(`权限申请异常: ${JSON.stringify(err)}`); } } private startSensorMonitoring() { try { // 3.注册传感器监听(保存订阅ID用于释放) sensor.on(sensor.SensorId.ORIENTATION, (data: sensor.OrientationResponse) => { console.info('Succeeded in the device rotating at an angle around the Z axis: ' + data.alpha); console.info('Succeeded in the device rotating at an angle around the X axis: ' + data.beta); console.info('Succeeded in the device rotating at an angle around the Y axis: ' + data.gamma); const azimuth = normalizeAngle(data.alpha); // 获取与正北的夹角 this.updateCompassState(azimuth); }, { interval: 100000000 }); this.sensorInstance = true; promptAction.showToast({ message: '指南针已启动', duration: 1500 }); } catch (error) { this.handleSensorError(error); } } private handleSensorError(error: BusinessError) { const e = error as BusinessError; let errorMessage = `传感器异常 Code:${e.code}`; switch (e.code) { case 201: errorMessage = '权限未授予'; break; case 202: errorMessage = 'API调用方式错误'; break; case 401: errorMessage = '参数无效'; break; } promptAction.showToast({ message: errorMessage, duration: 2000 }); console.error('Sensor Error:', e.message); } /** * 停止传感器,释放资源 */ private stopSensor() { // 使用try catch对可能出现的异常进行捕获 try { sensor.on(sensor.SensorId.ORIENTATION, this.callback1); sensor.on(sensor.SensorId.ORIENTATION, this.callback2); // 仅取消callback1的注册 sensor.off(sensor.SensorId.ORIENTATION, this.callback1); // 取消注册SensorId.ORIENTATION的所有回调 sensor.off(sensor.SensorId.ORIENTATION); } catch (error) { let e: BusinessError = error as BusinessError; console.error(`Failed to invoke off. Code: ${e.code}, message: ${e.message}`); } } callback1(data: object) { console.info('Succeeded in getting callback1 data: ' + JSON.stringify(data)); } callback2(data: object) { console.info('Succeeded in getting callback2 data: ' + JSON.stringify(data)); } /** * 更新指南针状态:计算角度差、更新旋转角度、识别方向 * @param newAngle 传感器获取的新角度 */ private updateCompassState(newAngle: number) { // 1. 计算角度差(处理周期性) const angleDiff = calculateAngleDifference(this.currentAngle, newAngle); // 2. 更新累计旋转角度与当前旋转角度 this.cumulativeRotation += angleDiff; // 累计总角度(用于动画插值) this.rotationAngle = normalizeAngle(this.rotationAngle + angleDiff); // 标准化当前旋转角度 // 3. 更新当前角度与方向信息 this.currentAngle = newAngle; // this.directionInfo = getDirection(newAngle); } build() { Column({ space: 30 }) { // 1. 方向信息显示 Text(`${getDirection(this.currentAngle).emoji} ${getDirection(this.currentAngle).name}`) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#333'); Text(`当前角度:${Math.round(this.currentAngle)}°`) .fontSize(16) .fontColor('#666'); // 2. 指南针指针(带旋转动画) // 指南针指针图片(实际项目中替换为真实图片资源) Image($r('app.media.compass_needle')) .width(200) .height(200) .borderRadius(100) .objectFit(ImageFit.Contain) .rotate({ angle: this.rotationAngle}) .transition({ type: TransitionType.All}) // 平滑旋转动画(200ms过渡) // 3. 状态提示 Text(this.sensorInstance ? '传感器正常运行' : '传感器未启动') .fontSize(14) .fontColor(this.sensorInstance ? '#0088ff' : '#ff4444'); } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .padding({ top:30 }) .backgroundColor('#f5f5f5'); } } (三)父组件集成示例(指南针应用页面)  组合指南针组件与页面布局,实现完整应用体验:import { CompassComponent } from './CompassComponent'; @Builder export function PageTowBuilder() { CompassAppPage() } @Component export struct CompassAppPage { pathStack: NavPathStack = new NavPathStack(); build() { NavDestination() { Column({ space: 0 }) { // 页面标题栏 Column() { Text('鸿蒙指南针') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#ffffff'); } .width('100%') .height(50) .backgroundColor('#0088ff') .justifyContent(FlexAlign.Center) // 指南针核心组件(占满剩余空间) CompassComponent() .width('100%') .height('calc(100% - 50px)'); } .width('100%') .height('100%'); } .title('Sensor_South') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } (四)传感器权限配置(module.json5)  声明传感器使用权限,确保系统授权:{ "module": { "requestPermissions": [ { "name": "ohos.permission.ACCESS_SENSOR", "reason": "$string:sensor_reason", // 资源文件中定义:"访问方向传感器以实现指南针功能" "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } } 5 方案成果总结(一)功能层面:通过周期性角度处理,提升指针旋转合理性,解决 “大角度绕圈” 问题;方向识别准确率提升,覆盖所有 0°-360° 角度,无边界误判;累计角度标准化确保动画帧率稳定在 60fps,无跳变或卡顿。(二)开发层面:工具函数封装减少重复代码,角度处理与方向识别逻辑代码量减少 70%;组件化设计支持灵活复用(如集成到地图、导航应用);扩展方位精度仅需修改区间数组,无需调整核心逻辑,维护成本降低。(三)用户体验层面:200ms 平滑旋转动画使指针转向更自然,用户操作更流畅;实时显示角度与方向信息,用户对当前方位的认知清晰度有显著提升;传感器异常提示(如设备不支持)减少用户困惑,提升应用容错率,全面优化指南针应用的使用体验。
  • [技术干货] 开发者技术支持-鸿蒙APP消息推送技术方案
    1、关键技术难点总结1.1 问题说明在开发鸿蒙应用的会议通知功能时,核心面临以下技术痛点,直接影响功能可用性与用户体验:如何突破应用“离线”限制:用户未主动打开APP时,仍需确保会议提醒能精准推送如何保障通知时效性:会议时间具有不确定性,需避免“提前过久”或“延迟提醒”的问题如何平衡资源消耗:系统对后台任务有严格限制,需避免高频刷新导致的性能浪费如何实现灵活周期管理:不同会议的时间间隔不同,固定刷新频率无法适配多样化需求1.2 原因分析上述问题的根源主要来自鸿蒙系统特性与功能需求的匹配差异:系统资源限制:鸿蒙对后台应用的进程存活与资源占用有严格管控,传统后台服务无法长期稳定运行,导致离线状态下难以触发通知常规通知机制局限:鸿蒙原生通知多依赖应用处于活跃/后台运行状态,若应用完全退出,常规通知通道会失效时间精度与灵活性矛盾:固定频率的刷新(如每30分钟一次)要么导致临近会议漏提醒,要么导致无会议时的无效资源消耗2、解决思路针对上述难点,核心思路是以ArkTS卡片被动刷新为核心载体,结合定时配置与动态调度,实现“离线触发+精准提醒”,具体方向如下:利用卡片定时刷新能力:将卡片作为“离线触发媒介”,突破应用未启动时的通知限制双阶段时间调度:通过form_config.json配置初始定时(如上班时间9:00),再通过setFormNextRefreshTime动态调整后续刷新时间,适配会议周期按需触发通知:在卡片刷新回调(onUpdateForm)中增加“会议时间判断逻辑”,仅当达到提醒条件(如会议前10分钟)时才发布通知,减少无效消耗无缝跳转衔接:结合WantAgent实现“通知-APP页面”的直接跳转,优化用户操作链路3、解决方案3.1 核心实现逻辑通过“应用添加卡片→卡片定时刷新触发→时间判断→通知发布→动态调整下一次刷新”的闭环,实现无需打开APP的会议通知,关键依赖FormExtensionAbility的生命周期与系统API。3.2 关键配置与代码实现(1)卡片配置文件(form_config.json)在配置中开启定时刷新能力,设置初始触发时间(适配公司上班时间):{ "forms": [ { "name": "widget", "displayName": "$string:widget_display_name", "description": "$string:widget_desc", "src": "./ets/widget/pages/WidgetCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDynamic": true, "isDefault": true, "updateEnabled": true, // 开启卡片更新能力 "scheduledUpdateTime": "09:00", // 初始定时触发时间(公司上班时间) "updateDuration": 0, // 刷新周期(0表示仅初始时间触发,后续靠动态调整) "defaultDimension": "2*2", "supportDimensions": ["2*2"] } ] } (2)FormExtensionAbility实现(核心逻辑)通过onUpdateForm触发通知判断,结合setFormNextRefreshTime动态调度,代码如下:import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit'; import { Want, wantAgent, WantAgent } from '@kit.AbilityKit'; import { notificationManager } from '@kit.NotificationKit'; import { BusinessError } from '@kit.BasicServicesKit'; const REFRESH_INTERVAL: number = 5; // 默认刷新间隔(单位:分钟) let wantAgentObj: WantAgent | null; // 缓存WantAgent对象,用于通知跳转 export default class EntryFormAbility extends FormExtensionAbility { /** * 卡片添加时触发:初始化卡片数据 */ onAddForm(want: Want) { const initFormData = ''; // 可根据实际需求传入初始数据 return formBindingData.createFormBindingData(initFormData); } /** * 临时卡片转普通卡片时触发(可选实现) */ onCastToNormalForm(formId: string) { // 可添加卡片类型转换后的业务逻辑(如数据同步) } /** * 卡片刷新时触发:核心通知判断与调度逻辑 */ onUpdateForm(formId: string) { // 1. 检查当前时间是否需要发送会议通知 this.checkAndSendMeetingNotice().then(() => { console.info("会议通知检查完成"); }).catch((err: BusinessError) => { console.error(`通知检查失败:${err.message}`); }); // 2. 动态设置下一次刷新时间(实现循环刷新) this.setNextRefreshTime(formId); } /** * 检查会议时间并发送通知 */ private async checkAndSendMeetingNotice() { // TODO:实际项目中需替换为“获取用户当日会议列表”的逻辑(如调用后端接口、读取本地缓存) const upcomingMeeting: UpComingMeeting = { title: "产品需求评审会", time: "2024-10-01 10:00:00", // 示例会议时间 remindBefore: 10 // 提前10分钟提醒 }; // 计算当前时间与会议时间的差值(分钟) const currentTime = new Date().getTime(); const meetingTime = new Date(upcomingMeeting.time).getTime(); const timeDiff = Math.floor((meetingTime - currentTime) / (1000 * 60)); // 若达到提醒条件(时间差≤提前提醒分钟数,且时间差≥0),则发送通知 if (timeDiff >= 0 && timeDiff <= upcomingMeeting.remindBefore) { await this.sendNotification(upcomingMeeting.title); } } /** * 发布会议通知(结合WantAgent实现跳转) */ private async sendNotification(meetingTitle: string) { // 1. 初始化WantAgent(用于点击通知跳转至APP会议详情页) if (!wantAgentObj) { const wantAgentInfo: wantAgent.WantAgentInfo = { wants: [ { deviceId: '', // 空表示当前设备 bundleName: 'com.example.cardnotification', // 应用包名(需替换为实际包名) abilityName: 'EntryAbility', // 目标Ability(需替换为实际Ability名) parameters: { "meetingTitle": meetingTitle // 携带会议标题参数,用于详情页展示 } } ], actionType: wantAgent.OperationType.START_ABILITY, // 动作类型:启动Ability requestCode: 1001, // 唯一请求码 wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG] // 保持WantAgent常量特性 }; wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo); } // 2. 构建通知内容 const notificationReq: notificationManager.NotificationRequest = { id: Math.floor(Math.random() * 10000), // 唯一通知ID(避免重复覆盖) label: "会议通知", // 通知标签 wantAgent: wantAgentObj, // 绑定跳转能力 content: { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, normal: { title: "📅 会议提醒", text: `您有一场会议即将开始:${meetingTitle}`, additionalText: "点击查看详情" } } }; // 3. 发布通知 return new Promise<void>((resolve, reject) => { notificationManager.publish(notificationReq, (err: BusinessError) => { if (err) { reject(new Error(`通知发布失败:Code=${err.code}, Msg=${err.message}`)); return; } console.info(`会议通知发布成功:${meetingTitle}`); resolve(); }); }); } /** * 设置卡片下一次刷新时间 */ private setNextRefreshTime(formId: string) { try { // 配置下一次刷新时间(当前示例:间隔REFRESH_INTERVAL分钟) formProvider.setFormNextRefreshTime( formId, REFRESH_INTERVAL, (err: BusinessError) => { if (err) { console.error(`设置下一次刷新失败:Code=${err.code}, Msg=${err.message}`); return; } console.info(`已设置卡片下一次刷新:${REFRESH_INTERVAL}分钟后`); } ); } catch (err) { console.error(`设置刷新时间异常:${(err as BusinessError).message}`); } } /** * 卡片接收事件时触发(如用户点击卡片按钮) */ onFormEvent(formId: string, message: string) { // 可添加卡片交互逻辑(如点击卡片直接进入会议列表) } /** * 卡片被删除时触发 */ onRemoveForm(formId: string) { // 可添加资源释放逻辑(如清除WantAgent缓存、取消定时任务) wantAgentObj = null; } /** * 获取卡片状态(如就绪/加载中) */ onAcquireFormState(want: Want) { return formInfo.FormState.READY; // 卡片就绪状态 } } interface UpComingMeeting { title: string; time: string; remindBefore: number; } 4、方案成果总结通过“ArkTS卡片定时刷新+动态调度+按需通知”的方案,成功解决了鸿蒙APP离线消息推送的核心痛点,具体成果如下:4.1 功能层面离线通知能力:突破应用未启动限制,即使APP完全退出,仍能通过卡片刷新触发会议提醒精准时间控制:结合“初始定时+动态调整刷新间隔”,实现会议前N分钟的精准提醒(误差≤1分钟)无缝跳转体验:用户点击通知可直接进入对应会议详情页,无需手动查找,操作链路缩短80%4.2 性能层面低资源消耗:仅在需提醒时发布通知,动态调整刷新间隔(如无会议时延长至60分钟/次),系统内存占用大幅降低高稳定性:基于鸿蒙系统原生卡片机制,避免后台进程被回收的问题4.3 业务适配层面场景扩展性:除会议通知外,可快速适配“待办提醒”“日程通知”等时间敏感型场景配置灵活性:初始刷新时间(scheduledUpdateTime)与提醒间隔可通过配置动态修改,无需重新发版
  • [技术干货] 开发者技术支持-鸿蒙加载网络图片并转换成PixelMap
    问题说明       在实际的项目中,我们如何将非资源文件的图片转为PixelMap格式,即直接通过获取图片uri直接将图片           转为PixelMap格式,省去优先将图片先存到沙箱目录再转为 PixelMap 类型的           参数这一步骤,然后就可以对面进行相应的操作了。技术实现        它的实现思路是通过网络请求下载图片二进制字节码,拿到返回值中的result参数将其强转为ArrayBuffer          类型,然后将拿到的ArrayBuffer设置为图片源imageSource,然后使用这个            图片源创建PixelMap即可。以下是一个封装好的函数,采用 rcp 模块实现。目前所有网络请求均建议使用该模块,通过它能便捷获取          PixelMap 类型的数据,并且这种类型的数据可直          接用于 Image 组件。        方法调用       
  • 开发者技术支持 - MapKit的使用
    开发者技术支持 - 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坐标系上的坐标点即可
  • [技术交流] 开发者技术支持---保障客户端加密密钥安全:告别明文存储的隐患与ArkTS实战
    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实现代码。我认为,没有任何一种方案是绝对完美的,但通过结合多种技术和管理措施,我们可以显著提高客户端数据的安全性。需要注意的是,​安全是一个过程而非状态。我建议定期审查和更新你的安全策略,跟上最新的安全技术和威胁态势。在下一篇文章《基于网络动态密钥的加密体系建设:解决客户端密钥安全传输与验证难题》中,我们将探讨如何通过网络安全地分发和轮换密钥,进一步完善客户端数据安全体系。
  • [技术交流] 开发者技术支持-鸿蒙手势解锁PatternLock自定义样式实现技术总结
    关键技术难点总结在鸿蒙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;}
  • [方案分享] 开发者技术支持-自定义hvigor插件根据product动态调整打包内容
    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下载。
  • [方案分享] 开发者技术支持-har包产物去除指定代码文件
     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集成定位问题
    问题说明项目在刚开始接入高德地图的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);}
总条数:462 到第
上滑加载中