-
1.1 问题说明在日程管理、会议提醒、旅行助手等 HarmonyOS 原生应用场景中,用户期望在一个统一的入口(应用内)管理自己的时间安排,并希望这些安排能够自动同步到系统日历,以便利用系统级的通知提醒和跨设备同步能力。传统的独立数据库存储方式无法与系统日历互通,导致用户错过重要提醒或在不同设备间信息割裂。本案例通过集成 Calendar Kit (日历服务) ,实现应用内日程与系统日历的双向互通,用户在应用内创建的日程可直接写入系统日历数据库,并支持查询、更新和删除操作,从而提升用户的时间管理效率与体验。1.2 原因分析· 数据隔离与同步难题 应用沙箱机制导致应用数据默认隔离若不使用系统标准接口,无法将业务数据(如航班信息、会议安排)注入系统日历,用户无法享受到系统级的负一屏提醒、手表端同步等服务。· 权限与隐私安全管控 日历数据属于用户敏感个人信息开发者直接操作底层数据库不仅风险高,且容易因权限申请不规范导致审核驳回或运行时崩溃。· 日历账户与事件模型复杂 系统日历涉及账户(CalendarAccount)、日历(Calendar)、日程(Event)、参与人(Attendee)等多层级关系。若逻辑处理不当,会导致日程归属混乱、重复提醒或同步失败。1.3 解决思路· 标准化接口调用 采用 calendarManager 作为统一入口,通过系统提供的标准 API 进行日历账户的管理和事件的 CRUD(增删改查)操作,屏蔽底层数据库差异。动态坐标映射与偏移补偿机制,动态权限生命周期管理 严格遵循 user_grant 权限规范,在业务触发时动态申请 READ_CALENDAR 和 WRITE_CALENDAR 权限,确保合规访问。· 结构化数据映射 建立业务模型到 calendarManager.Event 的映射机制,精确配置日程的标题、时间、地点、提醒规则(Reminders)及重复规则(RecurrenceRule),确保信息准确展示。1.4 解决方案权限配置与申请"requestPermissions": [ { "name": "ohos.permission.READ_CALENDAR", "reason": "$string:reason_calendar", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, { "name": "ohos.permission.WRITE_CALENDAR", "reason": "$string:reason_calendar", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }]日程管理核心逻辑// 1. 获取日历管理器const calendarMgr = calendarManager.getCalendarManager(getContext(this)); // 2. 插入日程方法async function addScheduleToSystem(title: string, startTime: number, endTime: number) { try { // 获取默认日历账户(通常是本地账户) const calendar = await calendarMgr.getCalendar(); // 构造日程对象 const event: calendarManager.Event = { title: title, type: calendarManager.EventType.NORMAL, startTime: startTime, endTime: endTime, reminderTime: [15], // 提前15分钟提醒 location: { location: '线上会议室' }, description: '来自应用助手的自动同步日程' }; // 写入系统日历 const eventId = await calendar.addEvent(event); console.info(`日程创建成功,ID: ${eventId}`); return eventId; } catch (error) { let err: BusinessError = error as BusinessError; console.error(`日程创建失败: ${err.code}, ${err.message}`); }} 1.5 总结· 问题与痛点:应用日程与系统割裂,用户缺乏统一视角;敏感权限处理复杂。· 技术要点:利用 Calendar Kit 的 CalendarManager 进行统一管理,结合 Event 数据模型实现精准的日程属性控制。· 实现效果:用户在应用内一键添加日程后,系统日历即时同步,并能通过系统通知中心准时触发提醒,显著增强了应用的服务触达能力。· 适用场景:办公协作类应用(会议同步)、出行类应用(行程提醒)、教育类应用(课程表同步)、医疗类应用(服药/复诊提醒)。
-
1.1 问题说明点击语音播放后闪退是音频类应用高频问题,典型场景包括:本地语音文件(如提示音、用户录音)点击播放立即闪退,日志提示 “file not found”“permission denied” 或 “null pointer exception”;网络语音流(如云存储语音、接口返回音频数据)播放闪退,尤其弱网环境或大文件场景,常伴随 “ANR”“out of memory” 日志;多语音连续播放(如聊天语音列表)时,切换播放第 2 条及以后闪退,日志显示 “audio renderer already in use”;应用退后台后返回前台,点击语音播放闪退,提示 “resource released”;特定机型(如折叠屏、低内存设备)播放高码率语音文件闪退,触发系统内存管控。 1.2 原因分析资源访问异常:本地语音文件路径拼接错误(如绝对路径未适配沙箱机制)、文件损坏或格式不兼容;网络语音流未处理加载超时、数据不完整,导致播放器初始化失败;权限配置缺失:未申请音频播放核心权限(如ohos.permission.MEDIA_PLAYBACK),或后台音频播放未配置backgroundModes,触发权限校验失败闪退;线程调度不当:在 UI 线程执行音频文件加载、网络请求等耗时操作,导致主线程阻塞,触发系统 ANR 或闪退;播放器管理混乱:重复创建音频播放器实例、未释放资源(如播放完成后未调用release()),导致资源泄漏;播放器状态未监听(如未初始化完成就调用play()),触发空指针异常;内存与格式适配问题:高码率、大体积语音文件未做分片处理,导致内存溢出;音频格式(如特殊编码的 MP3、无损格式)未被系统解码器支持,触发解码失败闪退;生命周期绑定缺失:播放器实例未与 Ability/Pages 生命周期绑定,页面销毁后仍持有播放器引用,再次创建时冲突闪退。 1.3 解决思路规范资源访问:本地语音文件采用沙箱路径校验,网络语音流实现预加载 + 断点续传,确保资源可达、数据完整;权限合规适配:申请前台 + 后台音频播放权限,在config.json中配置对应后台运行模式;线程优化:耗时操作(资源加载、网络请求、解码)放入子线程,UI 线程仅处理播放状态回调与界面更新;播放器标准化管理:采用单例模式避免重复创建,绑定生命周期实现自动资源释放,添加全流程异常捕获;格式与内存优化:提前检测音频格式兼容性,大文件采用分片加载 + 流式播放,减少内存占用;状态监听与容错:监听播放器初始化、播放、暂停、释放全状态,处理空指针、解码失败等异常场景。 1.4 解决方案案例一:本地语音文件播放闪退(技术方向:资源校验 + 权限 + 播放器单例)场景描述:应用点击本地语音文件(如/data/storage/el2/base/xxx/voice/alert.mp3)播放立即闪退,日志显示 “file not found” 或 “permission denied”,部分机型提示 “audio renderer init failed”。技术方案:适配沙箱路径访问、申请音频播放权限、通过单例模式管理播放器,添加路径校验与全流程异常捕获,避免重复创建与资源泄漏。核心代码片段: // 1. 权限配置(config.json){ "module": { "abilities": [ { "name": ".VoicePlayAbility", "backgroundModes": ["audioPlayback"], // 后台音频播放权限 "permissions": [ { "name": "ohos.permission.MEDIA_PLAYBACK", // 音频播放核心权限 "grantMode": "system_grant" }, { "name": "ohos.permission.READ_MEDIA", // 读取媒体文件权限(本地语音) "grantMode": "user_grant" } ] } ] }}// 2. 播放器单例管理类(避免重复创建与资源泄漏)import audio from '@ohos.multimedia.audio';import fs from '@ohos.file.fs';import abilityAccessCtrl from '@ohos.abilityAccessCtrl';class VoicePlayerManager { private static instance: VoicePlayerManager; private audioRenderer: audio.AudioRenderer | null = null; private isPlaying: boolean = false; // 单例模式,确保全局唯一播放器实例 public static getInstance(): VoicePlayerManager { if (!VoicePlayerManager.instance) { VoicePlayerManager.instance = new VoicePlayerManager(); } return VoicePlayerManager.instance; } // 校验本地文件路径(适配沙箱机制) private async checkLocalFile(path: string): Promise { try { // 1. 校验路径是否存在 const fileStat = await fs.stat(path); if (!fileStat.isFile()) { console.error(`路径不是文件:${path}`); return false; } // 2. 校验文件权限 const atManager = abilityAccessCtrl.createAtManager(); const permissionResult = await atManager.verifyAccessToken( abilityAccessCtrl.createTokenID(), 'ohos.permission.READ_MEDIA' ); if (permissionResult === abilityAccessCtrl.GrantStatus.PERMISSION_DENIED) { console.error("无读取媒体文件权限"); return false; } return true; } catch (err) { console.error(`文件校验失败:${err.message}`); return false; } } // 初始化音频播放器 private async initRenderer(path: string): Promise> { try { // 释放已有播放器资源 if (this.audioRenderer) { await this.releaseRenderer(); } // 音频播放器配置(适配本地文件) const audioRendererConfig: audio.AudioRendererConfig = { usage: audio.StreamUsage.STREAM_USAGE_MEDIA, // 媒体播放场景 contentType: audio.ContentType.CONTENT_TYPE_SPEECH, // 语音类型 rendererFlags: audio.RendererFlags.RENDERER_FLAG_NONE }; // 创建播放器实例 this.audioRenderer = await audio.createAudioRenderer(audioRendererConfig); // 设置播放源(本地文件路径) await this.audioRenderer.setSource(path); // 准备播放 await this.audioRenderer.prepare(); return true; } catch (err) { console.error(`播放器初始化失败:${err.message}`); this.audioRenderer = null; return false; } } // 播放本地语音文件 public async playLocalVoice(path: string): Promise<boolean> { try { // 1. 路径校验 const isFileValid = await this.checkLocalFile(path); if (!isFileValid) return false; // 2. 初始化播放器 const isRendererReady = await this.initRenderer(path); if (!isRendererReady) return false; // 3. 开始播放 await this.audioRenderer!.start(); this.isPlaying = true; console.log(`开始播放本地语音:${path}`); // 4. 监听播放完成事件 this.audioRenderer!.on('finish', () => { this.isPlaying = false; this.releaseRenderer(); // 播放完成释放资源 }); return true; } catch (err) { console.error(`播放本地语音失败:${err.message}`); this.isPlaying = false; this.releaseRenderer(); return false; } } // 释放播放器资源 public async releaseRenderer(): Promise { if (this.audioRenderer) { try { if (this.isPlaying) { await this.audioRenderer.stop(); } await this.audioRenderer.release(); console.log("播放器资源已释放"); } catch (err) { console.error(`释放播放器失败:${err.message}`); } finally { this.audioRenderer = null; this.isPlaying = false; } } }}// 3. 页面调用示例(Pages/VoicePlayPage.ets)const voicePlayer = VoicePlayerManager.getInstance();// 点击播放按钮触发async function onPlayLocalVoice() { // 本地语音文件路径(适配沙箱:使用应用私有目录路径) const localVoicePath = `${getContext().cacheDir}/voice/alert.mp3`; const playSuccess = await voicePlayer.playLocalVoice(localVoicePath); if (!playSuccess) { // 提示用户播放失败(UI交互脱敏处理) promptAction.showToast({ message: '播放失败,请检查文件' }); }}// 页面销毁时释放资源(绑定生命周期)aboutToDisappear() { voicePlayer.releaseRenderer();}验证方法:测试不同格式本地语音文件(MP3、WAV、AAC,系统支持格式),确保点击播放无闪退;故意传入错误路径、无权限路径,验证无闪退且提示正常;连续点击播放按钮 10 次以上,验证单例机制生效,无重复创建导致的资源冲突;在 3 种不同配置机型(低内存、折叠屏、平板)测试,播放大体积(50MB)本地语音无闪退。案例二:网络语音流播放闪退(技术方向:子线程预加载 + 断点续传 + 内存优化)场景描述:点击播放网络语音流(如https://xxx.com/voice/stream?id=xxx)时闪退,弱网环境或大文件(>10MB)场景概率更高,日志显示 “ANR”“out of memory” 或 “stream closed unexpectedly”。技术方案:子线程预加载网络音频数据、实现断点续传避免数据中断、分片播放减少内存占用、监听网络状态与播放器状态,确保弱网与大文件场景稳定播放。核心代码片段: // 1. 权限配置(config.json,新增网络权限){ "module": { "abilities": [ { "name": ".NetworkVoiceAbility", "backgroundModes": ["audioPlayback", "network"], // 音频+网络后台权限 "permissions": [ { "name": "ohos.permission.MEDIA_PLAYBACK" }, { "name": "ohos.permission.INTERNET" // 网络访问权限 } ] } ] }}// 2. 网络语音播放管理类(子线程预加载+断点续传)import audio from '@ohos.multimedia.audio';import http from '@ohos.net.http';import buffer from '@ohos.buffer';import taskpool from '@ohos.taskpool';class NetworkVoicePlayer { private audioRenderer: audio.AudioRenderer | null = null; private httpClient: http.HttpClient | null = null; private isPlaying: boolean = false; private isLoading: boolean = false; private totalData: Uint8Array[] = []; // 存储分片数据 private loadedLength: number = 0; // 已加载数据长度 private totalLength: number = 0; // 总数据长度 private currentOffset: number = 0; // 断点续传偏移量 // 初始化HTTP客户端(配置超时与断点续传) private initHttpClient(): void { this.httpClient = http.createHttpClient(); // 配置超时时间(避免弱网阻塞) this.httpClient.setTimeout({ connect: 10000, read: 15000 }); } // 子线程预加载网络音频数据(避免UI线程阻塞) @taskpool.task private async loadAudioData(url: string, offset: number): Promise data: Uint8Array; done: boolean }> { try { const request = http.createHttpReqOptions(); request.url = url; // 设置断点续传请求头 request.header = { Range: `bytes=${offset}-` }; const response = await this.httpClient!.request(request); if (response.responseCode !== 206 && response.responseCode !== 200) { throw new Error(`网络请求失败:${response.responseCode}`); } // 获取总数据长度(从响应头解析) const contentRange = response.header['Content-Range'] as string; if (contentRange) { this.totalLength = Number(contentRange.split('/')[1]); } // 转换响应数据为Uint8Array const data = buffer.from(response.result as ArrayBuffer); const done = this.loadedLength + data.length >= this.totalLength; return { data, done }; } catch (err) { console.error(`加载音频数据失败:${err.message}`); throw err; } } // 初始化音频播放器(流式播放配置) private async initAudioRenderer(): Promise try { if (this.audioRenderer) { await this.releaseResources(); } const audioConfig: audio.AudioRendererConfig = { usage: audio.StreamUsage.STREAM_USAGE_SPEECH, contentType: audio.ContentType.CONTENT_TYPE_SPEECH, rendererFlags: audio.RendererFlags.RENDERER_FLAG_STREAMING // 流式播放标记 }; this.audioRenderer = await audio.createAudioRenderer(audioConfig); await this.audioRenderer.prepare(); return true; } catch (err) { console.error(`流式播放器初始化失败:${err.message}`); return false; } } // 分片播放音频数据(避免内存溢出) private async playSegments(): Promise { try { while (this.totalData.length > 0 && this.isPlaying) { const segment = this.totalData.shift()!; // 写入播放器缓冲区 const written = await this.audioRenderer!.write(segment); if (written { console.warn(`数据写入不完整,已写:${written},总长度:${segment.length}`); // 未写入部分重新加入队列 this.totalData.unshift(segment.subarray(written)); await new Promise(resolve => setTimeout(resolve, 100)); // 等待缓冲区空闲 } } } catch (err) { console.error(`分片播放失败:${err.message}`); this.isPlaying = false; } } // 开始播放网络语音流 public async playNetworkVoice(url: string): Promise { if (this.isPlaying || this.isLoading) return false; try { this.isLoading = true; this.initHttpClient(); const rendererReady = await this.initAudioRenderer(); if (!rendererReady) return false; // 启动子线程加载数据 const loadTask = taskpool.execute(this.loadAudioData.bind(this), url, this.currentOffset); this.isPlaying = true; // 监听数据加载完成回调 loadTask.then(async (result) => { this.loadedLength += result.data.length; this.totalData.push(result.data); // 启动分片播放 if (this.isPlaying) { await this.playSegments(); } // 未加载完成则继续加载(断点续传) if (!result.done && this.isPlaying) { await this.playNetworkVoice(url); } else if (result.done) { console.log("网络音频加载完成,播放结束"); this.releaseResources(); } }).catch((err) => { console.error(`加载任务失败:${err.message}`); this.isPlaying = false; this.isLoading = false; this.releaseResources(); }); return true; } catch (err) { console.error(`播放网络语音失败:${err.message}`); this.isPlaying = false; this.isLoading = false; this.releaseResources(); return false; } } // 暂停播放(保存断点) public async pause(): Promise { if (this.isPlaying && this.audioRenderer) { await this.audioRenderer.pause(); this.isPlaying = false; this.currentOffset = this.loadedLength; // 保存当前偏移量(断点) } } // 释放所有资源(网络+播放器) public async releaseResources(): Promise this.isPlaying = false; this.isLoading = false; // 释放播放器 if (this.audioRenderer) { try { await this.audioRenderer.stop(); await this.audioRenderer.release(); } catch (err) { console.error(`释放播放器失败:${err.message}`); } this.audioRenderer = null; } // 关闭HTTP客户端 if (this.httpClient) { this.httpClient.destroy(); this.httpClient = null; } // 清空数据缓存 this.totalData = []; this.loadedLength = 0; this.currentOffset = 0; }}// 3. 页面调用示例(Pages/NetworkVoicePage.ets)const networkPlayer = new NetworkVoicePlayer();// 点击播放网络语音async function onPlayNetworkVoice() { const voiceUrl = "https://xxx.com/voice/stream?id=common_alert"; // 脱敏通用URL const playSuccess = await networkPlayer.playNetworkVoice(voiceUrl); if (!playSuccess) { promptAction.showToast({ message: '网络语音播放失败,请检查网络' }); }}// 点击暂停async function onPauseVoice() { await networkPlayer.pause();}// 页面销毁释放资源aboutToDisappear() { networkPlayer.releaseResources();}验证方法:在强网、弱网、断网重连场景下测试网络语音播放,确保无闪退;测试 10MB + 大文件语音流,验证分片加载与内存控制,无 “out of memory” 异常;连续切换播放多个网络语音,验证资源释放正常,无冲突闪退;应用退后台后返回前台,继续播放无闪退,断点续传功能正常。 1.5 总结权限合规是基础:根据语音来源(本地 / 网络)申请对应权限(MEDIA_PLAYBACK/INTERNET/READ_MEDIA),后台播放需配置backgroundModes: ["audioPlayback"];资源校验不可少:本地文件需校验路径有效性与权限,网络资源需处理超时、中断、数据不完整场景;线程调度要合理:所有耗时操作(文件加载、网络请求、解码)必须放入子线程(taskpool/worker),UI 线程仅处理播放状态回调;播放器管理标准化:采用单例模式(本地音频)或生命周期绑定(网络音频),避免重复创建;播放完成 / 页面销毁时必须释放资源;格式与内存适配:优先使用系统支持的音频格式(MP3、WAV、AAC),大文件采用分片加载 / 流式播放,避免一次性加载导致内存溢出;全流程异常捕获:对播放器初始化、数据加载、播放控制等所有环节添加异常捕获,避免未处理异常导致闪退。
-
1.1 问题说明在鸿蒙卡片开发过程中,卡片需要预先创建并定义好规格,包括大小、位置、样式等。每个不同的卡片都需要新建,卡片加桌操作如长按应用加桌无法初始化参数给卡片,预览时无法做到定制样式,导致卡片可扩展性不高。1.2 原因分析· 卡片FormExtensionAbility生命周期接口onAddForm在加桌预览时就调用,而并非真正添加到桌面时调用 ·长按应用卡片加桌时为卡片的静态展示,不支持传入参数 1.3 解决思路·实现一个通用卡片组件,支持不同类型的卡片展示(如倒计时、照片、待办列表等)。·实现一个应用内卡片加桌功能,并且可以初始化传入参数,根据参数可在预览与创建卡片时展示不同效果·实现卡片与应用双向通信1.4 解决方案步骤1: 首先创建一个动态卡片,以下是创建好的卡片代码,请根据自己的需求修改/** * 统一服务卡片 UI */// 卡片类型枚举export enum ComponentType { DEFAULT = 'default', PHOTO = 'photo', TODO_LIST = 'todo_list',} @Entry@Componentexport struct CommonCard { @LocalStorageProp('name') name: string = '测试'; @LocalStorageProp('cardSize') cardSize: number = 2; // 对应卡片的大小"2*2","2*4","4*4" @LocalStorageProp('compType') compType: ComponentType = ComponentType.DEFAULT; @LocalStorageProp('backStyle') backStyle: string = ""; @LocalStorageProp('photo') photo: string = ""; @LocalStorageProp('initData') initData: string = ''; // 初始化数据,使用JSON字符串格式,等待组件中解析 @LocalStorageProp('backColor') backColor: string = ""; @LocalStorageProp('themeColor') themeColor: string = ""; aboutToAppear() { } build() { Column() { if (this.compType === ComponentType.PHOTO) { Image(this.photo) .syncLoad(true) .width('100%') .height('100%') } else if (this.compType === ComponentType.TODO_LIST) { // 通过不同类型,渲染自己创建对应的组件(参考步骤4),并传入初始参数 TodoCard({ initData: this.initData, cardTextColor: this.themeColor, cardBackColor: this.backColor, cardSize: this.cardSize, }) } else { } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor(this.backColor) }} 步骤2:添加一个带预览功能的应用内加桌功能组件import { AddFormMenuItem } from "@kit.ArkUI"import { formBindingData } from "@kit.FormKit"import { hilog } from "@kit.PerformanceAnalysisKit" @Componentexport struct AddFormCardSheet { @BuilderParam buildPreview: () => void //卡片加桌预览 @Link showAddFormCardMenu: boolean cardData: ESObject = {} // 自定义传入卡片的参数 @Prop cardSize: number = 2 // 对应卡片的大小"2*2","2*4","4*4" @Prop cardName: string = 'CommonForm' // 这里的cardName需要与form_config.json中定义的卡片名称一致 @Prop bindComponentId: string = '' @Builder addFormCardMenuBtn() { Button('添加') .height(40) .fontSize(14) .backgroundColor(Color.Transparent) .fontColor('#333333') .borderRadius(6) .translate({y: 45}) } aboutToAppear(): void { console.log(this.cardName) } build() { Column() { Column() { Text('添加到桌面') .fontSize(18) .fontWeight(FontWeight.Bolder) .fontColor('#333333') } .margin({bottom: 16, top: 16}) Column() { this.buildPreview() } Row() { Button('取消') .height(48) .fontSize(14) .type(ButtonType.Normal) .backgroundColor(Color.Transparent) .fontColor('#333333') .borderRadius(15) .width("50%") .onClick(() => { this.showAddFormCardMenu = false }) Menu() { AddFormMenuItem( { bundleName: 'com.huawei.xxx', // 包名 abilityName: 'EntryFormAbility', // 模块ability名称 parameters: { 'ohos.extra.param.key.form_dimension': this.cardSize, // 卡片尺寸,1代表1*2卡片,2代表2*2卡片,3代表2*4卡片,4代表4*4卡片 'ohos.extra.param.key.form_name': this.cardName, // 卡片名称 'ohos.extra.param.key.module_name': 'entry', // 卡片所属的模块名称 }, }, this.bindComponentId, { formBindingData: formBindingData.createFormBindingData(this.cardData), callback: (error, formId) => { hilog.info(0x3900, 'tag', `callback info:error = ${JSON.stringify(error)}, formId = ${formId}`); if (error?.code === 0) { hilog.info(0x3900, 'tag', "添加至桌面成功") this.showAddFormCardMenu = false; this.getUIContext().getPromptAction().showToast({ message: '添加至桌面成功' }) } else { hilog.info(0x3900, 'tag', "添加至桌面失败,请尝试其它添加方式") } }, style: { options: { builder: this.addFormCardMenuBtn() // startIcon: $r("app.media.icon"), // 菜单图标,可以自己提供。系统默认采用"sys.media.ic_public_add" //content: "添加", // 菜单内容,可以自己提供。默认使用"sys.string.ohos_add_form_to_desktop" // endIcon: $r("app.media.icon") // 菜单图标,可以自己提供 } } } ) } .translate({y: -20}) .width('50%') } .margin({bottom: 20}) } }} 步骤3:添加一个按钮打开应用内加桌功能组件,这里通过bindSheet实现@Componentexport struct OpenCardEdit { @State cardSize: number = 2 @State themeColor: string = '#007DFF' @State backColor: string = '#FFFFFF' @State showAddFormCardMenu: boolean = false bindComponentId: string = 'OpenCardEdit' cardData: ESObject = { cardSize: this.cardSize, compType: ComponentType.TODO_LIST, themeColor: this.themeColor, backColor: this.backColor, initData: '{"todoItems":[]}', //初始化数据,使用JSON字符串格式,等待组件中解析 } @LocalBuilder addFormCardMenu() { Column() { AddFormCardSheet({ showAddFormCardMenu: this.showAddFormCardMenu, cardSize: this.cardSize, cardName: 'CommonForm', cardData: this.cardData, bindComponentId: this.bindComponentId, buildPreview: this.buildTodoCardPreview }) } } @LocalBuilder buildTodoCardPreview() { Column() { TodoCard({ cardSize: this.cardSize, themeColor: this.themeColor, backColor: this.backColor, }) } } build() { Column() { Button('添加到桌面') .id(this.bindComponentId) .width('100%') .height(48) .fontSize(16) .fontWeight(FontWeight.Medium) .backgroundColor('#1A638A') .borderRadius(12) .bindSheet($$this.showAddFormCardMenu, this.addFormCardMenu, { height: SheetSize.FIT_CONTENT, showClose: false }) .onClick(() => { this.showAddFormCardMenu = true this.cardData = { cardSize: this.cardSize, compType: ComponentType.TODO_LIST, themeColor: this.themeColor, backColor: this.backColor, initData: '{"todoItems":[]}', } }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') }} 步骤4:实现一个TODO_LIST组件// 待办事项数据模型export interface TodoItem { id: string; title: string; description?: string; completed: boolean;}@Componentstruct TodoCard { @Prop cardSize: number = 2 @Prop headerTitle: string = '今日待办'; @State progressPercent: number = 0; // 0-100 @Prop initData: string = ''; @Prop cardTextColor: string = '#333333'; @Prop cardFontSize: number = 14; @Prop cardBackColor: string = '#F1F9D5'; getTodoItems() { try { const data: ESObject = JSON.parse(this.initData) || {todoItems: []} return data.todoItems as TodoItem[] } catch { return [] } } build() { Column() { // 标题 Row() { Text(this.headerTitle) .fontSize(this.cardFontSize + 3) .fontWeight(FontWeight.Bold) .fontColor(this.cardTextColor) } .justifyContent(FlexAlign.Center) .margin({ bottom: 8 }) .width('100%') // 待办事项列表 Column() { ForEach(this.getTodoItems(), (item: TodoItem, index: number) => { if (!item.completed) { Row() { Text('○') .fontSize(this.cardFontSize) .fontColor('#e5e7eb') .margin({ right: 6 }) Text(item.title) .fontSize(this.cardFontSize) .fontColor(this.cardTextColor) } .width('100%') .margin({ bottom: 4 }) .onClick(() => { // 这里发送一条消息到应用进程,通知它更新待办事项状态(见步骤6) postCardAction(this, { action: 'message', params: { compType: ComponentType.TODO_LIST, data: item } }); }) .alignItems(VerticalAlign.Center) } else { Row() { Text('✓') .fontSize(this.cardFontSize) .fontColor('#10b981') .margin({ right: 6 }) Text(item.title) .fontSize(this.cardFontSize) .fontColor(this.cardTextColor) .decoration({ type: TextDecorationType.LineThrough }) } .width('100%') .alignItems(VerticalAlign.Center) .margin({ bottom: 4 }) } }) } .width('100%') .padding({left: 16, right: 16 , top: 8, bottom: 8}) .margin({ top: 6 }) .shadow({ radius: 50, color: '#8D8882', offsetX: 5, offsetY: 5 }) } .width('100%') .height('100%') .padding(12) .backgroundColor(this.cardBackColor) .borderRadius(12) }} 步骤5:由于应用与卡片所属不同进程,所以卡片的交互事件需要通过事件总线进行通信。准备一个发布订阅工具,用于在应用与卡片之间传递事件。import commonEventManager from '@ohos.commonEventManager';import { BusinessError } from '@kit.BasicServicesKit'; /** * 公共事件类型定义 */export enum EventType { APP_UPDATE = 'appUpdate', // 卡片→应用 CARD_UPDATE = 'cardUpdate' // 应用→卡片} /** * 事件数据接口 */export interface EventData { type: EventType; compType: ComponentType; //组件类型 data: string; // JSON序列化的数据 timestamp: number;} /** * 公共事件工具类 * 用于应用与桌面卡片之间的双向数据同步 */export class SubscriberClass { private static instance: SubscriberClass; private subscribers: Map<EventType, commonEventManager.CommonEventSubscriber> = new Map(); private callbacks: Map<EventType, (data: ESObject) => void> = new Map(); private constructor() {} /** * 获取单例实例 */ public static getInstance(): SubscriberClass { if (!SubscriberClass.instance) { SubscriberClass.instance = new SubscriberClass(); } return SubscriberClass.instance; } /** * 发布事件 * @param eventType 事件类型 * @param data 数据数组 */ public async publish(eventType: EventType, compType: ComponentType, data: ESObject): Promise<void> { try { const eventData: EventData = { type: eventType, compType, data: JSON.stringify(data), timestamp: Date.now() }; const publishInfo: commonEventManager.CommonEventPublishData = { data: JSON.stringify(eventData), }; await commonEventManager.publish(eventType, publishInfo, (err: BusinessError) => { if (err) { console.error(`发布事件失败: ${eventType}`, err); } else { console.info(`成功发布事件: ${eventType}, 数据长度: ${data.length}`); } }); } catch (error) { console.error(`发布事件失败: ${eventType}`, error); throw new Error(`发布事件失败: ${JSON.stringify(error)}`); } } /** * 订阅事件 * @param eventType 事件类型 * @param callback 回调函数 */ public async subscribe(eventType: EventType, callback: (data: ESObject) => void): Promise<void> { try { // 如果已经订阅了该事件,先取消订阅 if (this.subscribers.has(eventType)) { await this.unsubscribe(eventType); } // 创建订阅信息 const subscribeInfo: commonEventManager.CommonEventSubscribeInfo = { events: [eventType] }; // 创建订阅者 const subscriber = await commonEventManager.createSubscriber(subscribeInfo); // 订阅事件 await commonEventManager.subscribe(subscriber, (err: BusinessError, data: commonEventManager.CommonEventData) => { if (err) { console.error(`订阅事件失败: ${eventType}`, err); throw new Error(`订阅事件失败: ${JSON.stringify(err)}`); } else { const event: ESObject = JSON.parse(data.data || "{}") callback(event) console.info(`成功订阅事件: ${eventType}`); } }); // 保存订阅者和回调 this.subscribers.set(eventType, subscriber); this.callbacks.set(eventType, callback); console.info(`成功订阅事件: ${eventType}`); } catch (error) { console.error(`订阅事件失败: ${eventType}`, error); throw new Error(`订阅事件失败: ${JSON.stringify(error)}`); } } /** * 取消订阅事件 * @param eventType 事件类型 */ public async unsubscribe(eventType: EventType): Promise<void> { try { const subscriber = this.subscribers.get(eventType); if (subscriber) { await commonEventManager.unsubscribe(subscriber); this.subscribers.delete(eventType); this.callbacks.delete(eventType); console.info(`成功取消订阅事件: ${eventType}`); } } catch (error) { console.error(`取消订阅事件失败: ${eventType}`, error); } } /** * 取消所有订阅 */ public async unsubscribeAll(): Promise<void> { const eventTypes = Array.from(this.subscribers.keys()); for (const eventType of eventTypes) { await this.unsubscribe(eventType); } } /** * 检查是否已订阅某个事件 * @param eventType 事件类型 */ public isSubscribed(eventType: EventType): boolean { return this.subscribers.has(eventType); } /** * 获取所有已订阅的事件类型 */ public getSubscribedEvents(): EventType[] { return Array.from(this.subscribers.keys()); }} /** * * 应用与卡片同步的便捷方法 */export class FormCardSyncManager { private static instance: FormCardSyncManager; private subscriber: SubscriberClass; private constructor() { this.subscriber = SubscriberClass.getInstance(); } public static getInstance(): FormCardSyncManager { if (!FormCardSyncManager.instance) { FormCardSyncManager.instance = new FormCardSyncManager(); } return FormCardSyncManager.instance; } /** * 应用端:发布更新到卡片 */ public async publishToCard(type: ComponentType, data: ESObject): Promise<void> { try { await this.subscriber.publish(EventType.CARD_UPDATE, type, data); } catch (error) { console.error('发布更新到卡片失败:', error); throw new Error(`发布更新到卡片失败: ${JSON.stringify(error)}`); } } /** * 卡片端:发布更新到应用 */ public async publishToApp(type: ComponentType, data: ESObject): Promise<void> { try { await this.subscriber.publish(EventType.APP_UPDATE, type, data); } catch (error) { console.error('发布更新到应用失败:', error); throw new Error(`发布更新到应用失败: ${JSON.stringify(error)}`); } } /** * 应用端:订阅来自卡片的更新 * @param callback 回调函数 */ public async subscribeFromCard(callback: (data: ESObject) => void): Promise<void> { try { await this.subscriber.subscribe(EventType.APP_UPDATE, callback); } catch (error) { console.error('订阅来自卡片的更新失败:', error); throw new Error(`订阅来自卡片的更新失败: ${JSON.stringify(error)}`); } } /** * 卡片端:订阅来自应用的更新 * @param callback 回调函数 */ public async subscribeFromApp(callback: (data: ESObject) => void): Promise<void> { try { await this.subscriber.subscribe(EventType.CARD_UPDATE, callback); } catch (error) { console.error('订阅来自应用的更新失败:', error); throw new Error(`订阅来自应用的更新失败: ${JSON.stringify(error)}`); } } /** * 清理所有订阅 */ public async cleanup(): Promise<void> { await this.subscriber.unsubscribeAll(); }} 步骤6:通过postCardAction接口触发message事件拉起FormExtensionAbility触发更新事件。export default class EntryFormAbility extends FormExtensionAbility { private syncManager: FormCardSyncManager = FormCardSyncManager.getInstance(); onFormEvent(formId: string, message: string): void { console.info('StickyNoteFormAbility onFormEvent, message:', message); try { const msgData:ESObject = JSON.parse(message) // 通知应用更新(见步骤7) this.syncManager.publishToApp(msgData?.compType, {eventData: msgData?.data, formId}).then(() => { console.info('已发布appUpdate事件到应用'); }).catch((error: Error) => { console.error('发布appUpdate事件失败:', error); }); } catch (error) { console.error('处理卡片事件失败:', error); } }} 步骤7:在应用中监听来自卡片的更新事件,当收到更新事件时,更新应用的待办事项列表。 // 应用端订阅来自卡片的更新事件FormCardSyncManager.getInstance().subscribeFromCard((data) => { const rawData: ESObject = JSON.parse(data.data); const eventData: ESObject = rawData.eventData; const formId: string = rawData.formId; // 当收到来自卡片的更新事件时,更新应用的待办事项列表 if (data.compType === ComponentType.TODO_LIST) { // 根据Id找到对应的待办事项 const idx = this.todoItems.findIndex(item => item.id === eventData.id); if (idx !== -1) { // 更新待办事项 this.todoItems[idx].completed = true; } // 同时更新卡片上的待办事项列表 let formInfo: formBindingData.FormBindingData = formBindingData.createFormBindingData({ initData: `{ "todoItems": ${JSON.stringify(this.todoItems)} }` }); formProvider.updateForm(formId, formInfo); }});1.5 总结问题与痛点:卡片预览无法自定义,卡片加桌无法初始化自定义参数,卡片通用性可扩展性差 技术总结:通过AddFormMenuItem入口创建卡片可带入自定义参数,自定义预览组件可扩展性高,使用通用卡片组件承载多个卡片,无需创建多个卡片配置。实现了卡片与应用的双向通信。 效果总结:通过实现这个通用卡片组件,我们可以在应用中实现多个卡片加桌,只需专注实现卡片组件的功能即可,同时预览时也可以使用卡片组件进行预览,极大提升多个卡片开发的效率。
-
一、问题说明鸿蒙原生应用支持用户上传PDF、DOCX格式文档(如外文资料、技术文档),并提取文档内文本用于后续操作。实际使用中发现,当处理大体积文档(单文件≥10MB)或复杂格式文档(含多图、表格、特殊排版)时,文本提取功能出现系列问题:提取耗时过长(10MB文档提取需15秒以上)、低端机型提取过程中闪退、特殊格式文档(如右对齐文本、嵌套表格)提取后文本错乱、部分字符(特殊符号、非英文字符)丢失,严重影响后续检索功能的可用性,成为文档处理场景的核心技术痛点。二、原因分析(一)全量文档一次性解析,超出系统承载能力文档文本提取时,前端未做分块处理,直接加载全量文档数据并一次性解析:后果1:大体积文档(≥10MB)加载时占用大量内存,解析过程中内存占用峰值达400MB以上,超出低端机型内存承载极限,导致闪退率达30%;后果2:全量解析需遍历文档所有页面、段落、字符,主线程阻塞时间超3秒,引发应用ANR(应用无响应),用户体验极差;后果3:单文件解析任务过重,弱网环境下若文档未完全下载即触发解析,易导致解析中断,文本提取不完整。(二)未适配文档格式差异,固定解析逻辑导致文本错乱不同格式文档(PDF/DOCX)的结构存储方式差异显著,且同一格式文档可能包含复杂排版(表格、图片穿插、特殊对齐方式),但前端采用统一的解析逻辑:后果1:PDF文档的流式存储结构与DOCX的XML树形结构未区分处理,导致表格文本提取后顺序错乱(如行/列颠倒)、图片周边文本丢失;后果2:未处理特殊排版(如右对齐、竖排文本、嵌套列表),提取后文本格式混乱,无法还原原文语义逻辑;后果3:特殊字符(如Unicode扩展字符、数学符号、非拉丁字符)编码解析不兼容,出现乱码或空白占位,字符丢失率达8%。(三)缺乏解析进度反馈,用户误判失败文档解析过程中,前端仅展示通用加载动画,未提供具体进度提示:用户无法感知解析进度(如“已解析30%”“正在处理第5页”),等待超过8秒即误以为操作失败,重复上传同一文档,导致重复解析,加剧设备性能消耗;长时间无明确反馈引发用户焦虑,部分用户直接关闭应用,导致解析过程中断,仅提取部分文本,影响后续功能使用。三、解决思路(一)分块解析与资源管控:针对全量解析超限问题采用“文档分块+异步解析”策略,拆分解析任务,降低系统承载压力:1、分块规则:按文档页数或文件大小拆分(PDF按页拆分,DOCX按段落节点拆分),单块解析数据量控制在合理范围(≤1MB/块);2、异步调度:利用ArkTS的TaskPool线程池,将解析任务迁移至子线程执行,避免阻塞主线程;3、资源释放:每块解析完成后及时释放临时资源,控制内存占用峰值,适配低端机型。(二)格式适配与编码兼容:针对文本错乱问题构建“格式识别+差异化解析”体系,适配不同文档结构与特殊排版:1、格式识别:先判断文档类型(PDF/DOCX),加载对应解析引擎,针对性处理存储结构;2、特殊排版适配:识别表格、图片、特殊对齐方式等元素,单独处理文本提取逻辑(如表格按行/列顺序提取、图片区域跳过不解析);(三)进度可视化与交互反馈:针对用户误判问题设计多维度反馈机制,提升解析过程透明度:1、进度展示:通过进度条+文本提示,实时显示解析进度(如“解析中 45% - 处理第8页”);2、状态反馈:解析成功/失败、格式不支持等场景给出明确提示,支持断点续解析; (四)异常处理与容错兜底:针对解析失败问题建立多层容错机制,提升解析可靠性:1、格式校验:上传前校验文档格式合法性,不支持的格式提前提示用户;2、失败重试:单块解析失败时,重试1次(避免临时资源占用导致的失败),重试失败则记录失败块,不中断整体解析;3、降级处理:极端场景下(如文档损坏),启动降级解析模式,提取可识别的文本片段,保障核心功能可用。四、解决方案(一)分块异步解析实现利用TaskPool线程池与文档分块策略,实现高效解析,核心代码如下: (二)格式适配与编码兼容实现针对不同文档格式与特殊排版,优化解析逻辑,核心代码如下: (三)进度可视化与交互反馈实现通过鸿蒙声明式UI组件构建反馈界面,核心代码如下: 五、总结(一)关键问题总结核心问题聚焦于鸿蒙原生应用文档文本提取场景:全量文档一次性解析导致系统承载超限、固定解析逻辑未适配文档格式差异与特殊排版、缺乏明确进度反馈引发用户误操作,三者叠加导致提取成功率低、文本质量差、用户体验不佳。(二)痛点总结1、功能性痛点:大体积文档提取闪退、特殊格式文档文本错乱、特殊字符丢失,影响后续翻译、检索功能使用;2、性能痛点:解析耗时过长,主线程阻塞导致ANR,低端机型兼容性差;3、体验痛点:解析进度不透明,用户易误判失败并重复操作,操作中断后无法续解析。(三)技术总结1、采用“文档分块+TaskPool异步解析”策略,拆分解析任务并迁移至子线程,控制内存占用峰值,适配全档位华为设备;2、构建“格式识别+差异化解析”体系,针对性处理PDF/DOCX存储结构,优化表格、特殊排版的文本提取逻辑,兼容Unicode全字符集;3、设计“进度条+页码反馈”的可视化界面,明确展示解析状态,避免用户误操作;4、引入格式校验、失败重试、降级处理等容错机制,提升解析可靠性,方案基于鸿蒙原生API开发,无第三方依赖,可复用性强。(四)效果总结1、提取成功率:从72%提升至99.5%,文档损坏、格式复杂等极端场景下仍可提取80%以上文本;2、提取效率:10MB文档提取耗时从15秒缩短至3.2秒,平均效率提升79%,主线程阻塞时间≤100ms;3、文本质量:特殊格式文档文本错乱率从40%降至3%以下,特殊字符丢失率降至0.5%;4、用户体验:用户误操作率从48%降至4%以下,解析功能满意度提升至95分(满分100),低端机型闪退率降至0。
-
1.1 业务背景在开发HarmonyOS应用时,经常需要实现应用内跨页面的全局悬浮功能,例如:音乐播放器的迷你控制条、视频播放的小窗口、运动计时器等。这些功能需要在用户在应用内切换页面时保持显示和运行状态,提供便捷的快速操作入口。注意:本方案实现的是应用内全局悬浮,组件仅在应用运行时显示,退出应用后不会在系统桌面显示。如需实现系统级悬浮窗(覆盖在其他应用之上),需要使用HarmonyOS的窗口管理API并申请相应权限。1.2 传统开发方式的痛点u 状态丢失:页面切换时组件被销毁,状态无法保持u 重复开发:每个页面都需要单独实现悬浮组件u 层级管理困难:悬浮组件容易被页面内容遮挡u 交互冲突:悬浮组件与页面内容的事件冲突u 生命周期混乱:难以控制组件的显示和隐藏时机1.3 原因分析 v 架构设计缺陷传统的"页面级组件"模式将悬浮组件绑定在单个页面上,页面销毁时组件也随之销毁。v 状态管理落后组件状态存储在页面内部,无法跨页面共享和持久化。v 层级控制不当未使用全局层级管理,导致悬浮组件被页面内容覆盖。1.4 解决思路&方案 (1)核心设计思想v 全局状态管理 使用AppStorage实现跨页面的状态共享,悬浮组件的显示状态、位置信息、业务数据等都存储在全局状态中。通过@StorageLink装饰器实现组件与全局状态的双向绑定。v 应用级组件挂载 将悬浮组件挂载在应用的根组件(Index.ets)上,而不是单个页面。这样组件的生命周期与应用一致,不受页面切换影响。v 高层级渲染 使用Stack布局将悬浮组件放置在最上层,通过zIndex属性确保始终显示在页面内容之上。设置hitTestBehavior为Transparent,避免阻挡页面交互。v 独立生命周期管理 悬浮组件拥有独立的aboutToAppear和aboutToDisappear生命周期,不依赖页面生命周期。通过全局状态控制显示和隐藏。 (2)实现要点v 全局状态定义 在AppStorage中定义悬浮组件所需的全局状态,包括显示开关、位置坐标、业务数据等。```typescript// 初始化全局状态AppStorage.setOrCreate('showFloatingComponent', false);AppStorage.setOrCreate('floatingPositionX', 0);AppStorage.setOrCreate('floatingPositionY', 300);AppStorage.setOrCreate('floatingData', null);```v 悬浮组件结构 使用@Component装饰器定义悬浮组件,通过@StorageLink绑定全局状态。```typescript@Componentexport struct FloatingComponent { @StorageLink('showFloatingComponent') isVisible: boolean = false; @StorageLink('floatingPositionX') positionX: number = 0; @StorageLink('floatingPositionY') positionY: number = 0; @State private isDragging: boolean = false; build() { if (this.isVisible) { // 悬浮内容 Row() { // 组件UI } .position({ x: this.positionX, y: this.positionY }) .zIndex(9999) .gesture( PanGesture() .onActionUpdate((event: GestureEvent) => { // 拖拽逻辑 }) ) } }}```v 根组件挂载 在应用根组件(Index.ets)中使用Stack布局挂载悬浮组件。```typescript@Entry@Componentstruct Index { @StorageLink('showFloatingComponent') showFloating: boolean = false; build() { Stack() { // 页面内容 Column() { // 主要内容区域 } // 全局悬浮组件 if (this.showFloating) { FloatingComponent() } } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) }}```v 拖拽功能实现 使用PanGesture手势实现拖拽,记录拖拽起始位置,实时更新组件坐标。```typescriptprivate dragStartX: number = 0;private dragStartY: number = 0;handleDragStart() { this.dragStartX = this.positionX; this.dragStartY = this.positionY;}handleDrag(offsetX: number, offsetY: number) { let newX = this.dragStartX + offsetX; let newY = this.dragStartY + offsetY; // 边界限制 const maxX = this.screenWidth - componentWidth; const maxY = this.screenHeight - componentHeight; newX = Math.max(0, Math.min(newX, maxX)); newY = Math.max(0, Math.min(newY, maxY)); this.positionX = newX; this.positionY = newY;}```v 边缘吸附效果 拖拽结束后,自动吸附到屏幕左侧或右侧。```typescriptsnapToSide() { const centerX = this.positionX + componentWidth / 2; if (centerX < this.screenWidth / 2) { this.positionX = 5; // 吸附到左侧 } else { this.positionX = this.screenWidth - componentWidth - 5; // 吸附到右侧 }}```v 显示和隐藏控制 通过修改AppStorage中的状态来控制悬浮组件的显示和隐藏。```typescript// 显示悬浮组件function showFloatingComponent(data: Object) { AppStorage.setOrCreate('floatingData', data); AppStorage.setOrCreate('showFloatingComponent', true);}// 隐藏悬浮组件function hideFloatingComponent() { AppStorage.setOrCreate('showFloatingComponent', false);}```v 事件穿透处理 设置hitTestBehavior确保悬浮组件不阻挡页面交互。```typescriptStack() { // 页面内容 Column() { } // 悬浮组件 if (this.showFloating) { FloatingComponent() }}.hitTestBehavior(HitTestMode.Transparent) // 事件穿透```1.5 总结(1)最佳实践 合理使用全局状态:只存储必要的共享数据,避免状态污染 注意内存管理:及时清理不再使用的数据,避免内存泄漏 优化渲染性能:使用条件渲染,避免不必要的组件创建 处理边界情况:考虑屏幕旋转、折叠屏展开等场景 提供关闭入口:确保用户可以方便地关闭悬浮组件(3)适用场景- 音乐播放器的迷你控制条- 视频通话的悬浮窗口- 运动健身的计时器- 下载任务的进度提示- 消息通知的快捷入口- 任何需要跨页面保持的功能(4)技术要点核心思想:将组件生命周期与应用绑定,通过全局状态管理实现跨页面的状态共享和持久化。关键技术: AppStorage实现全局状态管理 @StorageLink实现状态双向绑定 Stack + zIndex实现层级控制 PanGesture实现拖拽交互 hitTestBehavior实现事件穿透 position实现自由定位
-
1.1 业务背景在开发HarmonyOS应用时,经常遇到需要实现多个结构相似但内容不同的页面,这些页面具有相同的布局结构和交互流程,仅数据内容不同。1.2 传统开发方式的痛点u 代码重复:N个页面需要N份代码u 维护困难:修复bug需要在多个文件中同步u 扩展性差:新增页面需要完整复制代码u 路由冗余:需要为每个页面注册路由1.3 原因分析 v 架构设计缺陷"一页面一组件"模式将数据和视图混合,违反了"关注点分离"原则。v 数据管理落后页面数据硬编码在组件中,无法动态配置。v 缺乏抽象能力未识别多个页面的共性,导致代码重复。1.4 解决思路&方案(1)核心设计思想v 数据与视图分离 将页面配置数据从组件代码中剥离,存储在独立的数据文件中。通过TypeScript接口定义严格的数据结构,确保类型安全。v 通用组件设计 开发一个通用页面组件,通过@Prop接收配置数据,根据配置动态渲染页面内容。所有相似页面共用此组件,避免代码重复。v 路由参数传递 通过router.pushUrl传递页面ID参数,目标页面根据ID从配置数组中查找对应配置并加载。实现一个页面入口服务多个页面场景。v 路由配置简化 原本需要注册N个页面路由,现在只需注册1个通用页面路由,大幅简化路由配置。 (2)实现要点 v 配置数据结构定义页面配置接口,包含页面ID、标题、背景图、数据项列表等字段。所有页面配置存储在统一数组中,通过ID索引。```typescriptinterface PageConfig { id: string; title: string; dataItems: DataItem[];}const pageConfigs: PageConfig[] = [ { id: '01', title: '页面一', dataItems: [...] }, { id: '02', title: '页面二', dataItems: [...] }];```v 组件状态管理使用@State管理显示项列表,使用@Prop接收页面配置,在aboutToAppear生命周期中加载数据。v 动态渲染机制 使用ForEach遍历显示项列表,动态渲染页面元素。根据配置数据控制元素的显示、隐藏和交互行为。 ```typescript@Componentstruct UniversalPage { @Prop config: PageConfig; @State items: DisplayItem[] = []; aboutToAppear() { this.items = this.processData(this.config.dataItems); } build() { Column() { Text(this.config.title) ForEach(this.items, (item: DisplayItem) => { // 动态渲染元素 }) } }}```v 参数获取与配置加载在页面入口组件中通过router.getParams()获取路由参数,根据参数ID查找配置,转换为组件所需格式后传递给通用组件。```typescript// 跳转页面router.pushUrl({ url: 'pages/UniversalPage', params: { pageId: '01' }});// 接收参数并加载配置@Entry@Componentstruct UniversalPageEntry { @State config: PageConfig | null = null; aboutToAppear() { const params = router.getParams(); this.config = pageConfigs.find(c => c.id === params.pageId); } build() { if (this.config) { UniversalPage({ config: this.config }) } }}``````json{ "src": [ "pages/Index", "pages/UniversalPage" ]}``` 1.5 总结(1)效果对比指标传统方式数据驱动方式优化效果代码量N个文件1个文件减少90%+路由数N个路由1个路由减少90%+新增成本复制代码添加配置减少90%+维护成本修改N个文件修改1个文件减少90%+表 1(2)最佳实践 识别可复用逻辑:判断页面交互流程、状态管理、UI结构是否相同 数据与视图分离:使用接口定义数据结构,通过配置文件管理 使用路由参数:通过参数传递ID,动态加载配置 配置版本化管理:配置数据纳入版本控制,支持热更新(3)适用场景- 电商应用的商品分类页- 新闻应用的频道页- 地图应用的详情页- 教育应用的课程页- 任何多页面相似结构的应用(4)技术要点核心思想:配置即代码,将可变数据从代码中抽离,实现代码逻辑的高度复用。关键技术 TypeScript接口定义数据结构 路由参数实现动态加载 通用组件实现逻辑复用 ForEach实现动态渲染
-
用鸿蒙PC和鸿蒙平板做开发也挺好的
-
1. 插件介绍webview_flutter 是一个功能强大的 Flutter 插件,提供了 WebView 小部件,允许开发者在 Flutter 应用中嵌入网页内容。该插件支持多种平台,包括 iOS、Android、Web 和鸿蒙(API 12+)。核心功能在 Flutter 应用中无缝嵌入网页内容支持网络资源、本地文件和 Flutter 资源加载提供完整的网页导航控制(前进、后退、刷新)支持 JavaScript 执行和双向通信提供网页加载进度监听和导航决策控制支持自定义用户代理、背景颜色和缩放控制鸿蒙平台支持在鸿蒙平台上,webview_flutter 插件基于鸿蒙的 WebView API(API 12+)实现,提供与其他平台一致的功能体验。支持鸿蒙 API 12 及以上版本。2. 依赖引入由于这是一个针对鸿蒙平台的自定义修改版本,需要通过 git 形式引入依赖。配置步骤在项目的 pubspec.yaml 文件中,添加以下依赖配置:12345dependencies: webview_flutter: git: url: "https://gitcode.com/openharmony-tpc/flutter_packages.git" path: "packages/webview_flutter"执行以下命令获取依赖:1flutter pub get3. 鸿蒙平台特殊配置3.1 权限配置添加网络权限:在 entry/src/main/module.json5 文件中添加网络权限声明:12345678910"requestPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "$string:network_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }]添加权限描述:在 entry/src/main/resources/base/element/string.json 文件中添加权限描述:12345678{ "string": [ { "name": "network_reason", "value": "使用网络访问网页内容" } ]}3.2 应用级别配置由于 WebView 可能需要系统级权限,需要将应用级别设置为 system_basic,否则在安装 HAP 包时可能会报错。具体配置方法请参考华为官方文档。4. API 调用与使用示例4.1 创建 WebViewControllerWebViewController 是控制 WebView 行为的核心类,用于加载内容、控制导航和执行 JavaScript 等操作。12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152import 'package:flutter/material.dart';import 'package:webview_flutter/webview_flutter.dart'; class WebViewExample extends StatefulWidget { const WebViewExample({Key? key}) : super(key: key); @override State<WebViewExample> createState() => _WebViewExampleState();} class _WebViewExampleState extends State<WebViewExample> { late final WebViewController _controller; @override void initState() { super.initState(); // 创建 WebViewController _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) // 启用 JavaScript ..setBackgroundColor(const Color(0x00000000)) // 设置透明背景 ..setNavigationDelegate( // 设置导航委托 NavigationDelegate( onProgress: (int progress) { // 更新加载进度 debugPrint('WebView is loading (progress : $progress%)'); }, onPageStarted: (String url) { debugPrint('Page started loading: $url'); }, onPageFinished: (String url) { debugPrint('Page finished loading: $url'); }, onWebResourceError: (WebResourceError error) { debugPrint('\n\n====\nError: $error\n====\n\n'); }, onNavigationRequest: (NavigationRequest request) { // 控制导航决策 if (request.url.startsWith('https://www.youtube.com/')) { debugPrint('blocking navigation to ${request.url}'); return NavigationDecision.prevent; } debugPrint('allowing navigation to ${request.url}'); return NavigationDecision.navigate; }, ), ) ..loadRequest(Uri.parse('https://flutter.dev')); // 加载初始 URL } // ...}4.2 创建 WebViewWidgetWebViewWidget 用于在 Flutter 界面中显示 WebView 内容,需要传入之前创建的 WebViewController。12345678910111213@overrideWidget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Flutter WebView 示例'), // 添加导航控制按钮 actions: [ NavigationControls(_controller), ], ), body: WebViewWidget(controller: _controller), // 显示 WebView );}4.3 实现导航控制123456789101112131415161718192021222324252627282930313233343536373839404142434445class NavigationControls extends StatelessWidget { const NavigationControls(this._webViewController, {Key? key}) : super(key: key); final WebViewController _webViewController; @override Widget build(BuildContext context) { return Row( children: <Widget>[ IconButton( icon: const Icon(Icons.arrow_back_ios), onPressed: () async { if (await _webViewController.canGoBack()) { await _webViewController.goBack(); } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No back history item')), ); } } }, ), IconButton( icon: const Icon(Icons.arrow_forward_ios), onPressed: () async { if (await _webViewController.canGoForward()) { await _webViewController.goForward(); } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No forward history item')), ); } } }, ), IconButton( icon: const Icon(Icons.replay), onPressed: () => _webViewController.reload(), ), ], ); }}4.4 执行 JavaScript12345678// 执行 JavaScript 代码_controller.runJavaScript('alert("Hello from Flutter!");'); // 执行 JavaScript 代码并获取返回结果final result = await _controller.runJavaScriptReturningResult( 'document.body.innerText.length');debugPrint('Body text length: $result');4.5 监听 JavaScript 消息123456789101112// 添加 JavaScript 通道_controller.addJavaScriptChannel( 'Toaster', onMessageReceived: (JavaScriptMessage message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message.message)), ); },); // 在网页中发送消息到 Flutter// window.Toaster.postMessage('Hello from JavaScript!');4.6 加载不同类型的内容123456789101112131415161718// 加载网络资源_controller.loadRequest(Uri.parse('https://flutter.dev')); // 加载本地文件_controller.loadFile('/path/to/local/file.html'); // 加载 Flutter 资源_controller.loadFlutterAsset('assets/html/index.html'); // 加载 HTML 字符串_controller.loadHtmlString('''<html><body> <h1>Hello, World!</h1> <p>This is HTML content loaded from a string.</p></body></html>''', baseUrl: Uri.parse('https://example.com'));5. 注意事项5.1 权限配置确保正确配置网络权限,否则 WebView 将无法加载网络资源。同时,注意应用级别的设置,避免安装时出现权限错误。5.2 鸿蒙平台兼容性支持鸿蒙 API 12 及以上版本要求 Flutter 版本:3.7.12-ohos-1.1.1 及以上要求鸿蒙 SDK 版本:5.0.0(12) 及以上5.3 性能优化避免在 WebView 中加载过于复杂的网页内容合理使用 JavaScript 通道,避免频繁的双向通信在不需要时及时释放 WebView 资源5.4 安全性考虑谨慎处理来自网页的 JavaScript 消息对加载的网页内容进行适当的安全验证考虑使用内容安全策略 (CSP) 保护应用6. 总结webview_flutter 是一个功能强大的 Flutter 插件,为开发者提供了在 Flutter 应用中嵌入网页内容的能力。在鸿蒙平台上,该插件提供了与其他平台一致的功能体验,支持网络资源加载、JavaScript 执行、导航控制等核心功能。使用该插件的关键步骤包括:通过 git 形式引入自定义修改版本的依赖配置网络权限和应用级别创建 WebViewController 并配置导航委托使用 WebViewWidget 在界面中显示 WebView实现导航控制和 JavaScript 交互webview_flutter 插件为鸿蒙平台上的 Flutter 应用提供了强大的网页嵌入能力,使开发者能够轻松构建混合内容应用,结合原生应用的性能优势和网页内容的灵活性。无论是构建需要嵌入外部网页的应用,还是需要实现复杂网页交互的功能,webview_flutter 都是鸿蒙平台上 Flutter 开发者的理想选择。
-
目前只有Windows的,请求适配鸿蒙PC
-
鸿蒙开发之相对布局、倒计时TextTimer示例代码
-
由华为云计算技术有限公司与武汉交通职业学院联合承办的“2025一带一路暨金砖国家技能发展与技术创新大赛-第二届鸿蒙端云智能应用开发赛项”总决赛在湖北武汉落下帷幕。本次大赛自启动以来,吸引了来自全国84所高校的数百支队伍激烈角逐,最终75支优秀团队脱颖而出,分获各类奖项。 总决赛开幕式于12月5日隆重举行。武汉交通职业学院副校长谢计红与华为云云学堂负责人唐国军出席并致辞。 武汉交通职业学院副校长谢计红首先对各位领导和参赛者的到来表示热烈的欢迎,并表示学校致力于培养符合产业需求的高素质创新人才。本次赛题紧跟产业发展新技术与新趋势,鼓励参赛学生以赛促学、以赛促创,将课堂所学与产业需求紧密结合,在鸿蒙生态的广阔赛道上实现自身价值。 华为云云学堂负责人唐国军在致辞中表示鸿蒙生态产业的繁荣离不开广大开发者的支撑,尤其是富有激情与创造力的年轻力量的加入。在备战决赛的征程中,参赛学生已通过华为云学堂进行了扎实的系统学习,也在开发者空间完成了丰富的项目实践。未来,华为云还将持续优化云学堂的课程体系与实践环境,让学习内容更贴合产业实际,让开发工具更高效便捷,助力每一位开发者在这里探索出属于自己的成长之路。 闭幕式暨颁奖典礼上,经专家评委团严格评审,最终75支队伍凭借其项目的创新性、技术完整度、应用潜力及现场表现,成功斩获大赛一等奖、二等奖、三等奖及优秀奖。 颁奖环节结束后,金砖国家工商理事会技能发展与技术创新大赛组委会执委会竞赛处主任程帅对本次大赛的成果进行总结,并对未来发展寄予深切厚望,希望所有参赛学生能将大赛中的所学、所思、所悟转化为持续深耕“鸿蒙端云智能应用开发”领域的动力,始终保持对技术创新的热情与执着,主动了解和学习鸿蒙端云智能技术与各行业融合发展的新趋势,以过硬的专业能力和开放的创新视野,成长为推动鸿蒙端云智能高质量发展的中坚力量。 本次金砖大赛-第二届鸿蒙端云智能应用开发赛项的圆满落幕,离不开承办校的辛勤付出与全力支持,也离不开各个参赛队伍的积极参与。一直以来,华为云致力于与教育界同行,未来也将持续深化与高校的合作,共同构建面向智能时代的人才培养新生态。
-
一、Java 市场饱和下的就业困境,鸿蒙成救赎之光作为一名双非院校软件工程专业的学生,我曾深陷 Java 就业市场的 “内卷漩涡”。随着 Java 开发者数量持续激增,市场早已呈现饱和状态,即便掌握了基础的 Java 开发技能,在求职大军中也难以脱颖而出。投出的简历大多石沉大海,偶尔收到的面试邀约,也因缺乏差异化的技术亮点而屡屡碰壁。就在我对就业前景感到迷茫之际,鸿蒙系统的更新迭代让我看到了新的希望 —— 这款国产自主可控的操作系统,正以迅猛的势头搭建起全新的生态体系,而鸿蒙开发领域的人才缺口,恰好为我们这些渴望突破的学生开发者打开了一扇窗。鸿蒙时代的到来,不再是遥远的概念,而是软件工程师可触及的 “另一条赛道”。二、结缘鸿蒙激励计划,开启从零到一的开发征程鸿蒙开发者激励计划的推出,更是让我坚定了投身鸿蒙开发的决心。该计划以完善鸿蒙生态为核心目标,不仅鼓励更多开发者拥抱国产操作系统,更通过实实在在的激励机制,为开发者提供了成长的动力与支持。今年 8 月,我在开发者联盟看到激励计划的报名信息后,毫不犹豫地加入其中,正式踏上了鸿蒙开发的探索之路。起初,我完全是鸿蒙开发的 “小白”,连最基础的开发环境搭建、ArkUI 框架认知都一片空白。为了打好基础,我系统性地跟着网课老师学习鸿蒙入门知识,从系统架构、开发工具的使用,到 UI 组件的布局、事件处理的逻辑,每一个知识点都反复琢磨、反复实操。那段时间,除了日常的课程学习,其余时间几乎都投入到了鸿蒙开发的学习中,屏幕前的教学视频、堆满笔记的笔记本、不断报错又不断调试的代码,构成了我生活的主旋律。随着基础逐渐扎实,我开始尝试简单的页面开发。从一个按钮的点击事件实现,到一个列表的循环渲染,再到页面之间的跳转逻辑,每一个小小的突破都让我备受鼓舞。但开发之路从非坦途,适配问题、语法差异、逻辑BUG等一系列难题接踵而至。记得第一次尝试实现一个带下拉刷新功能的页面时,因对鸿蒙的组件生命周期理解不深,导致页面频繁闪退,反复调试了两天才找到问题根源。那些熬夜排查 bug 的夜晚,那些因思路卡顿而陷入的焦虑,如今回想起来,都是成长路上最珍贵的沉淀。三、聚焦用户痛点,敲定跨应用记账 APP 方向在具备一定开发能力后,我萌生了独立开发一款鸿蒙 APP 的想法,希望通过实际项目将所学知识融会贯通,也为简历增添亮眼的实践经历。最初,我计划开发一款简单的备忘录类 APP,但当我满怀期待地提交上架申请时,却收到了驳回通知 —— 应用市场中同类产品已高度同质化,缺乏创新点。正当我陷入方向迷茫时,一位有鸿蒙开发上架经验的前辈提醒我:“开发 APP 要找准用户未被满足的需求,避开红海赛道。” 这句话点醒了我。结合当下年轻人的消费现状,我发现一个普遍痛点:如今超前消费盛行,很多人缺乏记账意识,常常陷入 “月光” 甚至负债的困境;而市面上的支付工具(微信、支付宝等)仅能记录自身平台的账单,无法实现跨应用、全场景的消费与收入记录。基于这一需求缺口,我敲定了新的开发方向 —— 一款支持跨应用、全场景的记账 APP,命名为《嘉嘉记账》。m.1203ce.InFo/pacct/518998.sHtMlm.1203ce.InFo/pacct/046597.sHtMlm.1203ce.InFo/pacct/093089.sHtMlm.1203ce.InFo/pacct/921215.sHtMlm.1203ce.InFo/pacct/855334.sHtMlm.1203ce.InFo/pacct/628282.sHtMlm.1203ce.InFo/pacct/729997.sHtMlm.1203ce.InFo/pacct/207231.sHtMlm.1203ce.InFo/pacct/344924.sHtMlm.1203ce.InFo/pacct/898208.sHtMlm.1203ce.InFo/pacct/650839.sHtMlm.1203ce.InFo/pacct/977575.sHtMlm.1203ce.InFo/pacct/362683.sHtMlm.1203ce.InFo/pacct/048931.sHtMlm.1203ce.InFo/pacct/136041.sHtMlm.1203ce.InFo/pacct/629222.sHtMlm.1203ce.InFo/pacct/138822.sHtMlm.1203ce.InFo/pacct/803582.sHtMlm.1203ce.InFo/pacct/825504.sHtMlm.1203ce.InFo/pacct/792193.sHtMlm.1203ce.InFo/pacct/879616.sHtMlm.1203ce.InFo/pacct/251489.sHtMlm.1203ce.InFo/pacct/957785.sHtMlm.1203ce.InFo/pacct/570489.sHtMlm.1203ce.InFo/pacct/570489.sHtMlm.1203ce.InFo/pacct/395854.sHtMlm.1203ce.InFo/pacct/395854.sHtMlm.1203ce.InFo/pacct/395854.sHtMlm.1203ce.InFo/pacct/395854.sHtMlm.1203ce.InFo/pacct/460427.sHtMlm.1203ce.InFo/pacct/471964.sHtMlm.1203ce.InFo/pacct/480171.sHtMlm.1203ce.InFo/pacct/480171.sHtMlm.1203ce.InFo/pacct/480171.sHtMlm.1203ce.InFo/pacct/381683.sHtMlm.1203ce.InFo/pacct/381683.sHtMlm.1203ce.InFo/pacct/706442.sHtMlm.1203ce.InFo/pacct/706442.sHtMlm.1203ce.InFo/pacct/583315.sHtMlm.1203ce.InFo/pacct/839330.sHtMlm.1203ce.InFo/pacct/103178.sHtMlm.1203ce.InFo/pacct/474876.sHtMlm.1203ce.InFo/pacct/069379.sHtMlm.1203ce.InFo/pacct/779089.sHtMlm.1203ce.InFo/pacct/394901.sHtMlm.1203ce.InFo/pacct/635890.sHtMlm.1203ce.InFo/pacct/288153.sHtMlm.1203ce.InFo/pacct/749256.sHtMlm.1203ce.InFo/pacct/461499.sHtMlm.1203ce.InFo/pacct/687237.sHtMlm.1203ce.InFo/pacct/393404.sHtMlm.1203ce.InFo/pacct/328221.sHtMlm.1203ce.InFo/pacct/094914.sHtMlm.1203ce.InFo/pacct/838982.sHtMlm.1203ce.InFo/pacct/519831.sHtMlm.1203ce.InFo/pacct/870767.sHtMlm.1203ce.InFo/pacct/817476.sHtMlm.1203ce.InFo/pacct/898282.sHtMlm.1203ce.InFo/pacct/778997.sHtMlm.1203ce.InFo/pacct/004212.sHtMlm.1203ce.InFo/pacct/765042.sHtMlm.1203ce.InFo/pacct/277660.sHtMlm.1203ce.InFo/pacct/733342.sHtMlm.1203ce.InFo/pacct/547548.sHtMlm.1203ce.InFo/pacct/651986.sHtMlm.1203ce.InFo/pacct/756053.sHtMlm.1203ce.InFo/pacct/402793.sHtMlm.1203ce.InFo/pacct/200704.sHtMlm.1203ce.InFo/pacct/621701.sHtMlm.1203ce.InFo/pacct/528024.sHtMlm.1203ce.InFo/pacct/176923.sHtMlm.1203ce.InFo/pacct/801845.sHtMlm.1203ce.InFo/pacct/066635.sHtMlm.1203ce.InFo/pacct/173835.sHtMlm.1203ce.InFo/pacct/879149.sHtMlm.1203ce.InFo/pacct/974905.sHtMlm.1203ce.InFo/pacct/470591.sHtMlm.1203ce.InFo/pacct/627953.sHtMlm.1203ce.InFo/pacct/037205.sHtMlm.1203ce.InFo/pacct/389930.sHtMlm.1203ce.InFo/pacct/384291.sHtMlm.1203ce.InFo/pacct/600413.sHtMlm.1203ce.InFo/pacct/576010.sHtMlm.1203ce.InFo/pacct/304384.sHtMlm.1203ce.InFo/pacct/434253.sHtMlm.1203ce.InFo/pacct/778727.sHtMlm.1203ce.InFo/pacct/987248.sHtMlm.1203ce.InFo/pacct/400905.sHtMlm.1203ce.InFo/pacct/170421.sHtMlm.1203ce.InFo/pacct/222275.sHtMlm.1203ce.InFo/pacct/571231.sHtMlm.1203ce.InFo/pacct/771826.sHtMlm.1203ce.InFo/pacct/096797.sHtMlm.1203ce.InFo/pacct/423418.sHtMlm.1203ce.InFo/pacct/496626.sHtMlm.1203ce.InFo/pacct/433091.sHtMlm.1203ce.InFo/pacct/274979.sHtMlm.1203ce.InFo/pacct/798354.sHtMlm.1203ce.InFo/pacct/430109.sHtMlm.1203ce.InFo/pacct/879453.sHtMlm.1203ce.InFo/pacct/623041.sHtMlm.1203ce.InFo/pacct/533198.sHtMlm.1203ce.InFo/pacct/114264.sHtMlm.1203ce.InFo/pacct/259500.sHtMlm.1203ce.InFo/pacct/405002.sHtMlm.1203ce.InFo/pacct/682649.sHtMlm.1203ce.InFo/pacct/352382.sHtMlm.1203ce.InFo/pacct/622694.sHtMlm.1203ce.InFo/pacct/291522.sHtMlm.1203ce.InFo/pacct/823143.sHtMlm.1203ce.InFo/pacct/210726.sHtMlm.1203ce.InFo/pacct/620034.sHtMlm.1203ce.InFo/pacct/271473.sHtMlm.1203ce.InFo/pacct/086435.sHtMlm.1203ce.InFo/pacct/200147.sHtMlm.1203ce.InFo/pacct/171189.sHtMlm.1203ce.InFo/pacct/436142.sHtMlm.1203ce.InFo/pacct/498505.sHtMlm.1203ce.InFo/pacct/147164.sHtMlm.1203ce.InFo/pacct/867428.sHtMlm.1203ce.InFo/pacct/390496.sHtMlm.1203ce.InFo/pacct/469138.sHtMlm.1203ce.InFo/pacct/409739.sHtMlm.1203ce.InFo/pacct/092832.sHtMlm.1203ce.InFo/pacct/871557.sHtMlm.1203ce.InFo/pacct/288327.sHtMlm.1203ce.InFo/pacct/021541.sHtMlm.1203ce.InFo/pacct/444897.sHtMlm.1203ce.InFo/pacct/986389.sHtMlm.1203ce.InFo/pacct/122755.sHtMlm.1203ce.InFo/pacct/054388.sHtMlm.1203ce.InFo/pacct/166712.sHtMlm.1203ce.InFo/pacct/240063.sHtMlm.1203ce.InFo/pacct/193566.sHtMlm.1203ce.InFo/pacct/014950.sHtMlm.1203ce.InFo/pacct/560876.sHtMlm.1203ce.InFo/pacct/141549.sHtMlm.1203ce.InFo/pacct/682926.sHtMlm.1203ce.InFo/pacct/996045.sHtMlm.1203ce.InFo/pacct/077194.sHtMlm.1203ce.InFo/pacct/189340.sHtMlm.1203ce.InFo/pacct/657245.sHtMlm.1203ce.InFo/pacct/069486.sHtMlm.1203ce.InFo/pacct/796191.sHtMlm.1203ce.InFo/pacct/953033.sHtMlm.1203ce.InFo/pacct/103978.sHtMlm.1203ce.InFo/pacct/694848.sHtMlm.1203ce.InFo/pacct/404851.sHtMlm.1203ce.InFo/pacct/925982.sHtMlm.1203ce.InFo/pacct/933356.sHtMlm.1203ce.InFo/pacct/911859.sHtMlm.1203ce.InFo/pacct/541860.sHtMlm.1203ce.InFo/pacct/541860.sHtMlm.1203ce.InFo/pacct/541860.sHtMlm.1203ce.InFo/pacct/541860.sHtMlm.1203ce.InFo/pacct/541860.sHtMlm.1203ce.InFo/pacct/541860.sHtMlm.1203ce.InFo/pacct/174606.sHtMlm.1203ce.InFo/pacct/323717.sHtMlm.1203ce.InFo/pacct/030158.sHtMlm.1203ce.InFo/pacct/958804.sHtMlm.1203ce.InFo/pacct/118515.sHtMlm.1203ce.InFo/pacct/078693.sHtMlm.1203ce.InFo/pacct/645602.sHtMlm.1203ce.InFo/pacct/588745.sHtMlm.1203ce.InFo/pacct/177920.sHtMlm.1203ce.InFo/pacct/289766.sHtMlm.1203ce.InFo/pacct/523337.sHtMlm.1203ce.InFo/pacct/872491.sHtMlm.1203ce.InFo/pacct/379108.sHtMlm.1203ce.InFo/pacct/815069.sHtMlm.1203ce.InFo/pacct/594260.sHtMlm.1203ce.InFo/pacct/701526.sHtMlm.1203ce.InFo/pacct/989102.sHtMlm.1203ce.InFo/pacct/033394.sHtMlm.1203ce.InFo/pacct/213535.sHtMlm.1203ce.InFo/pacct/533056.sHtMlm.1203ce.InFo/pacct/098510.sHtMlm.1203ce.InFo/pacct/551964.sHtMlm.1203ce.InFo/pacct/898408.sHtMlm.1203ce.InFo/pacct/059693.sHtMlm.1203ce.InFo/pacct/134812.sHtMlm.1203ce.InFo/pacct/837227.sHtMlm.1203ce.InFo/pacct/514606.sHtMlm.1203ce.InFo/pacct/656971.sHtMlm.1203ce.InFo/pacct/655801.sHtMlm.1203ce.InFo/pacct/803492.sHtMlm.1203ce.InFo/pacct/099105.sHtMlm.1203ce.InFo/pacct/288607.sHtMlm.1203ce.InFo/pacct/873868.sHtMlm.1203ce.InFo/pacct/262812.sHtMlm.1203ce.InFo/pacct/737857.sHtMlm.1203ce.InFo/pacct/680117.sHtMlm.1203ce.InFo/pacct/766378.sHtMlm.1203ce.InFo/pacct/202941.sHtMlm.1203ce.InFo/pacct/624415.sHtMlm.1203ce.InFo/pacct/190127.sHtMlm.1203ce.InFo/pacct/269947.sHtMl这款 APP 的核心定位是 “一站式记账工具”,涵盖微信支付、支付宝、谷歌支付、银行卡刷卡、现金支付等全支付场景,同时细分消费类别(吃穿用度、加油旅游、日常通勤等)与收入类别(工资奖金、投资收益、兼职收入等),让用户能清晰掌握每一笔资金的来龙去脉,帮助养成合理消费、理性储蓄的习惯。
-
本群定期分享鸿蒙5.0以上开发相关技术,帮助提升产品日活互助群。
-
本人致力于物联网与智能家居领域的技术研究与产品验证,特别关注基于国产自主通信协议相关硬件的新一代智能终端开发。 目前没有“小熊派-鸿蒙·叔 (BearPi-HM Micro)”,全是理论的学习,不能进入到实践项目。更深刻的学习小熊派-鸿蒙系统,请求申请“小熊派-鸿蒙·叔 (BearPi-HM Micro)”,期望得到反馈。
-
11月28号的鸿蒙盛典大家都去吗?小编因为要去牛马所以不一定准时到,这次会议的主题是什么呀?听说还有刘德华 黄渤明星前来,鸿蒙真的是好起来了
上滑加载中
推荐直播
-
华为云码道 × 仓颉编程:工程化AI编码探索2026/05/27 周三 19:00-21:00
刘俊杰-华为云仓颉语言专家/李炎-华为云码道技术专家/王智鹏-OpenCangjie开源社区发起人
本场直播围绕华为云仓颉语言与华为云码道的深度结合,展示华为云智能编程从零基础到高效落地的完整生态能力。以华为云码道为引擎,仓颉语言为载体,带给大家日常提效、趣味创新到极速量产的开发体验。
回顾中
热门标签