-
鸿蒙图片压缩1.1 问题说明在鸿蒙应用开发中,当需要处理用户上传或从网络加载的图片时,经常会遇到以下场景问题:场景一:应用卡顿,内存占用过高用户选择或拍摄了一张高分辨率(如4000x3000像素,5MB以上)的图片,直接在Image组件中显示或进行预览时,应用出现明显的卡顿、页面渲染延迟,甚至导致应用无响应(ANR)或内存溢出(OOM)。场景二:上传缓慢,用户体验差应用需要将用户图片上传到服务器。由于原始图片体积过大(如3-5MB),在网络状况不佳(如移动网络)时,上传耗时过长,消耗用户大量流量,并可能导致上传失败。场景三:本地存储空间压力大应用需要将用户编辑后的图片保存到本地。如果未经压缩直接存储大量原始图片,会迅速占用用户设备的宝贵存储空间,影响用户对应用的评价。 1.2 解决思路核心思路是:在满足视觉质量要求的前提下,尽可能早地、在合适的环节对图片进行尺寸和质量的压缩。整体逻辑框架如下:源控制:在图片输入源头(相机、相册选择器)即请求适当尺寸的图片。按需采样:根据图片最终显示控件的实际尺寸,对图片进行尺寸缩放(降采样),这是减少像素数据最有效的一步。有损压缩:在完成尺寸缩放后,对像素数据进行质量压缩(如JPEG编码),以减小文件体积。异步处理:所有压缩操作必须在异步线程(如TaskPool)中进行,绝不能阻塞UI线程。缓存策略:对压缩后的结果进行内存和磁盘缓存,避免重复计算。 1.3 解决方案以下是一个结合了尺寸压缩和质量压缩的、可复用的鸿蒙(API 9+)图片压缩工具类ImageCompressor.ets实现方案。// ImageCompressor.etsimport { image } from '@kit.ImageKit';import { fileIo } from '@kit.CoreFileKit';import { BusinessError } from '@kit.BasicServicesKit';import { taskpool } from '@kit.TaskPoolKit';/** * 压缩配置选项 */export class CompressOptions { maxWidth: number = 1024; // 目标最大宽度 maxHeight: number = 1024; // 目标最大高度 quality: number = 85; // 压缩质量 (0-100),仅对JPEG有效 outputFormat: image.ImageFormat = image.ImageFormat.JPEG; // 输出格式 destPath?: string; // 指定输出路径,如果不指定则生成临时文件}/** * 图片压缩工具类 */export class ImageCompressor { /** * 核心压缩方法(异步) * @param srcUri 源图片URI (例如:'file://xxx.jpg', 'dataability://xxx') * @param options 压缩配置 * @returns 压缩后的图片URI (Promise<string>) */ public static async compress(srcUri: string, options?: CompressOptions): Promise<string> { const opt: CompressOptions = { ...new CompressOptions(), ...options }; // 1. 创建ImageSource并获取原始图片信息 let imageSource: image.ImageSource = image.createImageSource(srcUri); try { const imageInfo: image.ImageInfo = await imageSource.getImageInfo(); console.info(`原始图片信息: width=${imageInfo.size.width}, height=${imageInfo.size.height}`); // 2. 计算采样后的目标尺寸(保持宽高比) const targetSize: image.Size = this.calculateTargetSize(imageInfo.size, opt); // 3. 创建像素图,并进行尺寸解码(关键降采样步骤) const decodeOptions: image.DecodingOptions = { desiredSize: targetSize, // 指定期望解码的尺寸,系统会自动采样 desiredPixelFormat: image.PixelMapFormat.RGBA_8888, }; let pixelMap: image.PixelMap = await imageSource.createPixelMap(decodeOptions); imageSource.releaseSync(); // 及时释放ImageSource // 4. 如果指定了输出路径,准备输出;否则使用临时目录 let outputUri = opt.destPath; if (!outputUri) { const context = getContext(this) as common.UIAbilityContext; const tempDir = context.filesDir; const fileName = `compressed_${Date.now()}.jpg`; outputUri = `${tempDir}/${fileName}`; } // 5. 配置编码选项并进行质量压缩 const imagePackerApi: image.ImagePacker = image.createImagePacker(); const packOpts: image.PackingOptions = { format: opt.outputFormat, quality: opt.quality, }; // 6. 将PixelMap编码为压缩后的图片文件 const file: fileIo.File = await fileIo.open(outputUri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); await imagePackerApi.packing(pixelMap, packOpts, file.fd); file.closeSync(); pixelMap.releaseSync(); // 释放PixelMap内存 console.info(`图片压缩成功: ${srcUri} -> ${outputUri}`); return outputUri; } catch (error) { const err: BusinessError = error as BusinessError; console.error(`图片压缩失败,错误码:${err.code}, 信息:${err.message}`); imageSource?.releaseSync(); throw new Error(`Compression failed: ${err.message}`); } } /** * 在TaskPool中执行压缩(适用于大批量或后台任务) * @param srcUri 源图片URI * @param options 压缩配置 * @returns 返回一个可通过await获取结果的Promise */ public static async compressInTaskPool(srcUri: string, options?: CompressOptions): Promise<string> { const task: taskpool.Task = new taskpool.Task(this.compress, srcUri, options); return await taskpool.execute(task) as Promise<string>; } /** * 计算目标尺寸(保持宽高比,不超过最大限制) * @param originalSize 原始尺寸 * @param opt 压缩选项 * @returns 计算后的目标尺寸 */ private static calculateTargetSize(originalSize: image.Size, opt: CompressOptions): image.Size { let { width: origWidth, height: origHeight } = originalSize; const { maxWidth, maxHeight } = opt; // 如果原始尺寸已经小于目标尺寸,则不放大 if (origWidth <= maxWidth && origHeight <= maxHeight) { return { width: origWidth, height: origHeight }; } // 计算缩放比例,以更接近max的那个边为准 const widthRatio = maxWidth / origWidth; const heightRatio = maxHeight / origHeight; const ratio = Math.min(widthRatio, heightRatio); return { width: Math.round(origWidth * ratio), height: Math.round(origHeight * ratio) }; }} 使用示例:// 在页面或ViewModel中使用import { ImageCompressor, CompressOptions } from '../utils/ImageCompressor';async onImageSelected(selectedUri: string) { // 示例1:基础压缩(异步函数内) try { const options = new CompressOptions(); options.maxWidth = 800; options.maxHeight = 600; options.quality = 80; const compressedUri = await ImageCompressor.compress(selectedUri, options); // 使用 compressedUri 显示或上传 this.previewImageSrc = compressedUri; this.uploadImage(compressedUri); } catch (error) { console.error('处理图片失败', error); } // 示例2:在TaskPool中执行(不阻塞当前异步函数) // ImageCompressor.compressInTaskPool(selectedUri, options).then(uri => { // // 处理结果 // });} 1.4 结果展示实施上述解决方案后,取得了显著的效果提升:内存效率提升:原图(4000x3000)内存占用:~48MB。压缩后(800x600)内存占用:800 * 600 * 4 ≈ 1.9MB。内存消耗降低超过95%。在图片列表或编辑页面中,同时处理多张图片也流畅自如。性能与用户体验优化:UI流畅度:图片加载和显示的速度显著加快,彻底消除了因加载大图导致的界面卡顿。上传速度:一张5MB的原始图片压缩后可能仅为200-300KB,在4G网络下的上传时间从10秒以上缩短至2-3秒,上传效率提升70%以上。流量与存储节省:为用户和服务器节省了大量带宽与存储成本。
-
案例概述骨架屏(Skeleton Screen)是一种在数据加载期间显示的页面框架,提升用户体验。本案例演示如何使用HarmonyOS最新API实现一个优雅的骨架屏效果。一、架构设计1.1 核心组件// SkeletonScreen.ets// 骨架屏核心组件,包含动画和布局@Componentexport struct SkeletonScreen { }// ShimmerEffect.ets// 流光动画效果组件@Componentexport struct ShimmerEffect { }// SkeletonManager.ets// 骨架屏状态管理器export class SkeletonManager { }二、实现步骤详解步骤1:定义数据模型和配置// 1. 定义骨架屏项类型export interface SkeletonItem {type: ‘rectangle’ | ‘circle’ | ‘text-line’ | ‘custom’;width: Length;height: Length;borderRadius?: number;margin?: Padding | Margin;animationDelay?: number; // 动画延迟}// 2. 动画配置export interface SkeletonAnimationConfig {shimmerDuration: number; // 流光动画时长fadeDuration: number; // 淡入淡出时长shimmerWidth: number; // 流光宽度shimmerColor: ResourceColor; // 流光颜色baseColor: ResourceColor; // 基础颜色highlightColor: ResourceColor; // 高亮颜色}// 3. 骨架屏配置export class SkeletonConfig {static readonly DEFAULT_CONFIG: SkeletonAnimationConfig = {shimmerDuration: 1500,fadeDuration: 500,shimmerWidth: 100,shimmerColor: ‘#FFFFFF40’,baseColor: ‘#F0F0F0’,highlightColor: ‘#F5F5F5’};}● 定义骨架屏的数据结构,支持多种形状类型● 配置动画参数,便于统一管理● 使用TypeScript接口确保类型安全步骤2:实现流光动画效果// ShimmerEffect.ets@Componentexport struct ShimmerEffect {@State private shimmerOffset: number = -100; // 流光位置// 动画控制器private animationController: animation.Animator = new animation.Animator();@Prop config: SkeletonAnimationConfig = SkeletonConfig.DEFAULT_CONFIG;@Prop isActive: boolean = true; // 是否激活动画aboutToAppear() {if (this.isActive) {this.startShimmerAnimation();}}// 启动流光动画private startShimmerAnimation(): void {this.animationController.update({duration: this.config.shimmerDuration,iterations: -1, // 无限循环curve: animation.Curve.Linear});this.animationController.onFrame((value: number) => { this.shimmerOffset = 200 * value - 100; // 计算流光位置 }); this.animationController.play();}build() {// 使用LinearGradient实现流光效果Row().width(this.config.shimmerWidth).height(‘100%’).backgroundImage(new LinearGradient({angle: 0,colors: [[Color.Transparent, 0],[this.config.shimmerColor, 0.5],[Color.Transparent, 1]]})).translate({ x: ${this.shimmerOffset}% })}}● 使用HarmonyOS的animation.Animator实现平滑动画● 通过LinearGradient创建渐变流光效果● 支持无限循环动画,提升加载体验步骤3:实现基础骨架屏项// SkeletonItem.ets@Componentexport struct SkeletonItem {@State private isAnimating: boolean = false;@Prop itemConfig: SkeletonItem; // 骨架项配置@Prop animationConfig: SkeletonAnimationConfig = SkeletonConfig.DEFAULT_CONFIG;@Prop enableShimmer: boolean = true; // 是否启用流光效果aboutToAppear() {// 延迟启动动画setTimeout(() => {this.isAnimating = true;}, this.itemConfig.animationDelay || 0);}@Builderprivate buildSkeletonContent() {// 根据类型构建不同的骨架形状switch (this.itemConfig.type) {case ‘rectangle’:this.buildRectangle();break;case ‘circle’:this.buildCircle();break;case ‘text-line’:this.buildTextLine();break;default:this.buildRectangle();}}@Builderprivate buildRectangle() {Column().width(this.itemConfig.width).height(this.itemConfig.height).backgroundColor(this.animationConfig.baseColor).borderRadius(this.itemConfig.borderRadius || 4).overflow(Overflow.Hidden) // 重要:确保流光不溢出.overlay(// 流光效果叠加层this.enableShimmer && this.isAnimating ?ShimmerEffect({config: this.animationConfig,isActive: this.isAnimating}) : null)}@Builderprivate buildCircle() {Circle().width(this.itemConfig.width).height(this.itemConfig.height).fill(this.animationConfig.baseColor).overlay(this.enableShimmer && this.isAnimating ?ShimmerEffect({config: this.animationConfig,isActive: this.isAnimating}) : null)}@Builderprivate buildTextLine() {// 文本行骨架,模拟段落Column({ space: 4 }) {Rectangle().width(‘100%’).height(16).fill(this.animationConfig.baseColor).borderRadius(8) Rectangle() .width('80%') .height(16) .fill(this.animationConfig.baseColor) .borderRadius(8) Rectangle() .width('60%') .height(16) .fill(this.animationConfig.baseColor) .borderRadius(8) }}build() {Column().margin(this.itemConfig.margin || {}).opacity(this.isAnimating ? 1 : 0).animation({duration: this.animationConfig.fadeDuration,curve: animation.Curve.EaseOut}){this.buildSkeletonContent()}}}● 支持多种骨架形状:矩形、圆形、文本行● 使用overlay属性叠加流光效果● 实现淡入动画,提升视觉体验● 可配置延迟动画,创建错落有致的加载效果步骤4:实现完整骨架屏组件// SkeletonScreen.ets@Componentexport struct SkeletonScreen {@State private isLoading: boolean = true; // 加载状态// 骨架布局配置@Prop skeletonLayout: SkeletonItem[] = [];@Prop contentBuilder: CustomBuilder; // 实际内容构建器@Prop animationConfig: SkeletonAnimationConfig = SkeletonConfig.DEFAULT_CONFIG;@Prop showShimmer: boolean = true; // 是否显示流光// 加载完成回调@Prop onLoadComplete?: () => void;// 模拟数据加载async loadData(): Promise<void> {// 显示骨架屏this.isLoading = true;try { // 模拟异步数据加载 await this.simulateDataFetch(); // 数据加载完成 this.isLoading = false; this.onLoadComplete?.(); } catch (error) { // 处理错误 this.isLoading = false; }}private async simulateDataFetch(): Promise<void> {return new Promise(resolve => {setTimeout(resolve, 2000); // 模拟2秒加载});}@Builderprivate buildSkeletonLayout() {Column({ space: 12 }) {ForEach(this.skeletonLayout, (item: SkeletonItem, index: number) => {SkeletonItem({itemConfig: {…item,animationDelay: index * 100 // 错开动画延迟},animationConfig: this.animationConfig,enableShimmer: this.showShimmer})})}.width(‘100%’).padding(16)}@Builderprivate buildContent() {// 使用@BuilderParam构建实际内容this.contentBuilder()}aboutToAppear() {// 组件出现时开始加载this.loadData();}build() {Stack({ alignContent: Alignment.TopStart }) {// 实际内容this.buildContent().opacity(this.isLoading ? 0 : 1).animation({duration: 300,curve: animation.Curve.EaseInOut}) // 骨架屏 this.buildSkeletonLayout() .opacity(this.isLoading ? 1 : 0) .animation({ duration: 300, curve: animation.Curve.EaseInOut }) } .width('100%') .height('100%')}}● 使用Stack层叠布局,切换骨架屏和实际内容● 支持自定义布局配置,灵活适配不同页面● 实现平滑的淡入淡出过渡效果● 提供异步数据加载接口步骤5:实现状态管理器// SkeletonManager.etsexport class SkeletonManager {private static instance: SkeletonManager;private loadingStates: Map<string, boolean> = new Map();private loadingCallbacks: Map<string, Array<() => void>> = new Map();// 单例模式static getInstance(): SkeletonManager {if (!SkeletonManager.instance) {SkeletonManager.instance = new SkeletonManager();}return SkeletonManager.instance;}// 开始加载startLoading(componentId: string): void {this.loadingStates.set(componentId, true);this.notifyStateChange(componentId);}// 完成加载finishLoading(componentId: string): void {this.loadingStates.set(componentId, false);this.notifyStateChange(componentId);}// 获取加载状态isLoading(componentId: string): boolean {return this.loadingStates.get(componentId) || false;}// 注册状态监听registerListener(componentId: string, callback: () => void): void {if (!this.loadingCallbacks.has(componentId)) {this.loadingCallbacks.set(componentId, []);}this.loadingCallbacks.get(componentId)!.push(callback);}private notifyStateChange(componentId: string): void {const callbacks = this.loadingCallbacks.get(componentId) || [];callbacks.forEach(callback => callback());}}● 单例模式管理全局加载状态● 支持多组件独立加载控制● 提供状态监听机制● 便于组件间通信步骤6:使用示例// UserProfileSkeleton.ets@Entry@Componentexport struct UserProfileSkeleton {// 定义骨架布局private skeletonLayout: SkeletonItem[] = [{type: ‘circle’,width: 80,height: 80,margin: { top: 20, bottom: 16 }},{type: ‘rectangle’,width: ‘40%’,height: 24,borderRadius: 12,margin: { bottom: 8 }},{type: ‘rectangle’,width: ‘60%’,height: 16,borderRadius: 8,margin: { bottom: 24 }},{type: ‘text-line’,width: ‘100%’,height: 100}];@Builderprivate buildUserProfile() {Column({ space: 12 }) {// 用户头像Image($r(‘app.media.user_avatar’)).width(80).height(80).borderRadius(40).objectFit(ImageFit.Cover) // 用户名 Text('张三') .fontSize(20) .fontWeight(FontWeight.Bold) // 用户描述 Text('高级软件工程师 | HarmonyOS开发者') .fontSize(14) .fontColor('#666666') // 用户简介 Text('专注于移动应用开发,拥有丰富的跨平台开发经验。') .fontSize(16) .lineHeight(24) } .padding(16)}build() {Column() {SkeletonScreen({skeletonLayout: this.skeletonLayout,contentBuilder: () => this.buildUserProfile(),animationConfig: {…SkeletonConfig.DEFAULT_CONFIG,shimmerColor: ‘#4D94FF40’ // 自定义流光颜色},showShimmer: true,onLoadComplete: () => {console.log(‘用户资料加载完成’);}})}.width(‘100%’).height(‘100%’).backgroundColor(‘#FFFFFF’)}}● 定义具体的骨架屏布局配置● 通过contentBuilder传入实际内容● 可自定义动画参数● 提供加载完成回调三、高级特性扩展3.1 列表骨架屏// ListSkeleton.ets@Componentexport struct ListSkeleton {@Prop itemCount: number = 5; // 骨架项数量@Builderprivate buildListItemSkeleton(index: number) {Row({ space: 12 }) {// 左侧图片SkeletonItem({itemConfig: {type: ‘rectangle’,width: 60,height: 60,borderRadius: 8}}) // 右侧内容 Column({ space: 6 }) { SkeletonItem({ itemConfig: { type: 'rectangle', width: '70%', height: 16, borderRadius: 8 } }) SkeletonItem({ itemConfig: { type: 'rectangle', width: '50%', height: 12, borderRadius: 6 } }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .padding({ top: 12, bottom: 12 })}build() {List({ space: 8 }) {ForEach(Array.from({ length: this.itemCount }), (_, index: number) => {ListItem() {this.buildListItemSkeleton(index)}})}}}3.2 网格骨架屏// GridSkeleton.ets@Componentexport struct GridSkeleton {@Prop columns: number = 2; // 列数@Prop itemCount: number = 6; // 项目数@Builderprivate buildGridItemSkeleton() {Column({ space: 8 }) {// 图片区域SkeletonItem({itemConfig: {type: ‘rectangle’,width: ‘100%’,height: 120,borderRadius: 8}}) // 标题 SkeletonItem({ itemConfig: { type: 'rectangle', width: '80%', height: 16, borderRadius: 8 } }) // 价格 SkeletonItem({ itemConfig: { type: 'rectangle', width: '40%', height: 14, borderRadius: 7 } }) }}build() {GridRow({ columns: this.columns, gutter: 8 }) {ForEach(Array.from({ length: this.itemCount }), (_, index: number) => {GridCol({ span: 1 }) {this.buildGridItemSkeleton()}})}}}四、最佳实践建议4.1 性能优化动画性能○ 使用硬件加速的动画○ 避免过多的同时动画○ 适时停止不必要的动画内存管理○ 及时清理动画资源○ 使用对象池复用骨架项○ 控制骨架屏显示时长4.2 用户体验设计原则○ 骨架屏布局应与实际内容一致○ 动画要自然流畅○ 提供加载超时处理错误处理○ 加载失败时提供重试机制○ 网络异常时显示适当提示○ 支持手动刷新4.3 可访问性// 为屏幕阅读器提供提示.accessibilityDescription(‘内容加载中,请稍候’).accessibilityState(AccessibilityState.Disabled)五、总结核心优势用户体验好:避免白屏,提供视觉连续性性能优秀:使用HarmonyOS原生动画系统灵活可配:支持多种布局和动画效果易于集成:提供简洁的API接口使用场景● 网络请求数据加载● 图片懒加载● 复杂组件初始化● 首屏性能优化
-
问题说明在HarmonyOS应用的多包环境中,通常会使用Harmony归档包(Harmony AchievePackage,下文简称 HAR)在各个模块中进行组件、资源和代码复用。在多包嵌套场景中,如果将相同的HAR多处引用打包,容易出现包体积冗余、冗余加载重复文件的问题,可能会导致应用程序包变大甚至运行异常。为了更好地实现资源复用精简包体,需要通过转换,将参与的HAR模块改写为可以提供动态复用和内存节省效果的Harmony共享模块(HarmonySharedPackage)。原因分析通常情况下,编译器完成打包时将所有需要的HAR文件解难,每个被引用了的HAP模块中包含copy所有相关HAP组件副本,打包完成后,各部分HAR各部分的内容复制到内含的File中()。这样的做法的长处是对用户进行了模块的复用复用(对应Version中setup中:条件复用为允许)在复用完整粘贴内容(静态储存单元中已储存在复用库中复用),对应Harmony社区称之为同簇打包依赖重复(harmonyarchive会导致multiple copies)。运行期间各部分镜像会占用更多的内存资源,特别是同时运行多个模块(复杂业务)时应用速度会显著降低。如果将其转换为动态共享模式(harmony-sharedpackage),可以在基础内容、下载分发环节仅提供关键模块,同一进程下的不同模块就能进行共享依赖。 解决思路总结核心思路:· 修改模块主配置文件 module.json5,调整类型为支持复用的动态共享类型 "shared"。· 修改构建脚本 hvigorfile.ts,调整构建任务模式。· 去除妨碍共享包配置的残留字段(如 consumerFiles)。· 准备必要的页面配置文件和入口编译文件。· 配置和调整模块对于外模块能输出页面跳转路径。解决方案步骤将此解决方案拆解为一系列的配置改写步骤与方法推敲,用户可以遵循格式逐步完成改写。**【第一步】改写 src/main/resources/base/profile/[MODULE_ROOT]/¥§\$…module.json¥§行单** 要旋转类型为 : "Shared">` authenticated以下字段。说明:改type为shared后必须配置deliveryWithInstall字段为true,和确保有合法pages配置(|,表示绑定了负责页面绑定的页面加载。”{ "module": { "name": "library", "type": "shared", // 关键项:修改为 "shared" "deliveryWithInstall": true, // 应用内安装模块时发布该共享包 "pages": "$profile:main_pages", // 配置页面加载标识符,适用于声明 HARP/合HAR中的跳转页 "deviceTypes": ["phone", "tablet"]}}【第二步】建立和配置模块跳转页面清单 在资源profile目录新建main_pages.json,内容包含可共享页面的路径:文件路径:src/main/resources/base/profile/main_pages.json{ "src": [ "pages/PageIndex" ]}【第三步】构建build中取消默认的混淆规则导出字段 修改build-profile.json5,注意在shared 模式下最好删除配置consumerFiles字字段(模)。以下是没有该配置项或删除之后的示例:{ "apiType": 'stageMode',"buildOption": {}}// 已去除 "consumerFiles": "./consumer-rules.txt"【第四步】修改构建脚本hvgorfile.ts 中的任务类型在该文件中,将原来由Hártasks改动的代码块替换为启用HSBT的任务:文件路径:hvigorfile.tsimport { hspTasks } from '@ohos/hvigor-ohos-plugin';export default { system: hspTasks, plugins: []}【第五步】增加packageType & Index入口模板 此步骤较为重要,在module中添加Index.h文件用于对外输出:增加src/main/resources/base/proflile次次Module主类型: 文件:src/main/resources/base/profile/oh-package.json5{ "name": "library","version": "1.0.0","packageType": "interfaceHar", // 该InterfaceHar表示当前是HSP对外接口类型}在/src/main/resources/base/profile文件夹中创建Index.ets,将名义export输出至外部使用:路径: src/main/resources/base/profile/Index.etsexport { PageIndex } from './pages/PageIndex';// 应当包含在src/main/ets/pages的 PageIndex.ets 是实际页面文件第六步:创建实际公开页文件进入ets目录,创建页面文件,支持符合ArkTS语法的页面布局:路径: src/main/ets/pages/PageIndex.ets@Entry@Componentstruct PageIndex { @State message: string = 'hello world' build() { Column() { Text(this.message) .fontSize(50) .fontWeight(FontWeight.Bold) .fontColor(Color.Blue) } .width('100%') .height('100%') .alignItems(HorizontalAlign.Center) }}第七步:最后一步重新编译模块1. 进入模块所在的文件夹。2. 运行`hvigorw`或点击DevEco Studio Build菜单的 `Build` > `Build HSP` 对模块进行编译。编译生成的产物,可在工程的build目录下得到 `library-default-unsigned.hsp` 和 `library.har`。A C A U T I O N确保编码及结构文件大小写精准概率均等。总结以上步骤完成了Har到Hsp的配置转换和技术性修改。若修改过程出现依赖报错或配置未生效,应按步骤验证相关时,其中步骤重点包括modules.json5及hvigorfile interface与publicated中方的修改,并删除consumerFiles段入口,这一点是容易被忽略的关键。若不配置导致不能再接收H页表的调用,请仔细检查public命名页面文件中强制显示跳转正确的键值(Pages 保留文字类名称),保证홍页面统一源自H and/or HSP下跳转到直。文章中提到的核心要点均适合ACS stage project,或HarmonyOS member codebase用于处理多重环境。
-
一、问题说明在HarmonyOS应用开发中,线程间通信是多线程并发场景下的核心需求。主要问题包括:1. ArkTS线程间数据传输限制:Actor内存隔离模型下,线程间无法直接共享内存,必须通过序列化/反序列化传递数据,单次传输限制16MB。2. Native子线程无法回调ArkTS函数:Native侧通过std::thread或pthread创建的子线程无法直接调用只能在UI主线程执行的ArkTS函数。3. 通信效率低下:JS对象序列化/反序列化带来性能开销,大量数据传递时影响应用响应性能。4. 线程数量限制:Worker线程数量限制为64个,过多的线程创建会导致内存和调度开销增大。二、原因分析根据HarmonyOS线程模型和Actor并发模型,问题根源如下:2.1 内存隔离机制· ArkTS线程模型:每个线程拥有独立的ArkTS引擎实例和内存空间,基于Actor模型实现内存隔离。· 线程间通信限制:必须通过消息传递机制进行通信,不能直接访问对方内存空间。2.2 ArkTS线程类型限制· UI主线程:负责UI绘制、事件分发、ArkTS引擎管理。· TaskPool线程(推荐):自动管理生命周期,支持优先级调度和负载均衡。· Worker线程:开发者自行管理生命周期,支持耗时任务和线程间通信。2.3 序列化约束· 对象类型限制:不支持@State、@Prop、@Link、@Observed等装饰器修饰的复杂类型。· 序列化方式:采用Structured Clone算法,通过深拷贝实现对象传输。2.4 Native线程约束· 线程安全:主线程中的napi_env、napi_value、napi_ref不能在子线程直接使用。· 回调限制:ArkTS函数只能在UI主线程调用。三、解决思路基于不同通信场景,提供分层解决方案:3.1 ArkTS线程间通信1. 基本通信:使用TaskPool和Worker的标准通信接口。2. 高效通信:使用@Sendable装饰器实现引用传递,减少序列化开销。3. 内存共享:使用SharedArrayBuffer配合Atomics原子操作。4. 事件通信:使用Emitter实现进程内线程间异步事件通信。3.2 Native与ArkTS线程通信1. 线程安全函数:使用Node-API的napi_create_threadsafe_function机制。2. libuv异步通信:使用uv_async_send方法(备选方案)。3. Native回调:Native侧执行耗时任务后,通过安全机制回调ArkTS函数。3.3 数据传递优化1. 分类传输:根据数据类型选择合适的通信对象。2. 减少数据量:控制单次传输数据大小。3. 异步锁管理:多线程访问共享数据时使用异步锁保证安全。四、解决方案方案一:ArkTS线程间高效通信4.1.1 TaskPool任务与宿主线程通信/** * SharedData.ets * 并发任务处理工具 * 功能: * 1. 定义并发处理函数 processData * 2. 提供任务管理和进度监控 * 3. 支持并发处理大量数据 */import { taskpool } from '@kit.ArkTS';import { hilog } from '@kit.PerformanceAnalysisKit';/** * Params 接口 * 定义处理数据的参数 */interface Params{ data: number[]; // 要处理的数据数组 threshold: number; // 阈值,用于筛选数据}/** * ProgressData 接口 * 定义进度数据的结构 */interface ProgressData{ type: string; // 数据类型,用于标识进度信息 current: number; // 当前处理的索引 total: number; // 总数据量 processed: number; // 已处理并符合条件的数据量}/** * processData 函数 * 并发处理数据,筛选出大于阈值的数据 * @param params 处理参数,包含数据数组和阈值 * @returns 筛选后的结果数组 */@Concurrentexport function processData(params: Params): number[] { const results: number[] = []; // 处理数据并实时发送进度 for (let i = 0; i < params.data.length; i++) { // 筛选出大于阈值的数据 if (params.data[i] > params.threshold) { results.push(params.data[i]); } // 每处理100个数据发送一次进度 if (i % 100 === 0) { taskpool.Task.sendData({ type: 'progress', current: i + 1, total: params.data.length, processed: results.length }); } } return results;}/** * MainPage 类 * 示例类,展示如何使用并发任务处理 */class MainPage { private currentTask: taskpool.Task | null = null; // 当前正在执行的任务 /** * 启动任务 * 1. 生成测试数据 * 2. 创建任务参数 * 3. 创建并配置任务 * 4. 执行任务并处理结果 */ async startProcessing(): Promise<void> { // 生成测试数据 const data = this.generateTestData(10000); // 创建任务参数 const params: Params = { data: data, threshold: 50 }; // 创建任务 const task = new taskpool.Task(processData, params); // 设置数据接收回调,用于处理进度信息 task.onReceiveData((progressData: ProgressData) => { hilog.info(0x0000, 'MainPage', `Progress: ${progressData.current}/${progressData.total}, Found: ${progressData.processed}`); this.updateProgress(progressData.current, progressData.total); }); // 保存当前任务 this.currentTask = task; try { // 执行任务并等待结果 const result: number[] = await taskpool.execute(task) as number[]; hilog.info(0x0000, 'MainPage', `Processing completed, found ${result.length} items`); this.displayResults(result); } catch (error) { hilog.error(0x0000, 'MainPage', `Task failed: ${error.message}`); } } /** * 生成测试数据 * @param count 数据量 * @returns 随机数据数组 */ private generateTestData(count: number): number[] { const data: number[] = []; for (let i = 0; i < count; i++) { data.push(Math.floor(Math.random() * 100)); } return data; } /** * 更新进度显示(示例) * @param current 当前处理的索引 * @param total 总数据量 */ private updateProgress(current: number, total: number): void { // 更新UI进度条 } /** * 显示结果(示例) * @param results 处理后的结果数组 */ private displayResults(results: number[]): void { // 处理结果显示 }}4.1.2 Worker线程即时通信/** * WorkerData.ets * Worker线程处理工具 * 功能: * 1. 定义Worker线程消息处理逻辑 * 2. 提供数据处理功能 * 3. 支持取消处理操作 */import { worker, ThreadWorkerGlobalScope, ErrorEvent } from '@kit.ArkTS';import { hilog } from '@kit.PerformanceAnalysisKit';const workerPort: ThreadWorkerGlobalScope = worker.workerPort;let shouldCancel = false;// 数据负载类型定义interface DataPayload { items: number[];}// 消息类型定义interface WorkerMessage { command: string; payload?: DataPayload;}// 批量结果消息类型定义interface BatchResultMessage { type: string; batchId: number; processedCount: number; completed: boolean;}// 取消消息类型定义interface CancelMessage { type: string; processedCount: number;}// 结果消息类型定义interface ResultMessage { type: string; processedCount: number;}// 错误消息类型定义interface ErrorMessage { type: string; error: string;}/** * 消息事件类型定义 */interface WorkerMessageEvent { data: WorkerMessage;}/** * 结果消息事件类型定义 */interface ResultMessageEvent { data: BatchResultMessage | CancelMessage | ErrorMessage;}/** * 处理Worker线程消息 */workerPort.onmessage = (event: WorkerMessageEvent): void => { const message: WorkerMessage = event.data; switch (message.command) { case 'process': if (message.payload) { processData(message.payload); } else { hilog.error(0x0000, 'DataProcessor', 'Missing payload for process command'); } break; case 'cancel': shouldCancel = true; break; default: hilog.warn(0x0000, 'DataProcessor', `Unknown command: ${message.command}`); }};/** * 数据处理函数 * @param data 要处理的数据 */function processData(data: DataPayload): void { let count = 0; const batchSize = 100; for (let i = 0; i < data.items.length; i++) { // 模拟数据处理 const processed = data.items[i] * 2; count++; // 分批发送结果 if (count % batchSize === 0 || i === data.items.length - 1) { const result: BatchResultMessage = { type: 'batch_result', batchId: Math.floor(i / batchSize), processedCount: count, completed: i === data.items.length - 1 }; workerPort.postMessage(result); } // 检查取消标志 if (shouldCancel) { const cancelledMessage: CancelMessage = { type: 'cancelled', processedCount: count }; workerPort.postMessage(cancelledMessage); shouldCancel = false; // 重置取消标志 return; } }}/** * MainPage 类 * 示例类,展示如何使用Worker线程处理数据 */class MainPage { private workerInstance: worker.ThreadWorker | null = null; /** * 启动Worker处理 * 1. 创建Worker实例 * 2. 设置消息接收回调 * 3. 设置错误处理回调 * 4. 发送处理命令 */ async startWorkerProcessing(): Promise<void> { this.workerInstance = new worker.ThreadWorker('entry/ets/workers/DataProcessor.ets'); // 设置消息接收 this.workerInstance.onmessage = (event: ResultMessageEvent): void => { const data: BatchResultMessage | CancelMessage | ErrorMessage = event.data; switch (data.type) { case 'batch_result': const batchResult = data as BatchResultMessage; this.handleBatchResult(batchResult.batchId, batchResult.processedCount, batchResult.completed); break; case 'cancelled': const cancelMessage = data as CancelMessage; this.handleCancellation(cancelMessage.processedCount); break; case 'error': const errorMessage = data as ErrorMessage; this.handleError(errorMessage.error); break; } }; // 设置错误处理 this.workerInstance.onerror = (err: ErrorEvent): void => { hilog.error(0x0000, 'MainPage', `Worker error: ${err.message}`); }; // 发送处理命令 const data = this.generateLargeDataSet(); const message: WorkerMessage = { command: 'process', payload: { items: data } }; this.workerInstance.postMessage(message); } /** * 生成测试数据 * @returns 测试数据数组 */ private generateLargeDataSet(): number[] { const data: number[] = []; for (let i = 0; i < 10000; i++) { data.push(Math.floor(Math.random() * 1000)); } return data; } /** * 处理批量结果 * @param batchId 批次ID * @param processedCount 已处理数量 * @param completed 是否完成 */ private handleBatchResult(batchId: number, processedCount: number, completed: boolean): void { hilog.info(0x0000, 'MainPage', `Batch ${batchId} completed, processed ${processedCount} items${completed ? ', all done!' : ''}`); } /** * 处理取消操作 * @param processedCount 已处理数量 */ private handleCancellation(processedCount: number): void { hilog.info(0x0000, 'MainPage', `Processing cancelled, processed ${processedCount} items`); } /** * 处理错误 * @param error 错误信息 */ private handleError(error: string): void { hilog.error(0x0000, 'MainPage', `Processing error: ${error}`); } /** * 取消处理 */ cancelProcessing(): void { if (this.workerInstance) { const message: WorkerMessage = { command: 'cancel' }; this.workerInstance.postMessage(message); } } /** * 清理Worker */ cleanupWorker(): void { if (this.workerInstance) { this.workerInstance.terminate(); this.workerInstance = null; } }}方案二:Native侧子线程与UI主线程通信4.2.1 基于线程安全函数机制(推荐方案)// Native侧代码 - ThreadSafeCommunicator.cpp#include <napi/native_api.h>#include <hilog/log.h>#include <thread>#include <vector> // 回调上下文结构 struct ThreadCallbackContext { napi_env env; napi_ref jsCallbackRef; std::vector<int> processedData; int requestId;};class ThreadSafeCommunicator { private: napi_threadsafe_function tsFunction_; bool initialized_; public: ThreadSafeCommunicator() : initialized_(false) {} ~ThreadSafeCommunicator() { cleanup();} // 初始化线程安全函数 napi_status initialize(napi_env env, napi_value jsCallback) { if (initialized_) { return napi_ok; } // 创建线程安全函数 napi_status status = napi_create_threadsafe_function( env, jsCallback, nullptr, napi_value("ThreadSafeCallback"), 0, // 无限队列 1, // 初始线程数 nullptr, nullptr, nullptr, [](napi_env env, napi_value js_callback, void* context, void* data) { // 此回调在主线程执行 ThreadCallbackContext* ctx = static_cast<ThreadCallbackContext*>(data); if (env && js_callback && ctx) { // 准备回调参数 napi_value resultArray; napi_create_array_with_length(env, ctx->processedData.size(), &resultArray); for (size_t i = 0; i < ctx->processedData.size(); i++) { napi_value element; napi_create_int32(env, ctx->processedData[i], &element); napi_set_element(env, resultArray, i, element);}napi_value requestId;napi_create_int32(env, ctx->requestId, &requestId);// 调用JavaScript回调napi_value argv[2];argv[0] = resultArray;argv[1] = requestId;napi_value global;napi_get_global(env, &global);napi_call_function(env, global, js_callback, 2, argv, nullptr);}// 清理资源if (ctx) { napi_delete_reference(ctx->env, ctx->jsCallbackRef); delete ctx;}},&tsFunction_);if (status == napi_ok) { initialized_ = true; OH_LOG_INFO(LOG_APP, "Thread safe function initialized.");}return status;}// 从子线程调用ArkTS函数void callFromWorkerThread(const std::vector<int>& data, int requestId, napi_env env, napi_ref callbackRef) { if (!initialized_) { OH_LOG_ERROR(LOG_APP, "Thread safe function not initialized!"); return; } // 准备上下文数据 auto context = new ThreadCallbackContext(); context->env = env; context->jsCallbackRef = callbackRef; context->processedData = data; context->requestId = requestId; // 获取线程安全函数 napi_acquire_threadsafe_function(tsFunction_, napi_tsfn_blocking); // 调用线程安全函数 napi_call_threadsafe_function(tsFunction_, context, napi_tsfn_nonblocking); // 释放线程安全函数 napi_release_threadsafe_function(tsFunction_, napi_tsfn_release);}// 清理资源void cleanup() { if (initialized_ && tsFunction_) { napi_release_threadsafe_function(tsFunction_, napi_tsfn_release); initialized_ = false; }}};// 导出的Native方法static ThreadSafeCommunicator gCommunicator;// 异步处理函数(在子线程执行)static void processInWorkerThread(int requestId) { OH_LOG_INFO(LOG_APP, "Worker thread started for request %{public}d", requestId); // 模拟耗时处理 std::vector<int> results; for (int i = 0; i < 100; i++) { results.push_back(i * requestId); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } OH_LOG_INFO(LOG_APP, "Worker thread completed for request %{public}d", requestId);}// ArkTS侧代码 - NativeCommunicator.etsimport { hilog } from '@kit.PerformanceAnalysisKit';// Native模块声明declare namespace native { function initializeThreadSafeFunction(callback: (data: number[], requestId: number) => void): void; function startProcessing(requestId: number): void; function cleanup(): void;}class NativeCommunicator { private requestCounter: number = 0; // 初始化通信 initialize(): void { try { // 初始化线程安全函数 native.initializeThreadSafeFunction(this.handleNativeCallback.bind(this)); hilog.info(0x0000, 'NativeCommunicator', 'Thread safe function initialized'); } catch (error) { hilog.error(0x0000, 'NativeCommunicator', `Initialization failed: ${error.message}`); } } // 启动Native侧处理 startNativeProcessing(): void { const requestId = ++this.requestCounter; hilog.info(0x0000, 'NativeCommunicator', `Starting native processing request ${requestId}`); try { native.startProcessing(requestId); } catch (error) { hilog.error(0x0000, 'NativeCommunicator', `Failed to start processing: ${error.message}`); } } // 处理Native回调 private handleNativeCallback(data: number[], requestId: number): void { hilog.info(0x0000, 'NativeCommunicator', `Received callback for request ${requestId}, data length: ${data.length}`); // 更新UI或处理数据 this.updateUIWithData(data, requestId); } // 清理资源 cleanup(): void { try { native.cleanup(); hilog.info(0x0000, 'NativeCommunicator', 'Native communicator cleaned up'); } catch (error) { hilog.error(0x0000, 'NativeCommunicator', `Cleanup failed: ${error.message}`); } } // 更新UI(示例) private updateUIWithData(data: number[], requestId: number): void { // 实现UI更新逻辑 const sum = data.reduce((acc, val) => acc + val, 0); hilog.info(0x0000, 'NativeCommunicator', `Request ${requestId}: Sum = ${sum}, Avg = ${(sum / data.length).toFixed(2)}`); }}方案三:使用Emitter进行线程间通信// 事件管理器 - ThreadEventManager.etsimport { emitter, EmitterEvent } from '@ohos.events.emitter';import { hilog } from '@kit.PerformanceAnalysisKit';export enum ThreadEventType { DATA_PROCESSING_START = 'data_processing_start', DATA_PROCESSING_PROGRESS = 'data_processing_progress', DATA_PROCESSING_COMPLETE = 'data_processing_complete', TASK_CANCELLED = 'task_cancelled', ERROR_OCCURRED = 'error_occurred'}export interface ProgressEventData { taskId: string; current: number; total: number; percentage: number;}export interface CompleteEventData { taskId: string; result: number[]; processingTime: number;}export class ThreadEventManager { private static instance: ThreadEventManager; private eventSubscriptions: Map<string, number[]> = new Map(); static getInstance(): ThreadEventManager { if (!ThreadEventManager.instance) { ThreadEventManager.instance = new ThreadEventManager(); } return ThreadEventManager.instance; } // 订阅事件 subscribe(eventType: ThreadEventType, callback: (eventData: EmitterEvent) => void, priority?: number): number { const innerEvent = { eventId: eventType }; const subscriptionId = emitter.on(innerEvent, callback); // 保存订阅ID if (!this.eventSubscriptions.has(eventType)) { this.eventSubscriptions.set(eventType, []); } this.eventSubscriptions.get(eventType)?.push(subscriptionId); return subscriptionId; } // 发布事件 emit(eventType: ThreadEventType, data: unknown): void { const innerEvent = { eventId: eventType, priority: emitter.EventPriority.HIGH }; const eventData = { data: data }; emitter.emit(innerEvent, eventData); } // 取消订阅 unsubscribe(eventType: ThreadEventType, subscriptionId?: number): void { if (subscriptionId) { emitter.off(subscriptionId); // 从映射中移除 const ids = this.eventSubscriptions.get(eventType); if (ids) { const index = ids.indexOf(subscriptionId); if (index > -1) { ids.splice(index, 1); } } } else { // 取消该事件的所有订阅 const ids = this.eventSubscriptions.get(eventType); if (ids) { ids.forEach(id => emitter.off(id)); this.eventSubscriptions.delete(eventType); } } } // 清理所有订阅 cleanup(): void { this.eventSubscriptions.forEach((ids) => { ids.forEach(id => emitter.off(id)); }); this.eventSubscriptions.clear(); }}// 使用示例 - 数据处理控制器class DataProcessorController { private eventManager: ThreadEventManager; private currentTaskId: string | null = null; constructor() { this.eventManager = ThreadEventManager.getInstance(); this.setupEventListeners(); } // 设置事件监听器 private setupEventListeners(): void { // 监听进度更新 this.eventManager.subscribe( ThreadEventType.DATA_PROCESSING_PROGRESS, this.handleProgressUpdate.bind(this) ); // 监听完成事件 this.eventManager.subscribe( ThreadEventType.DATA_PROCESSING_COMPLETE, this.handleCompletion.bind(this) ); // 监听错误事件 this.eventManager.subscribe( ThreadEventType.ERROR_OCCURRED, this.handleError.bind(this) ); } // 启动数据处理任务 async startDataProcessing(data: number[]): Promise<void> { this.currentTaskId = `task_${Date.now()}`; // 通知任务开始 this.eventManager.emit(ThreadEventType.DATA_PROCESSING_START, { taskId: this.currentTaskId, dataSize: data.length, timestamp: Date.now() }); // 在TaskPool中处理数据 const task = new taskpool.Task(this.processDataInTaskPool.bind(this), { taskId: this.currentTaskId, data: data }); try { const result = await taskpool.execute(task); this.eventManager.emit(ThreadEventType.DATA_PROCESSING_COMPLETE, result); } catch (error) { this.eventManager.emit(ThreadEventType.ERROR_OCCURRED, { taskId: this.currentTaskId, error: error.message }); } } // TaskPool中的处理函数 @Concurrent private processDataInTaskPool(params: { taskId: string, data: number[] }): { taskId: string, result: number[], processingTime: number } { const startTime = Date.now(); const results: number[] = []; const batchSize = 100; for (let i = 0; i < params.data.length; i++) { // 处理数据 const processed = params.data[i] * Math.random(); results.push(processed); // 发送进度更新 if (i % batchSize === 0 || i === params.data.length - 1) { ThreadEventManager.getInstance().emit( ThreadEventType.DATA_PROCESSING_PROGRESS, { taskId: params.taskId, current: i + 1, total: params.data.length, percentage: ((i + 1) / params.data.length * 100).toFixed(1) } ); } } const processingTime = Date.now() - startTime; return { taskId: params.taskId, result: results, processingTime: processingTime }; } // 处理进度更新 private handleProgressUpdate(event: EmitterEvent): void { const progressData = event.data as ProgressEventData; hilog.info(0x0000, 'DataProcessor', `Task ${progressData.taskId}: ${progressData.percentage}% completed`); // 更新UI进度 this.updateProgressUI(progressData); } // 处理完成事件 private handleCompletion(event: EmitterEvent): void { const completeData = event.data as CompleteEventData; hilog.info(0x0000, 'DataProcessor', `Task ${completeData.taskId} completed in ${completeData.processingTime}ms`); // 更新UI显示结果 this.displayResults(completeData); } // 处理错误 private handleError(event: EmitterEvent): void { const errorData = event.data as { taskId: string, error: string }; hilog.error(0x0000, 'DataProcessor', `Task ${errorData.taskId} failed: ${errorData.error}`); // 显示错误信息 this.showError(errorData.error); } // UI更新方法(示例) private updateProgressUI(progress: ProgressEventData): void { // 实现UI进度更新 } private displayResults(results: CompleteEventData): void { // 实现结果显示 } private showError(error: string): void { // 实现错误显示 }}五、总结5.1 核心要点总结线程模型理解:掌握HarmonyOS的Actor内存隔离模型,理解不同线程类型(主线程、TaskPool线程、Worker线程)的特性。通信机制选择:· ArkTS线程间:优先使用TaskPool,需要双向通信时使用Worker。· Native与ArkTS:优先使用线程安全函数机制。五、总结5.1 核心要点总结线程模型理解:掌握HarmonyOS的Actor内存隔离模型,理解不同线程类型(主线程、TaskPool线程、Worker线程)的特性。通信机制选择:· ArkTS线程间:优先使用TaskPool,需要双向通信时使用Worker。· Native与ArkTS:优先使用线程安全函数机制。· 进程内通信:使用Emitter进行事件驱动通信。性能优化关键:· 使用@Sendable装饰器减少序列化开销。· 使用SharedArrayBuffer实现高效内存共享。· 控制单次传输数据量(≤16MB)。5.2 最佳实践建议线程数量管理:· Worker线程数量控制在64个以内。· 优先使用TaskPool,由系统自动管理线程生命周期。· I/O密集型任务使用异步接口,避免线程阻塞。通信对象选择:· 小量数据:使用普通可序列化对象。· 大量数据:使用@Sendable对象或SharedArrayBuffer。· 二进制数据:使用ArrayBuffer。· 资源对象:使用Transferable对象(如文件描述符)。错误处理和资源管理:· 及时清理线程和通信资源。· 处理序列化异常和通信超时。· 使用异步锁保证共享数据安全。5.3 版本兼容性本方案基于HarmonyOS API Version 10+设计,支持ArkTS和Native开发。关键API包括:· @kit.ArkTS:TaskPool、Worker、Sendable5.4 性能监控建议在开发过程中使用:· Trace工具:监控线程间通信耗时。· 内存分析:检查SharedArrayBuffer使用情况。· 性能测试:验证多线程通信对应用性能的影响。通过合理选择和应用这些线程间通信方案,开发者可以在HarmonyOS平台上构建高性能、响应迅速的多线程应用,同时避免常见的线程安全和资源管理问题。· @ohos.events.emitter:事件通信· Node-API:线程安全函数· C++标准库:std::thread、pthread1. 进程内通信:使用Emitter进行事件驱动通信。性能优化关键:· 使用@Sendable装饰器减少序列化开销。· 使用SharedArrayBuffer实现高效内存共享。· 控制单次传输数据量(≤16MB)。5.2 最佳实践建议线程数量管理:· Worker线程数量控制在64个以内。· 优先使用TaskPool,由系统自动管理线程生命周期。· I/O密集型任务使用异步接口,避免线程阻塞。通信对象选择:· 小量数据:使用普通可序列化对象。· 大量数据:使用@Sendable对象或SharedArrayBuffer。· 二进制数据:使用ArrayBuffer。· 资源对象:使用Transferable对象(如文件描述符)。错误处理和资源管理:· 及时清理线程和通信资源。· 处理序列化异常和通信超时。· 使用异步锁保证共享数据安全。5.3 版本兼容性本方案基于HarmonyOS API Version 10+设计,支持ArkTS和Native开发。关键API包括:· @kit.ArkTS:TaskPool、Worker、Sendable· @ohos.events.emitter:事件通信· Node-API:线程安全函数· C++标准库:std::thread、pthread5.4 性能监控建议在开发过程中使用:1. Trace工具:监控线程间通信耗时。2. 内存分析:检查SharedArrayBuffer使用情况。3. 性能测试:验证多线程通信对应用性能的影响。通过合理选择和应用这些线程间通信方案,开发者可以在HarmonyOS平台上构建高性能、响应迅速的多线程应用,同时避免常见的线程安全和资源管理问题。
-
案例概述1.1 问题背景在移动应用开发中,TabBar是常见的底部导航组件。传统的TabBar通常采用平面设计,但为了提升用户体验和视觉吸引力,开发者需要实现以下效果:凸起效果:中间按钮凸起,吸引用户点击凹陷效果:TabBar整体凹陷,增强立体感动态交互:点击动画、悬浮效果状态管理:选中状态、未选中状态区分适配性:兼容不同设备尺寸1.2 解决方案概述本案例提供完整的自定义TabBar解决方案,包含:● 凸起TabBar:中间按钮凸起,带动画效果● 凹陷TabBar:整体凹陷设计,增强立体感● 平滑过渡:Tab切换平滑动画● 状态管理:完整的Tab状态管理● 主题适配:支持深色/浅色模式实现步骤详解步骤1:定义数据模型和配置类// TabBarModels.etsexport interface TabItem {id: string; // Tab唯一标识text: string; // 显示文本icon: Resource; // 图标资源activeIcon: Resource; // 激活图标badge?: number | string; // 角标disabled?: boolean; // 是否禁用accessibilityLabel?: string; // 无障碍标签}export interface TabBarMetrics {height: number; // TabBar高度width: number; // TabBar宽度itemWidth: number; // 每个Tab宽度bulgeHeight: number; // 凸起高度indentDepth: number; // 凹陷深度safeAreaBottom: number; // 底部安全区域}export interface TabAnimationConfig {duration: number; // 动画时长curve: number; // 动画曲线scale: number; // 缩放比例translateY: number; // Y轴偏移}export interface TabStyle {normalColor: ResourceColor; // 正常颜色activeColor: ResourceColor; // 激活颜色backgroundColor: ResourceColor; // 背景色borderColor: ResourceColor; // 边框颜色shadowColor: ResourceColor; // 阴影颜色textSize: number; // 文字大小iconSize: number; // 图标大小borderRadius: number; // 圆角半径}export class TabBarConfig {// 凸起TabBar配置static readonly BULGE_CONFIG = {height: 80, // 总高度bulgeHeight: 20, // 凸起高度bulgeWidth: 60, // 凸起宽度iconSize: 28, // 图标大小textSize: 12, // 文字大小borderRadius: 40, // 圆角半径shadowBlur: 20, // 阴影模糊shadowOffsetY: 4, // 阴影偏移highlightScale: 1.2, // 高亮缩放};// 凹陷TabBar配置static readonly INDENT_CONFIG = {height: 70, // 总高度indentDepth: 8, // 凹陷深度borderWidth: 1, // 边框宽度iconSize: 24, // 图标大小textSize: 11, // 文字大小borderRadius: 20, // 圆角半径innerPadding: 8, // 内边距highlightDepth: 4, // 高亮深度};// 动画配置static readonly ANIMATION_CONFIG = {tabChange: {duration: 300, // Tab切换动画curve: Curve.EaseInOut,},bulgePress: {duration: 200, // 凸起按钮按下curve: Curve.FastOutLinearIn,},indentPress: {duration: 150, // 凹陷按钮按下curve: Curve.EaseOut,},badgeUpdate: {duration: 300, // 角标更新curve: Curve.Spring,},};// 样式配置static readonly STYLE_CONFIG = {light: {normalColor: ‘#666666’, // 正常状态颜色activeColor: ‘#0066FF’, // 激活状态颜色backgroundColor: ‘#FFFFFF’, // 背景色borderColor: ‘#F0F0F0’, // 边框颜色shadowColor: ‘#40000000’, // 阴影颜色bulgeColor: ‘#0066FF’, // 凸起按钮颜色bulgeShadow: ‘#400066FF’, // 凸起按钮阴影},dark: {normalColor: ‘#AAAAAA’,activeColor: ‘#4D94FF’,backgroundColor: ‘#1C1C1E’,borderColor: ‘#2C2C2E’,shadowColor: ‘#40000000’,bulgeColor: ‘#4D94FF’,bulgeShadow: ‘#404D94FF’,},};}步骤1是整个自定义TabBar系统的基础架构设计,我们首先建立了一套完整的数据模型和配置体系。这个步骤的目的是为TabBar的各种状态、样式、动画等提供类型安全的定义和可配置的参数。步骤2:实现TabBar管理器// TabBarManager.etsimport { hilog } from ‘@kit.PerformanceAnalysisKit’;export class TabBarManager {private static instance: TabBarManager;private currentTab: string = ‘’;private previousTab: string = ‘’;private tabs: Map<string, TabItem> = new Map();private tabChangeCallbacks: Array<(tabId: string) => void> = [];private badgeUpdateCallbacks: Array<(tabId: string, badge: number | string) => void> = [];private animationStateCallbacks: Array<(tabId: string, isAnimating: boolean) => void> = [];private theme: ‘light’ | ‘dark’ = ‘light’;private metrics: TabBarMetrics = {height: 0,width: 0,itemWidth: 0,bulgeHeight: 0,indentDepth: 0,safeAreaBottom: 0};private constructor() {this.initialize();}public static getInstance(): TabBarManager {if (!TabBarManager.instance) {TabBarManager.instance = new TabBarManager();}return TabBarManager.instance;}private initialize(): void {this.initThemeListener();hilog.info(0x0000, ‘TabBarManager’, ‘TabBar管理器初始化完成’);}private initThemeListener(): void {// 监听系统主题变化try {const context = getContext(this) as common.UIAbilityContext;// 这里可以添加主题监听逻辑} catch (error) {hilog.error(0x0000, ‘TabBarManager’, '初始化主题监听失败: ’ + JSON.stringify(error));}}// 注册Tabpublic registerTab(tab: TabItem): void {this.tabs.set(tab.id, tab);if (!this.currentTab && this.tabs.size > 0) {this.currentTab = tab.id;}hilog.info(0x0000, ‘TabBarManager’, 注册Tab: ${tab.id} - ${tab.text});}// 切换Tabpublic switchTab(tabId: string, animated: boolean = true): boolean {if (!this.tabs.has(tabId)) {hilog.warn(0x0000, ‘TabBarManager’, Tab不存在: ${tabId});return false;}const tab = this.tabs.get(tabId)!; if (tab.disabled) { hilog.warn(0x0000, 'TabBarManager', `Tab被禁用: ${tabId}`); return false; } if (tabId === this.currentTab) { return false; } this.previousTab = this.currentTab; this.currentTab = tabId; hilog.info(0x0000, 'TabBarManager', `切换Tab: ${this.previousTab} -> ${this.currentTab}, 动画: ${animated}`); // 通知所有监听者 this.tabChangeCallbacks.forEach(callback => { callback(tabId); }); return true;}// 更新角标public updateBadge(tabId: string, badge: number | string): boolean {if (!this.tabs.has(tabId)) {return false;}const tab = this.tabs.get(tabId)!; tab.badge = badge; // 通知角标更新 this.badgeUpdateCallbacks.forEach(callback => { callback(tabId, badge); }); hilog.info(0x0000, 'TabBarManager', `更新角标: ${tabId} = ${badge}`); return true;}// 获取当前Tabpublic getCurrentTab(): string {return this.currentTab;}// 获取Tab信息public getTabInfo(tabId: string): TabItem | undefined {return this.tabs.get(tabId);}// 获取所有Tabpublic getAllTabs(): TabItem[] {return Array.from(this.tabs.values());}// 设置尺寸信息public setMetrics(metrics: TabBarMetrics): void {this.metrics = metrics;}// 获取尺寸信息public getMetrics(): TabBarMetrics {return this.metrics;}// 设置主题public setTheme(theme: ‘light’ | ‘dark’): void {this.theme = theme;hilog.info(0x0000, ‘TabBarManager’, 切换主题: ${theme});}// 获取当前主题public getCurrentTheme(): ‘light’ | ‘dark’ {return this.theme;}// 获取主题样式public getThemeStyle(): TabStyle {const style = TabBarConfig.STYLE_CONFIG[this.theme];return {…style,textSize: 14,iconSize: 24,borderRadius: 20};}// 注册监听器public onTabChange(callback: (tabId: string) => void): void {this.tabChangeCallbacks.push(callback);}public onBadgeUpdate(callback: (tabId: string, badge: number | string) => void): void {this.badgeUpdateCallbacks.push(callback);}public onAnimationStateChange(callback: (tabId: string, isAnimating: boolean) => void): void {this.animationStateCallbacks.push(callback);}// 通知动画状态public notifyAnimationState(tabId: string, isAnimating: boolean): void {this.animationStateCallbacks.forEach(callback => {callback(tabId, isAnimating);});}// 清理public destroy(): void {this.tabs.clear();this.tabChangeCallbacks = [];this.badgeUpdateCallbacks = [];this.animationStateCallbacks = [];hilog.info(0x0000, ‘TabBarManager’, ‘TabBar管理器已销毁’);}}步骤2实现了TabBar的核心管理器,这是一个单例模式的协调者,负责管理所有Tab的状态、协调组件间的通信、处理主题切换等全局功能。步骤3:实现凸起TabBar组件// BulgeTabBar.ets@Componentexport struct BulgeTabBar {private tabManager: TabBarManager = TabBarManager.getInstance();@State tabs: TabItem[] = [];@State currentTab: string = ‘’;@State animationStates: Map<string, boolean> = new Map();@State bulgeAnimationValue: number = 1;@State isThemeDark: boolean = false;@Prop onTabChange?: (tabId: string) => void;@Prop selectedColor?: ResourceColor;@Prop normalColor?: ResourceColor;@Prop backgroundColor?: ResourceColor;@Prop bulgeColor?: ResourceColor;@Prop height: number = TabBarConfig.BULGE_CONFIG.height;@Prop bulgeHeight: number = TabBarConfig.BULGE_CONFIG.bulgeHeight;@Prop showDivider: boolean = true;private bulgeAnimation: AnimationController = new AnimationController({ duration: 200 });aboutToAppear() {this.tabs = this.tabManager.getAllTabs();this.currentTab = this.tabManager.getCurrentTab();this.setupListeners();// 初始化动画状态 this.tabs.forEach(tab => { this.animationStates.set(tab.id, false); });}aboutToDisappear() {this.bulgeAnimation.reset();}private setupListeners(): void {this.tabManager.onTabChange((tabId: string) => {this.currentTab = tabId;});this.tabManager.onBadgeUpdate((tabId: string, badge: number | string) => { const index = this.tabs.findIndex(tab => tab.id === tabId); if (index !== -1) { this.tabs[index].badge = badge; this.tabs = [...this.tabs]; // 触发更新 } });}private onTabClick(tab: TabItem, index: number): void {if (tab.disabled) {return;}// 触发动画 this.triggerTabAnimation(tab.id); // 中间凸起按钮特殊动画 if (index === Math.floor(this.tabs.length / 2)) { this.triggerBulgeAnimation(); } // 切换Tab if (this.tabManager.switchTab(tab.id, true)) { this.onTabChange?.(tab.id); }}private triggerTabAnimation(tabId: string): void {this.animationStates.set(tabId, true);this.animationStates = new Map(this.animationStates);this.tabManager.notifyAnimationState(tabId, true); setTimeout(() => { this.animationStates.set(tabId, false); this.animationStates = new Map(this.animationStates); this.tabManager.notifyAnimationState(tabId, false); }, TabBarConfig.ANIMATION_CONFIG.tabChange.duration);}private triggerBulgeAnimation(): void {this.bulgeAnimation.value = 1;this.bulgeAnimation.play();}@Builderprivate buildTabItem(tab: TabItem, index: number) {const isActive = tab.id === this.currentTab;const isAnimating = this.animationStates.get(tab.id) || false;const isMiddleTab = index === Math.floor(this.tabs.length / 2);const themeStyle = this.tabManager.getThemeStyle();const config = TabBarConfig.BULGE_CONFIG;if (isMiddleTab) { // 中间凸起按钮 this.buildBulgeTab(tab, isActive, isAnimating, themeStyle); } else { // 普通Tab按钮 this.buildNormalTab(tab, isActive, isAnimating, themeStyle); }}@Builderprivate buildNormalTab(tab: TabItem, isActive: boolean, isAnimating: boolean, style: TabStyle) {const config = TabBarConfig.BULGE_CONFIG;const scale = isAnimating ? config.highlightScale : 1;const opacity = isAnimating ? 0.8 : 1;const translateY = isAnimating ? -5 : 0;Column() { // 角标 if (tab.badge !== undefined) { this.buildBadge(tab.badge); } // 图标 Stack({ alignContent: Alignment.Center }) { Image(isActive ? tab.activeIcon : tab.icon) .width(config.iconSize) .height(config.iconSize) .objectFit(ImageFit.Contain) .interpolation(ImageInterpolation.High) } .width(config.iconSize + 20) .height(config.iconSize + 20) .borderRadius((config.iconSize + 20) / 2) .backgroundColor(isActive ? `${style.activeColor}20` : Color.Transparent) // 文本 Text(tab.text) .fontSize(config.textSize) .fontColor(isActive ? style.activeColor : style.normalColor) .fontWeight(isActive ? FontWeight.Medium : FontWeight.Normal) .margin({ top: 4 }) .opacity(isActive ? 1 : 0.8) } .scale({ x: scale, y: scale }) .opacity(opacity) .translate({ y: translateY }) .animation({ duration: TabBarConfig.ANIMATION_CONFIG.tabChange.duration, curve: TabBarConfig.ANIMATION_CONFIG.tabChange.curve }) .width('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .padding({ top: 8, bottom: 8 }) .onClick(() => this.onTabClick(tab, index))}@Builderprivate buildBulgeTab(tab: TabItem, isActive: boolean, isAnimating: boolean, style: TabStyle) {const config = TabBarConfig.BULGE_CONFIG;const bulgeScale = isAnimating ? 1.1 : 1;const shadowOpacity = isAnimating ? 0.6 : 0.4;const translateY = isAnimating ? -config.bulgeHeight * 0.5 : -config.bulgeHeight * 0.3;Column() { // 凸起背景 Circle() .width(config.iconSize + 40) .height(config.iconSize + 40) .fill(this.bulgeColor || style.bulgeColor) .shadow({ radius: config.shadowBlur, color: style.bulgeShadow, offsetY: config.shadowOffsetY }) .opacity(shadowOpacity) // 图标容器 Circle() .width(config.iconSize + 30) .height(config.iconSize + 30) .fill(this.backgroundColor || style.backgroundColor) .margin({ top: -config.iconSize - 30 }) // 图标 Image(isActive ? tab.activeIcon : tab.icon) .width(config.iconSize + 10) .height(config.iconSize + 10) .objectFit(ImageFit.Contain) .interpolation(ImageInterpolation.High) .margin({ top: -config.iconSize - 25 }) } .scale({ x: bulgeScale, y: bulgeScale }) .translate({ y: translateY }) .animation({ duration: TabBarConfig.ANIMATION_CONFIG.bulgePress.duration, curve: TabBarConfig.ANIMATION_CONFIG.bulgePress.curve }) .width('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .onClick(() => this.onTabClick(tab, Math.floor(this.tabs.length / 2)))}@Builderprivate buildBadge(badge: number | string) {const isNumber = typeof badge === ‘number’;const showDot = !isNumber && badge === ‘dot’;if (showDot) { // 小红点 Circle() .width(8) .height(8) .fill('#FF3B30') .position({ x: '60%', y: '10%' }) } else { // 数字角标 Text(badge.toString()) .fontSize(10) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .backgroundColor('#FF3B30') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(10) .position({ x: '60%', y: '5%' }) .maxLines(1) .minFontSize(8) }}@Builderprivate buildDivider() {if (this.showDivider) {Divider().strokeWidth(0.5).color(‘#F0F0F0’).margin({ left: 16, right: 16 })}}build() {const themeStyle = this.tabManager.getThemeStyle();const config = TabBarConfig.BULGE_CONFIG;const totalHeight = this.height + this.bulgeHeight + this.tabManager.getMetrics().safeAreaBottom;Column() { // 顶部边框 this.buildDivider() // Tab内容 Row() { ForEach(this.tabs, (tab: TabItem, index: number) => { Column() { this.buildTabItem(tab, index) } .layoutWeight(1) .height(this.height) }) } .width('100%') .height(this.height) .backgroundColor(this.backgroundColor || themeStyle.backgroundColor) } .width('100%') .height(totalHeight) .backgroundColor(this.backgroundColor || themeStyle.backgroundColor) .shadow({ radius: config.shadowBlur, color: themeStyle.shadowColor, offsetY: -config.shadowOffsetY }) .position({ x: 0, y: 0 })}}步骤3实现了具有凸起效果的TabBar组件,这是本案例的核心视觉组件之一。该组件的特点是中间按钮会凸起显示,吸引用户注意力,常用于突出重要功能(如发布、拍照等)步骤4:实现凹陷TabBar组件// IndentTabBar.ets@Componentexport struct IndentTabBar {private tabManager: TabBarManager = TabBarManager.getInstance();@State tabs: TabItem[] = [];@State currentTab: string = ‘’;@State animationStates: Map<string, boolean> = new Map();@State pressDepth: Map<string, number> = new Map();@State isThemeDark: boolean = false;@Prop onTabChange?: (tabId: string) => void;@Prop selectedColor?: ResourceColor;@Prop normalColor?: ResourceColor;@Prop backgroundColor?: ResourceColor;@Prop borderColor?: ResourceColor;@Prop height: number = TabBarConfig.INDENT_CONFIG.height;@Prop indentDepth: number = TabBarConfig.INDENT_CONFIG.indentDepth;@Prop showBorder: boolean = true;private longPressTimer: Map<string, number> = new Map();aboutToAppear() {this.tabs = this.tabManager.getAllTabs();this.currentTab = this.tabManager.getCurrentTab();this.setupListeners();// 初始化状态 this.tabs.forEach(tab => { this.animationStates.set(tab.id, false); this.pressDepth.set(tab.id, 0); });}aboutToDisappear() {// 清理定时器this.longPressTimer.forEach(timer => {clearTimeout(timer);});this.longPressTimer.clear();}private setupListeners(): void {this.tabManager.onTabChange((tabId: string) => {this.currentTab = tabId;});this.tabManager.onBadgeUpdate((tabId: string, badge: number | string) => { const index = this.tabs.findIndex(tab => tab.id === tabId); if (index !== -1) { this.tabs[index].badge = badge; this.tabs = [...this.tabs]; } });}private onTabClick(tab: TabItem, index: number): void {if (tab.disabled) {return;}// 触发按下动画 this.triggerPressAnimation(tab.id, index); // 切换Tab if (this.tabManager.switchTab(tab.id, true)) { this.onTabChange?.(tab.id); }}private onTabPressIn(tabId: string, index: number): void {this.pressDepth.set(tabId, TabBarConfig.INDENT_CONFIG.highlightDepth);this.pressDepth = new Map(this.pressDepth);}private onTabPressOut(tabId: string, index: number): void {this.pressDepth.set(tabId, 0);this.pressDepth = new Map(this.pressDepth);}private onTabLongPress(tab: TabItem, index: number): void {if (tab.disabled) {return;}// 长按反馈 this.triggerLongPressAnimation(tab.id); hilog.info(0x0000, 'IndentTabBar', `长按Tab: ${tab.text}`); // 可以在这里触发预览或其他功能}private triggerPressAnimation(tabId: string, index: number): void {this.animationStates.set(tabId, true);this.animationStates = new Map(this.animationStates);this.tabManager.notifyAnimationState(tabId, true); setTimeout(() => { this.animationStates.set(tabId, false); this.animationStates = new Map(this.animationStates); this.tabManager.notifyAnimationState(tabId, false); }, TabBarConfig.ANIMATION_CONFIG.indentPress.duration);}private triggerLongPressAnimation(tabId: string): void {this.pressDepth.set(tabId, TabBarConfig.INDENT_CONFIG.highlightDepth * 2);this.pressDepth = new Map(this.pressDepth);setTimeout(() => { this.pressDepth.set(tabId, 0); this.pressDepth = new Map(this.pressDepth); }, 300);}@Builderprivate buildTabItem(tab: TabItem, index: number) {const isActive = tab.id === this.currentTab;const isAnimating = this.animationStates.get(tab.id) || false;const depth = this.pressDepth.get(tab.id) || 0;const themeStyle = this.tabManager.getThemeStyle();const config = TabBarConfig.INDENT_CONFIG;Column() { // Tab项 Column() { // 角标 if (tab.badge !== undefined) { this.buildBadge(tab.badge); } // 凹陷背景 Column() .width('80%') .height('80%') .backgroundColor(isActive ? `${themeStyle.activeColor}20` : `${themeStyle.normalColor}10`) .borderRadius(config.borderRadius - depth) .margin({ top: depth }) // 图标和文字 Column() { // 图标 Stack({ alignContent: Alignment.Center }) { Image(isActive ? tab.activeIcon : tab.icon) .width(config.iconSize) .height(config.iconSize) .objectFit(ImageFit.Contain) .interpolation(ImageInterpolation.High) .margin({ top: depth * 0.5 }) } .width(config.iconSize + 20) .height(config.iconSize + 20) .borderRadius((config.iconSize + 20) / 2) .backgroundColor(isActive ? `${themeStyle.activeColor}30` : Color.Transparent) // 文字 Text(tab.text) .fontSize(config.textSize) .fontColor(isActive ? themeStyle.activeColor : themeStyle.normalColor) .fontWeight(isActive ? FontWeight.Medium : FontWeight.Normal) .margin({ top: 6 }) .opacity(isActive ? 1 : 0.8) } .margin({ top: -depth }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .borderRadius(config.borderRadius) .backgroundColor(themeStyle.backgroundColor) .shadow({ radius: depth > 0 ? 4 : 2, color: '#40000000', offsetY: depth > 0 ? 2 : 1 }) .overlay( Column() .width('100%') .height('100%') .borderRadius(config.borderRadius) .border({ width: 0.5, color: depth > 0 ? `${themeStyle.activeColor}30` : `${themeStyle.borderColor}` }) ) } .scale({ x: isAnimating ? 0.95 : 1, y: isAnimating ? 0.95 : 1 }) .animation({ duration: TabBarConfig.ANIMATION_CONFIG.indentPress.duration, curve: TabBarConfig.ANIMATION_CONFIG.indentPress.curve }) .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .onClick(() => this.onTabClick(tab, index)) .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down) { this.onTabPressIn(tab.id, index); } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { this.onTabPressOut(tab.id, index); } }) .onClick(() => this.onTabClick(tab, index)) .onLongPress(() => this.onTabLongPress(tab, index))}@Builderprivate buildBadge(badge: number | string) {const isNumber = typeof badge === ‘number’;const showDot = !isNumber && badge === ‘dot’;if (showDot) { // 小红点 Circle() .width(6) .height(6) .fill('#FF3B30') .position({ x: '60%', y: '15%' }) } else { // 数字角标 Text(badge.toString()) .fontSize(9) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .backgroundColor('#FF3B30') .padding({ left: 4, right: 4, top: 1, bottom: 1 }) .borderRadius(8) .position({ x: '60%', y: '10%' }) .maxLines(1) .minFontSize(7) }}@Builderprivate buildIndentBackground() {const themeStyle = this.tabManager.getThemeStyle();const config = TabBarConfig.INDENT_CONFIG;const totalHeight = this.height + this.indentDepth + this.tabManager.getMetrics().safeAreaBottom;Column() { // 主凹陷区域 Row() .width('100%') .height(this.height) .backgroundColor(themeStyle.backgroundColor) .borderRadius(config.borderRadius) .border({ width: this.showBorder ? config.borderWidth : 0, color: this.borderColor || themeStyle.borderColor }) .shadow({ radius: 8, color: themeStyle.shadowColor, offsetY: 2 }) .padding({ left: 8, right: 8, top: 8, bottom: 8 }) // 底部填充 Column() .width('100%') .height(this.indentDepth) .backgroundColor(themeStyle.backgroundColor) } .width('100%') .height(totalHeight) .backgroundColor(themeStyle.backgroundColor)}build() {const themeStyle = this.tabManager.getThemeStyle();const config = TabBarConfig.INDENT_CONFIG;const totalHeight = this.height + this.indentDepth + this.tabManager.getMetrics().safeAreaBottom;Stack({ alignContent: Alignment.Bottom }) { // 凹陷背景 this.buildIndentBackground() // Tab内容 Row() { ForEach(this.tabs, (tab: TabItem, index: number) => { Column() { this.buildTabItem(tab, index) } .layoutWeight(1) .height(this.height - 16) // 减去内边距 .margin({ left: 4, right: 4, top: 8, bottom: 8 }) }) } .width('100%') .height(this.height) } .width('100%') .height(totalHeight)}}步骤4实现了具有凹陷效果的TabBar组件,这是另一种常见的TabBar设计风格。与凸起效果相反,凹陷TabBar让整个TabBar区域看起来像是嵌入到界面中,营造出精致、现代的视觉感受。步骤5:实现TabBar容器页面// CustomTabBarPage.ets@Entry@Componentexport struct CustomTabBarPage {@State currentPage: number = 0;@State selectedTab: string = ‘home’;@State themeMode: ‘light’ | ‘dark’ = ‘light’;@State showBulgeTabBar: boolean = true;@State badgeCounts: Map<string, number> = new Map([[‘message’, 3],[‘mine’, 99]]);private tabManager: TabBarManager = TabBarManager.getInstance();// 定义Tab数据private tabs: TabItem[] = [{id: ‘home’,text: ‘首页’,icon: $r(‘app.media.ic_home’),activeIcon: $r(‘app.media.ic_home_fill’),accessibilityLabel: ‘首页’},{id: ‘explore’,text: ‘发现’,icon: $r(‘app.media.ic_explore’),activeIcon: $r(‘app.media.ic_explore_fill’),accessibilityLabel: ‘发现’},{id: ‘add’,text: ‘发布’,icon: $r(‘app.media.ic_add’),activeIcon: $r(‘app.media.ic_add_fill’),accessibilityLabel: ‘发布’},{id: ‘message’,text: ‘消息’,icon: $r(‘app.media.ic_message’),activeIcon: $r(‘app.media.ic_message_fill’),badge: 3,accessibilityLabel: ‘消息’},{id: ‘mine’,text: ‘我的’,icon: $r(‘app.media.ic_mine’),activeIcon: $r(‘app.media.ic_mine_fill’),badge: 99,accessibilityLabel: ‘我的’}];aboutToAppear() {// 注册所有Tabthis.tabs.forEach(tab => {this.tabManager.registerTab(tab);});// 设置初始Tab this.tabManager.switchTab('home'); // 监听Tab变化 this.tabManager.onTabChange((tabId: string) => { this.selectedTab = tabId; this.currentPage = this.tabs.findIndex(tab => tab.id === tabId); }); hilog.info(0x0000, 'CustomTabBarPage', 'TabBar页面初始化完成');}aboutToDisappear() {this.tabManager.destroy();}private switchTheme(): void {this.themeMode = this.themeMode === ‘light’ ? ‘dark’ : ‘light’;this.tabManager.setTheme(this.themeMode);}private switchTabBarType(): void {this.showBulgeTabBar = !this.showBulgeTabBar;}private updateBadge(tabId: string): void {const currentBadge = this.badgeCounts.get(tabId) || 0;const newBadge = currentBadge + 1;this.badgeCounts.set(tabId, newBadge);this.tabManager.updateBadge(tabId, newBadge > 99 ? ‘99+’ : newBadge);}private clearBadge(tabId: string): void {this.badgeCounts.set(tabId, 0);this.tabManager.updateBadge(tabId, 0);}@Builderprivate buildControlPanel() {Column({ space: 16 }) {Text(‘自定义TabBar演示’).fontSize(20).fontWeight(FontWeight.Bold).fontColor(this.themeMode === ‘dark’ ? ‘#FFFFFF’ : ‘#000000’).margin({ bottom: 20 }) Row({ space: 12 }) { Button('切换主题') .layoutWeight(1) .height(40) .backgroundColor(this.themeMode === 'dark' ? '#4D94FF' : '#0066FF') .fontColor('#FFFFFF') .onClick(() => this.switchTheme()) Button(this.showBulgeTabBar ? '凹陷效果' : '凸起效果') .layoutWeight(1) .height(40) .backgroundColor('#34C759') .fontColor('#FFFFFF') .onClick(() => this.switchTabBarType()) } Row({ space: 12 }) { Button('消息+1') .layoutWeight(1) .height(40) .backgroundColor('#FF9500') .fontColor('#FFFFFF') .onClick(() => this.updateBadge('message')) Button('清空消息') .layoutWeight(1) .height(40) .backgroundColor('#FF3B30') .fontColor('#FFFFFF') .onClick(() => this.clearBadge('message')) } Row({ space: 12 }) { Button('我的+1') .layoutWeight(1) .height(40) .backgroundColor('#AF52DE') .fontColor('#FFFFFF') .onClick(() => this.updateBadge('mine')) Button('清空我的') .layoutWeight(1) .height(40) .backgroundColor('#FF2D55') .fontColor('#FFFFFF') .onClick(() => this.clearBadge('mine')) } Text(`当前Tab: ${this.selectedTab}`) .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') .margin({ top: 20 }) Text(`当前主题: ${this.themeMode === 'dark' ? '深色' : '浅色'}`) .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') Text(`TabBar类型: ${this.showBulgeTabBar ? '凸起效果' : '凹陷效果'}`) .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') } .width('100%') .padding(20) .backgroundColor(this.themeMode === 'dark' ? '#1C1C1E' : '#F8F8F8') .borderRadius(20) .margin({ left: 20, right: 20, top: 20 })}@Builderprivate buildContent() {TabContent() {// 首页TabContentItem() {Column() {this.buildControlPanel() Column({ space: 12 }) { Text('凸起TabBar特点') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(this.themeMode === 'dark' ? '#FFFFFF' : '#000000') Text('• 中间按钮凸起设计,增强视觉层次') Text('• 凸起按钮带动画效果,提升交互反馈') Text('• 支持角标显示,包括数字和小红点') Text('• 平滑的切换动画,优化用户体验') Text('• 自适应主题,支持深色/浅色模式') } .width('100%') .padding(20) .backgroundColor(this.themeMode === 'dark' ? '#2C2C2E' : '#FFFFFF') .borderRadius(12) .margin({ top: 20, left: 20, right: 20 }) } .width('100%') .height('100%') .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Auto) } .tabBar(this.buildTabBar('首页', 'app.media.ic_home_fill')) // 发现页 TabContentItem() { Column({ space: 20 }) { Text('发现') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.themeMode === 'dark' ? '#FFFFFF' : '#000000') Text('这是发现页面内容') .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } .tabBar(this.buildTabBar('发现', 'app.media.ic_explore_fill')) // 发布页 TabContentItem() { Column({ space: 20 }) { Text('发布') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.themeMode === 'dark' ? '#FFFFFF' : '#000000') Text('这是发布页面内容') .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } .tabBar(this.buildTabBar('发布', 'app.media.ic_add_fill')) // 消息页 TabContentItem() { Column({ space: 20 }) { Text('消息') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.themeMode === 'dark' ? '#FFFFFF' : '#000000') Text('这是消息页面内容') .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } .tabBar(this.buildTabBar('消息', 'app.media.ic_message_fill')) // 我的页 TabContentItem() { Column({ space: 20 }) { Text('我的') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.themeMode === 'dark' ? '#FFFFFF' : '#000000') Text('这是我的页面内容') .fontSize(16) .fontColor(this.themeMode === 'dark' ? '#AAAAAA' : '#666666') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } .tabBar(this.buildTabBar('我的', 'app.media.ic_mine_fill')) } .vertical(false) .scrollable(true) .barPosition(BarPosition.End) .onChange((index: number) => { const tabId = this.tabs[index]?.id; if (tabId) { this.selectedTab = tabId; this.tabManager.switchTab(tabId); } })}@Builderprivate buildTabBar(text: string, icon: Resource) {Row() {Image(icon).width(20).height(20).objectFit(ImageFit.Contain).margin({ right: 6 }) Text(text) .fontSize(14) }}@Builderprivate buildCustomTabBar() {if (this.showBulgeTabBar) {// 凸起TabBarBulgeTabBar({onTabChange: (tabId: string) => {this.selectedTab = tabId;this.currentPage = this.tabs.findIndex(tab => tab.id === tabId);},selectedColor: this.themeMode === ‘dark’ ? ‘#4D94FF’ : ‘#0066FF’,normalColor: this.themeMode === ‘dark’ ? ‘#AAAAAA’ : ‘#666666’,backgroundColor: this.themeMode === ‘dark’ ? ‘#1C1C1E’ : ‘#FFFFFF’,bulgeColor: this.themeMode === ‘dark’ ? ‘#4D94FF’ : ‘#0066FF’,height: 60,bulgeHeight: 20})} else {// 凹陷TabBarIndentTabBar({onTabChange: (tabId: string) => {this.selectedTab = tabId;this.currentPage = this.tabs.findIndex(tab => tab.id === tabId);},selectedColor: this.themeMode === ‘dark’ ? ‘#4D94FF’ : ‘#0066FF’,normalColor: this.themeMode === ‘dark’ ? ‘#AAAAAA’ : ‘#666666’,backgroundColor: this.themeMode === ‘dark’ ? ‘#1C1C1E’ : ‘#FFFFFF’,borderColor: this.themeMode === ‘dark’ ? ‘#2C2C2E’ : ‘#F0F0F0’,height: 60,indentDepth: 10,showBorder: true})}}build() {Column() {// 主内容Column().layoutWeight(1).width(‘100%’).backgroundColor(this.themeMode === ‘dark’ ? ‘#000000’ : ‘#F8F8F8’) // 自定义TabBar this.buildCustomTabBar() } .width('100%') .height('100%') .backgroundColor(this.themeMode === 'dark' ? '#000000' : '#F8F8F8')}}步骤5实现了一个完整的演示页面,将前面所有的组件和功能整合在一起,展示自定义TabBar的实际应用效果。这个页面不仅是一个演示,也是一个功能完备的应用界面模板。技术架构总结核心特点双模式支持:凸起和凹陷两种设计风格完整动画:点击、长按、切换都有平滑动画主题适配:支持深色/浅色模式高度可配置:所有参数均可自定义良好性能:使用状态管理和动画优化使用方式// 使用凸起TabBarBulgeTabBar({onTabChange: (tabId) => console.log(tabId),height: 60,bulgeHeight: 20})// 使用凹陷TabBarIndentTabBar({onTabChange: (tabId) => console.log(tabId),height: 60,indentDepth: 10})这个案例提供了完整的自定义TabBar实现,可以直接集成到您的HarmonyOS应用中,或者作为学习和参考的模板。
-
案例概述1.1 问题背景在移动应用开发中,当用户在输入框中输入内容时,系统软键盘会自动从屏幕底部弹出。在手机等小屏幕设备上,软键盘通常会占据30%-50%的屏幕高度,导致以下问题:输入框被遮挡:用户看不到正在输入的内容操作按钮被遮挡:提交、下一步等关键按钮不可见页面布局混乱:未考虑键盘高度的固定布局会被打乱用户体验差:需要手动滚动或关闭键盘才能看到完整内容1.2 解决方案概述本案例提供完整的软键盘避让解决方案,通过以下核心机制实现:键盘事件监听:实时监听软键盘的显示和隐藏动态布局调整:根据键盘状态自动调整页面布局智能滚动定位:确保当前输入框始终在可视区域内平滑动画过渡:使用动画实现自然的布局变化边界情况处理:处理各种特殊场景和边缘情况实现步骤详解步骤1:定义数据模型和工具类目的:为软键盘避让功能建立完整的数据结构和配置体系,为后续功能实现提供类型安全和配置支持。核心功能说明:键盘信息管理:定义键盘状态的数据结构,包括高度、可见性、动画信息等输入框信息管理:管理页面上所有输入框的位置、状态和验证规则屏幕尺寸计算:精确计算不同设备的可用屏幕空间,考虑安全区域滚动状态管理:支持动画滚动和位置追踪统一配置管理:集中管理所有可调参数,便于维护和优化关键技术点:● 使用TypeScript接口确保类型安全● 考虑不同设备的屏幕差异(刘海屏、圆角屏、虚拟导航栏)● 配置参数分离,便于适配不同需求● 支持动画效果配置,提升用户体验步骤说明:首先定义软键盘避让功能所需的核心数据结构和配置类,这是整个系统的基础。步骤1实现代码:// KeyboardAvoidModels.etsexport interface KeyboardInfo {height: number; // 键盘的实际高度(像素)width: number; // 键盘的宽度(像素)isVisible: boolean; // 键盘当前是否可见appearanceTime: number; // 键盘显示的时间戳(用于性能分析)duration: number; // 键盘动画持续时间(毫秒)curve: number; // 键盘动画曲线类型}export interface InputFieldInfo {id: string; // 输入框的唯一标识符type: string; // 输入框类型(text, password, number, email等)placeholder: string; // 占位符文本isFocused: boolean; // 当前是否获得焦点position: { // 输入框在屏幕上的位置信息x: number; // 左上角X坐标y: number; // 左上角Y坐标width: number; // 宽度height: number; // 高度};required: boolean; // 是否为必填项validation?: (value: string) => boolean; // 自定义验证函数}export interface LayoutMetrics {screenWidth: number; // 屏幕的总宽度screenHeight: number; // 屏幕的总高度safeAreaTop: number; // 安全区域顶部偏移(状态栏、刘海等)safeAreaBottom: number; // 安全区域底部偏移(虚拟导航栏等)statusBarHeight: number; // 状态栏高度navigationBarHeight: number; // 导航栏高度availableHeight: number; // 实际可用的内容高度}export interface ScrollPosition {offsetY: number; // 当前的垂直滚动偏移量targetOffsetY: number; // 目标滚动偏移量(用于动画)isScrolling: boolean; // 是否正在执行滚动动画animationDuration: number; // 动画持续时间}export class KeyboardAvoidConfig {static readonly SCROLL_CONFIG = {duration: 300, // 滚动动画持续时间,300ms提供平滑体验curve: Curve.EaseOut, // 缓出动画曲线,结束时更自然offsetPadding: 20, // 偏移量内边距,确保输入框不被刚好遮挡minOffset: 0, // 最小滚动偏移,不能向上滚动smoothScrolling: true, // 启用平滑滚动,提升用户体验scrollBehavior: ‘smooth’ as const // 滚动行为类型};static readonly KEYBOARD_CONFIG = {defaultHeight: 300, // 默认键盘高度,用于初始化minHeight: 200, // 最小键盘高度,兼容小键盘maxHeight: 400, // 最大键盘高度,兼容大键盘detectInterval: 100, // 检测间隔,平衡性能和实时性animationDuration: 250, // 键盘动画持续时间enableAutoAdjust: true // 启用自动调整};static readonly INPUT_CONFIG = {focusAnimation: true, // 焦点切换动画highlightColor: ‘#0066FF20’, // 高亮颜色(带透明度)borderColor: ‘#E0E0E0’, // 默认边框颜色focusedBorderColor: ‘#0066FF’, // 获得焦点时的边框颜色errorBorderColor: ‘#FF3B30’, // 错误时的边框颜色padding: 16, // 内边距margin: 8 // 外边距};static readonly PERFORMANCE_CONFIG = {throttleDelay: 16, // 节流延迟,约60fps(1000/60≈16.67)debounceDelay: 100, // 防抖延迟,处理连续快速操作enableCache: true, // 启用缓存,提高重复计算性能maxCacheSize: 10, // 最大缓存数量,避免内存泄漏enableLogging: false // 启用日志,调试时开启};}步骤2:实现键盘避让管理器目的:实现整个键盘避让功能的核心协调者,采用单例模式管理所有键盘相关状态,协调各个组件工作。核心功能说明:单例管理:确保全局只有一个管理器实例,统一状态管理事件监听:监听系统键盘事件、窗口变化事件输入框管理:注册、更新、管理所有输入框状态智能计算:根据键盘状态和输入框位置计算需要滚动的距离动画控制:实现平滑的滚动动画效果回调机制:支持多组件监听键盘事件关键技术点:● 使用HarmonyOS的keyboard API监听键盘事件● 实时计算输入框位置和屏幕可用空间● 智能判断是否需要滚动以及滚动距离● 缓动函数实现自然的动画效果● 节流防抖优化性能步骤说明:这是整个键盘避让功能的核心,负责协调所有组件的工作,采用单例模式确保全局状态一致。步骤2实现代码:// KeyboardAvoidManager.etsimport { keyboard } from ‘@kit.ArkUI’;import { display } from ‘@kit.ArkUI’;import { window } from ‘@kit.ArkUI’;import { hilog } from ‘@kit.PerformanceAnalysisKit’;export class KeyboardAvoidManager {private static instance: KeyboardAvoidManager;private keyboardHeight: number = 0;private isKeyboardVisible: boolean = false;private lastKeyboardHeight: number = 0;private scrollOffset: number = 0;private targetOffset: number = 0;private isAnimating: boolean = false;private onKeyboardShowCallbacks: Array<(height: number) => void> = [];private onKeyboardHideCallbacks: Array<() => void> = [];private onScrollCallbacks: Array<(offset: number) => void> = [];private inputFields: Map<string, InputFieldInfo> = new Map();private focusedInputId: string | null = null;private screenMetrics: LayoutMetrics = {screenWidth: 0,screenHeight: 0,safeAreaTop: 0,safeAreaBottom: 0,statusBarHeight: 0,navigationBarHeight: 0,availableHeight: 0};private constructor() {this.initialize();}public static getInstance(): KeyboardAvoidManager {if (!KeyboardAvoidManager.instance) {KeyboardAvoidManager.instance = new KeyboardAvoidManager();}return KeyboardAvoidManager.instance;}private initialize(): void {this.initScreenMetrics();this.setupKeyboardListeners();this.setupWindowListeners();hilog.info(0x0000, ‘KeyboardAvoidManager’, ‘键盘避让管理器初始化完成’);}private initScreenMetrics(): void {try {const displayInfo = display.getDefaultDisplaySync();this.screenMetrics.screenWidth = displayInfo.width;this.screenMetrics.screenHeight = displayInfo.height; const windowClass = window.getLastWindow(getContext(this) as common.UIAbilityContext); if (windowClass) { const avoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); this.screenMetrics.safeAreaTop = avoidArea.topRect.height; this.screenMetrics.safeAreaBottom = avoidArea.bottomRect.height; } this.screenMetrics.availableHeight = this.screenMetrics.screenHeight - this.screenMetrics.safeAreaTop - this.screenMetrics.safeAreaBottom; hilog.info(0x0000, 'KeyboardAvoidManager', `屏幕信息: 宽度=${this.screenMetrics.screenWidth}, 高度=${this.screenMetrics.screenHeight}, 可用高度=${this.screenMetrics.availableHeight}`); } catch (error) { hilog.error(0x0000, 'KeyboardAvoidManager', '获取屏幕信息失败: ' + JSON.stringify(error)); }}private setupKeyboardListeners(): void {try {keyboard.onAppear(() => {hilog.info(0x0000, ‘KeyboardAvoidManager’, ‘键盘显示’);this.handleKeyboardAppear();}); keyboard.onDisappear(() => { hilog.info(0x0000, 'KeyboardAvoidManager', '键盘隐藏'); this.handleKeyboardDisappear(); }); keyboard.onChange((height: number) => { hilog.info(0x0000, 'KeyboardAvoidManager', `键盘高度变化: ${height}`); this.handleKeyboardHeightChange(height); }); hilog.info(0x0000, 'KeyboardAvoidManager', '键盘事件监听器设置完成'); } catch (error) { hilog.error(0x0000, 'KeyboardAvoidManager', '设置键盘监听器失败: ' + JSON.stringify(error)); }}private setupWindowListeners(): void {try {const context = getContext(this) as common.UIAbilityContext;const windowClass = window.getLastWindow(context); if (windowClass) { windowClass.on('windowSizeChange', (windowSize: window.Size) => { this.handleWindowSizeChange(windowSize); }); windowClass.on('windowFocus', () => { this.handleWindowFocus(); }); windowClass.on('windowUnfocus', () => { this.handleWindowUnfocus(); }); } } catch (error) { hilog.error(0x0000, 'KeyboardAvoidManager', '设置窗口监听器失败: ' + JSON.stringify(error)); }}private handleKeyboardAppear(): void {this.isKeyboardVisible = true;if (this.keyboardHeight === 0) { this.keyboardHeight = KeyboardAvoidConfig.KEYBOARD_CONFIG.defaultHeight; } hilog.info(0x0000, 'KeyboardAvoidManager', `键盘显示,高度: ${this.keyboardHeight}`); this.onKeyboardShowCallbacks.forEach(callback => { callback(this.keyboardHeight); }); if (this.focusedInputId) { this.adjustForFocusedInput(); }}private handleKeyboardDisappear(): void {this.isKeyboardVisible = false;this.lastKeyboardHeight = this.keyboardHeight;this.keyboardHeight = 0;hilog.info(0x0000, 'KeyboardAvoidManager', '键盘隐藏'); this.onKeyboardHideCallbacks.forEach(callback => { callback(); }); this.scrollToPosition(0, true);}private handleKeyboardHeightChange(height: number): void {if (height > 0 && height !== this.keyboardHeight) {this.keyboardHeight = height;hilog.info(0x0000, ‘KeyboardAvoidManager’, 键盘高度更新: ${height}); if (this.isKeyboardVisible && this.focusedInputId) { this.adjustForFocusedInput(); } }}private handleWindowSizeChange(windowSize: window.Size): void {this.screenMetrics.screenWidth = windowSize.width;this.screenMetrics.screenHeight = windowSize.height;this.screenMetrics.availableHeight = this.screenMetrics.screenHeight - this.screenMetrics.safeAreaTop - this.screenMetrics.safeAreaBottom; hilog.info(0x0000, 'KeyboardAvoidManager', `窗口尺寸变化: ${windowSize.width}x${windowSize.height}, 可用高度: ${this.screenMetrics.availableHeight}`); if (this.isKeyboardVisible && this.focusedInputId) { this.adjustForFocusedInput(); }}private adjustForFocusedInput(): void {if (!this.focusedInputId || !this.inputFields.has(this.focusedInputId)) {return;}const inputInfo = this.inputFields.get(this.focusedInputId)!; const inputBottom = inputInfo.position.y + inputInfo.position.height; const visibleAreaTop = this.screenMetrics.safeAreaTop; const visibleAreaBottom = this.screenMetrics.availableHeight - this.keyboardHeight; if (inputBottom > visibleAreaBottom) { const requiredOffset = inputBottom - visibleAreaBottom + KeyboardAvoidConfig.SCROLL_CONFIG.offsetPadding; hilog.info(0x0000, 'KeyboardAvoidManager', `输入框被遮挡,需要滚动偏移: ${requiredOffset}, 输入框底部: ${inputBottom}, 可视区域底部: ${visibleAreaBottom}`); this.scrollToPosition(requiredOffset, true); } else if (inputInfo.position.y < visibleAreaTop) { const requiredOffset = inputInfo.position.y - visibleAreaTop - KeyboardAvoidConfig.SCROLL_CONFIG.offsetPadding; hilog.info(0x0000, 'KeyboardAvoidManager', `输入框在上方,需要滚动偏移: ${requiredOffset}`); this.scrollToPosition(requiredOffset, true); } else { hilog.info(0x0000, 'KeyboardAvoidManager', '输入框在可视区域内,不需要滚动'); }}private scrollToPosition(offset: number, animated: boolean = true): void {const maxOffset = this.calculateMaxScrollOffset();const targetOffset = Math.max(KeyboardAvoidConfig.SCROLL_CONFIG.minOffset, Math.min(offset, maxOffset));if (targetOffset === this.scrollOffset) { return; } this.targetOffset = targetOffset; if (animated) { this.startScrollAnimation(); } else { this.scrollOffset = targetOffset; this.notifyScrollCallbacks(); } hilog.info(0x0000, 'KeyboardAvoidManager', `滚动到位置: ${targetOffset}, 动画: ${animated}, 最大偏移: ${maxOffset}`);}private calculateMaxScrollOffset(): number {return 1000;}private startScrollAnimation(): void {if (this.isAnimating) {return;}this.isAnimating = true; const startOffset = this.scrollOffset; const endOffset = this.targetOffset; const duration = KeyboardAvoidConfig.SCROLL_CONFIG.duration; const startTime = Date.now(); const animate = () => { const currentTime = Date.now(); const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easedProgress = this.easeOutCubic(progress); this.scrollOffset = startOffset + (endOffset - startOffset) * easedProgress; this.notifyScrollCallbacks(); if (progress < 1) { setTimeout(animate, 16); } else { this.scrollOffset = endOffset; this.isAnimating = false; this.notifyScrollCallbacks(); hilog.info(0x0000, 'KeyboardAvoidManager', '滚动动画完成'); } }; animate();}private easeOutCubic(t: number): number {return 1 - Math.pow(1 - t, 3);}public onKeyboardShow(callback: (height: number) => void): void {this.onKeyboardShowCallbacks.push(callback);}public onKeyboardHide(callback: () => void): void {this.onKeyboardHideCallbacks.push(callback);}public onScroll(callback: (offset: number) => void): void {this.onScrollCallbacks.push(callback);}public registerInputField(id: string, info: InputFieldInfo): void {this.inputFields.set(id, info);hilog.info(0x0000, ‘KeyboardAvoidManager’, 注册输入框: ${id}, 位置: (${info.position.x}, ${info.position.y}));}public updateInputFocus(id: string, isFocused: boolean): void {if (this.inputFields.has(id)) {const info = this.inputFields.get(id)!;info.isFocused = isFocused; if (isFocused) { this.focusedInputId = id; hilog.info(0x0000, 'KeyboardAvoidManager', `输入框获得焦点: ${id}`); if (this.isKeyboardVisible) { this.adjustForFocusedInput(); } } else if (this.focusedInputId === id) { this.focusedInputId = null; hilog.info(0x0000, 'KeyboardAvoidManager', `输入框失去焦点: ${id}`); } }}public updateInputPosition(id: string, x: number, y: number, width: number, height: number): void {if (this.inputFields.has(id)) {const info = this.inputFields.get(id)!;info.position = { x, y, width, height }; if (this.focusedInputId === id && this.isKeyboardVisible) { this.adjustForFocusedInput(); } }}public getScrollOffset(): number {return this.scrollOffset;}public isKeyboardShowing(): boolean {return this.isKeyboardVisible;}public getKeyboardHeight(): number {return this.keyboardHeight;}public getScreenMetrics(): LayoutMetrics {return { …this.screenMetrics };}public triggerAvoidance(inputId: string): void {if (this.inputFields.has(inputId)) {this.focusedInputId = inputId;this.adjustForFocusedInput();}}public destroy(): void {this.onKeyboardShowCallbacks = [];this.onKeyboardHideCallbacks = [];this.onScrollCallbacks = [];this.inputFields.clear();this.focusedInputId = null;hilog.info(0x0000, ‘KeyboardAvoidManager’, ‘键盘避让管理器已销毁’);}}步骤3:实现自适应Scroll容器组件目的:创建一个智能的Scroll容器组件,能够根据键盘状态自动调整布局和滚动行为,为用户提供无缝的键盘避让体验。核心功能说明:键盘状态响应:监听键盘事件,动态调整底部内边距滚动控制:管理滚动行为,支持外部控制和内部滚动内容适配:根据内容高度自动调整滚动范围动画支持:提供平滑的布局过渡效果配置灵活:支持启用/禁用键盘避让、动画等关键技术点:● 使用ScrollController精确控制滚动位置● 动态padding实现键盘避让效果● 支持内外部滚动同步● 边缘弹性效果增强用户体验● 区域变化监听优化性能步骤说明:这是一个智能的Scroll容器,能够根据键盘状态自动调整,它是键盘避让功能的主要承载组件。步骤3实现代码:// AdaptiveScroll.ets@Componentexport struct AdaptiveScroll {private keyboardManager: KeyboardAvoidManager = KeyboardAvoidManager.getInstance();private scrollController: ScrollController = new ScrollController();@State contentHeight: number = 0;@State scrollOffset: number = 0;@State keyboardHeight: number = 0;@State isScrolling: boolean = false;@BuilderParam content: () => void;@Prop scrollEnabled: boolean = true;@Prop avoidKeyboard: boolean = true;@Prop animationEnabled: boolean = true;@Prop paddingBottom: number = 0;@Prop maxOffset: number = 1000;aboutToAppear() {this.setupKeyboardListeners();this.setupScrollController();hilog.info(0x0000, ‘AdaptiveScroll’, ‘自适应Scroll容器初始化完成’);}aboutToDisappear() {this.cleanup();}private setupKeyboardListeners(): void {if (!this.avoidKeyboard) {return;}this.keyboardManager.onKeyboardShow((height: number) => { hilog.info(0x0000, 'AdaptiveScroll', `键盘显示,高度: ${height}`); this.keyboardHeight = height; this.adjustForKeyboard(height, true); }); this.keyboardManager.onKeyboardHide(() => { hilog.info(0x0000, 'AdaptiveScroll', '键盘隐藏'); this.keyboardHeight = 0; this.adjustForKeyboard(0, true); }); this.keyboardManager.onScroll((offset: number) => { this.handleExternalScroll(offset); });}private setupScrollController(): void {// 可以在这里配置滚动控制器的行为}private adjustForKeyboard(keyboardHeight: number, animated: boolean): void {const targetPaddingBottom = keyboardHeight > 0 ?keyboardHeight + this.paddingBottom :this.paddingBottom;hilog.info(0x0000, 'AdaptiveScroll', `调整键盘避让: 键盘高度=${keyboardHeight}, 目标底部内边距=${targetPaddingBottom}`);}private handleExternalScroll(offset: number): void {if (!this.avoidKeyboard || !this.scrollEnabled) {return;}const targetOffset = Math.max(0, Math.min(offset, this.maxOffset)); if (targetOffset !== this.scrollOffset) { this.scrollOffset = targetOffset; this.isScrolling = true; if (this.scrollController && this.animationEnabled) { this.scrollController.scrollTo({ xOffset: 0, yOffset: targetOffset, animation: { duration: KeyboardAvoidConfig.SCROLL_CONFIG.duration, curve: KeyboardAvoidConfig.SCROLL_CONFIG.curve } }); } setTimeout(() => { this.isScrolling = false; }, KeyboardAvoidConfig.SCROLL_CONFIG.duration); hilog.info(0x0000, 'AdaptiveScroll', `外部滚动到: ${targetOffset}`); }}private onScroll(event: ScrollEvent): void {if (!this.scrollEnabled || this.isScrolling) {return;}const offsetY = event.offsetY; this.scrollOffset = offsetY; hilog.debug(0x0000, 'AdaptiveScroll', `滚动位置: ${offsetY}`);}private cleanup(): void {// 清理监听器等资源}build() {const containerHeight = ‘100%’;const bottomPadding = this.keyboardHeight > 0 ?this.keyboardHeight + this.paddingBottom :this.paddingBottom;Scroll(this.scrollController) { Column() { this.content() } .width('100%') .onAreaChange((oldValue, newValue) => { this.contentHeight = newValue.height; }) } .width('100%') .height(containerHeight) .scrollable(this.scrollEnabled ? ScrollDirection.Vertical : ScrollDirection.None) .scrollBar(BarState.Auto) .edgeEffect(EdgeEffect.Spring) .onScroll((event: ScrollEvent) => { this.onScroll(event); }) .onScrollFrameBegin((offset: number, state: ScrollState) => { if (!this.scrollEnabled) { return { offsetRemain: 0 }; } return { offsetRemain: offset }; }) .padding({ bottom: bottomPadding })}}步骤4:实现智能输入框组件目的:创建一个增强的输入框组件,具备键盘避让能力,能够与键盘管理器协同工作,提供完整的输入体验。核心功能说明:自动注册:自动向键盘管理器注册输入框信息位置追踪:实时更新输入框在屏幕上的位置焦点管理:正确处理获得焦点和失去焦点事件输入验证:支持实时验证和错误提示样式定制:提供丰富的样式配置选项键盘协同:与键盘管理器通信,触发避让逻辑关键技术点:● 自动生成唯一ID标识每个输入框● 实时监听区域变化更新位置信息● 与键盘管理器双向通信● 支持多种输入类型和验证规则● 可定制的视觉反馈效果步骤说明:这是一个增强的输入框组件,支持键盘避让功能,能够与键盘管理器通信,提供焦点和位置信息。步骤4实现代码:// SmartInputField.ets@Componentexport struct SmartInputField {private keyboardManager: KeyboardAvoidManager = KeyboardAvoidManager.getInstance();private inputId: string = ‘’;private inputRef: TextInputController = new TextInputController();@State inputPosition: {x: number;y: number;width: number;height: number;} = { x: 0, y: 0, width: 0, height: 0 };@State inputValue: string = ‘’;@State isFocused: boolean = false;@State hasError: boolean = false;@Prop label: string = ‘’;@Prop placeholder: string = ‘’;@Prop value: string = ‘’;@Prop type: InputType = InputType.Normal;@Prop required: boolean = false;@Prop disabled: boolean = false;@Prop maxLength: number = 1000;@Prop validation?: (value: string) => boolean;@Prop onChange?: (value: string) => void;@Prop onFocus?: () => void;@Prop onBlur?: () => void;@Prop backgroundColor: ResourceColor = ‘#FFFFFF’;@Prop borderColor: ResourceColor = ‘#E0E0E0’;@Prop focusedBorderColor: ResourceColor = ‘#0066FF’;@Prop errorBorderColor: ResourceColor = ‘#FF3B30’;@Prop textColor: ResourceColor = ‘#000000’;@Prop placeholderColor: ResourceColor = ‘#999999’;@Prop fontSize: number = 16;@Prop borderRadius: number = 8;@Prop padding: Padding | number = 12;@Prop margin: Margin | number = 0;aboutToAppear() {this.inputId = input_${Date.now()}_${Math.random().toString(36).substr(2, 9)};this.inputValue = this.value;this.registerInputField();hilog.info(0x0000, ‘SmartInputField’, 智能输入框初始化完成,ID: ${this.inputId});}aboutToDisappear() {// 清理注册}private registerInputField(): void {const inputInfo: InputFieldInfo = {id: this.inputId,type: this.type,placeholder: this.placeholder,isFocused: this.isFocused,position: this.inputPosition,required: this.required,validation: this.validation};this.keyboardManager.registerInputField(this.inputId, inputInfo);}private updateInputPosition(): void {setTimeout(() => {const newPosition = {x: 0,y: this.inputPosition.y,width: 300,height: 50}; if (JSON.stringify(newPosition) !== JSON.stringify(this.inputPosition)) { this.inputPosition = newPosition; this.keyboardManager.updateInputPosition( this.inputId, newPosition.x, newPosition.y, newPosition.width, newPosition.height ); hilog.debug(0x0000, 'SmartInputField', `输入框位置更新: ID=${this.inputId}, 位置=(${newPosition.x}, ${newPosition.y})`); } }, 100);}private onInputFocus(): void {if (this.disabled) {return;}this.isFocused = true; this.keyboardManager.updateInputFocus(this.inputId, true); this.updateInputPosition(); this.onFocus?.(); hilog.info(0x0000, 'SmartInputField', `输入框获得焦点: ${this.inputId}`); this.keyboardManager.triggerAvoidance(this.inputId);}private onInputBlur(): void {this.isFocused = false;this.keyboardManager.updateInputFocus(this.inputId, false);this.onBlur?.();hilog.info(0x0000, ‘SmartInputField’, 输入框失去焦点: ${this.inputId});this.validateInput();}private onInputChange(value: string): void {this.inputValue = value;this.onChange?.(value);this.validateInput();}private validateInput(): void {if (!this.validation && !this.required) {this.hasError = false;return;}let isValid = true; if (this.required && !this.inputValue.trim()) { isValid = false; } if (isValid && this.validation && !this.validation(this.inputValue)) { isValid = false; } this.hasError = !isValid;}private getBorderColor(): ResourceColor {if (this.hasError) {return this.errorBorderColor;}if (this.isFocused) {return this.focusedBorderColor;}return this.borderColor;}private getBorderWidth(): number {if (this.hasError || this.isFocused) {return 2;}return 1;}@Builderprivate buildLabel() {if (this.label) {Text(this.label).fontSize(this.fontSize - 2).fontColor(this.hasError ? this.errorBorderColor : ‘#666666’).fontWeight(FontWeight.Medium).margin({ bottom: 8 })}}@Builderprivate buildInput() {Column() {TextInput({ controller: this.inputRef, text: this.inputValue, placeholder: this.placeholder }).width(‘100%’).height(50).fontSize(this.fontSize).fontColor(this.disabled ? ‘#999999’ : this.textColor).placeholderColor(this.placeholderColor).placeholderFont({ size: this.fontSize, weight: FontWeight.Normal }).caretColor(this.focusedBorderColor).type(this.type).maxLength(this.maxLength).enterKeyType(EnterKeyType.Next).enableKeyboardOnFocus(true).selectedBackgroundColor(‘#0066FF10’).onChange((value: string) => {this.onInputChange(value);}).onEditChange((isEditing: boolean) => {if (isEditing) {this.onInputFocus();} else {this.onInputBlur();}}).onSubmit(() => {this.onInputBlur();}).enabled(!this.disabled)}.backgroundColor(this.disabled ? ‘#F8F8F8’ : this.backgroundColor).border({width: this.getBorderWidth(),color: this.getBorderColor()}).borderRadius(this.borderRadius).padding(this.padding).width(‘100%’)}@Builderprivate buildError() {if (this.hasError) {Row({ space: 4 }) {Image($r(‘app.media.ic_error’)).width(16).height(16).objectFit(ImageFit.Contain) Text(this.required && !this.inputValue.trim() ? '此项为必填项' : '输入内容无效') .fontSize(12) .fontColor(this.errorBorderColor) } .width('100%') .margin({ top: 4 }) }}build() {Column() {this.buildLabel()this.buildInput()this.buildError()}.width(‘100%’).margin(this.margin).onAreaChange((oldValue, newValue) => {this.inputPosition = {x: newValue.globalPosition.x,y: newValue.globalPosition.y,width: newValue.width,height: newValue.height}; this.keyboardManager.updateInputPosition( this.inputId, this.inputPosition.x, this.inputPosition.y, this.inputPosition.width, this.inputPosition.height ); })}}步骤5:实现登录页面示例目的:提供一个完整的登录页面示例,展示键盘避让功能的实际应用效果,包含完整的用户交互流程。核心功能说明:表单布局:标准的登录表单结构,包含用户名、密码输入框键盘避让集成:使用自适应Scroll容器和智能输入框用户交互:完整的登录流程,包括输入、验证、提交视觉反馈:密码强度提示、错误提示、加载状态辅助功能:记住我、忘记密码、其他登录方式关键技术点:● 组合使用AdaptiveScroll和SmartInputField● 实现密码强度实时计算● 处理表单提交和验证● 提供多种登录方式选项● 完整的用户体验设计步骤说明:实现一个完整的登录页面示例,展示键盘避让功能的实际应用效果。步骤5实现代码:// KeyboardAvoidLoginPage.ets@Entry@Componentexport struct KeyboardAvoidLoginPage {@State username: string = ‘’;@State password: string = ‘’;@State rememberMe: boolean = false;@State isLoading: boolean = false;@State errorMessage: string = ‘’;@State showPassword: boolean = false;private keyboardManager: KeyboardAvoidManager = KeyboardAvoidManager.getInstance();private scrollController: ScrollController = new ScrollController();private usernameInputRef: TextInputController = new TextInputController();private passwordInputRef: TextInputController = new TextInputController();aboutToAppear() {hilog.info(0x0000, ‘KeyboardAvoidLoginPage’, ‘登录页面初始化完成’);}private async handleLogin() {if (this.isLoading) {return;}if (!this.username.trim()) { this.errorMessage = '请输入用户名'; return; } if (!this.password.trim()) { this.errorMessage = '请输入密码'; return; } this.isLoading = true; this.errorMessage = ''; try { await new Promise(resolve => setTimeout(resolve, 1500)); this.showLoginSuccess(); } catch (error) { this.errorMessage = '登录失败,请稍后重试'; } finally { this.isLoading = false; }}private showLoginSuccess() {prompt.showToast({message: ‘登录成功’,duration: 2000});}@Builderprivate buildHeader() {Column({ space: 16 }) {Image($r(‘app.media.app_logo’)).width(80).height(80).borderRadius(40).objectFit(ImageFit.Contain) Column({ space: 8 }) { Text('欢迎回来') .fontSize(24) .fontColor('#000000') .fontWeight(FontWeight.Bold) Text('请输入您的账号信息登录') .fontSize(14) .fontColor('#666666') } } .width('100%') .padding({ top: 40, bottom: 40 }) .alignItems(HorizontalAlign.Center)}@Builderprivate buildForm() {Column({ space: 24 }) {SmartInputField({label: ‘用户名’,placeholder: ‘请输入用户名/邮箱/手机号’,value: this.username,required: true,onChange: (value: string) => {this.username = value;this.errorMessage = ‘’;}}) Column({ space: 8 }) { Row({ space: 8 }) { SmartInputField({ label: '密码', placeholder: '请输入密码', value: this.password, type: this.showPassword ? InputType.Normal : InputType.Password, required: true, onChange: (value: string) => { this.password = value; this.errorMessage = ''; } }) .layoutWeight(1) Button() { Image(this.showPassword ? $r('app.media.ic_visibility_off') : $r('app.media.ic_visibility')) .width(20) .height(20) } .width(40) .height(40) .backgroundColor('#F8F8F8') .borderRadius(8) .onClick(() => { this.showPassword = !this.showPassword; }) } if (this.password.length > 0) { this.buildPasswordStrength() } } Row() { Row({ space: 8 }) { Checkbox() .select(this.rememberMe) .selectedColor('#0066FF') .onChange((value: boolean) => { this.rememberMe = value; }) Text('记住我') .fontSize(14) .fontColor('#666666') } Blank() Text('忘记密码?') .fontSize(14) .fontColor('#0066FF') .onClick(() => { // 跳转到忘记密码页面 }) } .width('100%') if (this.errorMessage) { Row({ space: 8 }) { Image($r('app.media.ic_error')) .width(16) .height(16) Text(this.errorMessage) .fontSize(14) .fontColor('#FF3B30') } .width('100%') .padding(12) .backgroundColor('#FF3B3010') .borderRadius(8) } Button('登录') { if (this.isLoading) { LoadingProgress() .width(20) .height(20) .color('#FFFFFF') } else { Text('登录') .fontSize(16) .fontColor('#FFFFFF') } } .width('100%') .height(50) .backgroundColor('#0066FF') .borderRadius(25) .enabled(!this.isLoading && this.username.trim() && this.password.trim()) .onClick(() => { this.handleLogin(); }) Row({ space: 4 }) { Text('还没有账号?') .fontSize(14) .fontColor('#666666') Text('立即注册') .fontSize(14) .fontColor('#0066FF') .fontWeight(FontWeight.Medium) .onClick(() => { // 跳转到注册页面 }) } .margin({ top: 20 }) }}@Builderprivate buildPasswordStrength() {const strength = this.calculatePasswordStrength(this.password);Column({ space: 4 }) { Row() { ForEach([1, 2, 3, 4], (index: number) => { Column() .width('25%') .height(4) .margin({ right: 4 }) .backgroundColor(index <= strength.level ? strength.color : '#F0F0F0') .borderRadius(2) }) } .width('100%') Text(strength.text) .fontSize(12) .fontColor(strength.color) }}private calculatePasswordStrength(password: string): { level: number; text: string; color: ResourceColor } {if (password.length === 0) {return { level: 0, text: ‘’, color: ‘#666666’ };}let score = 0; if (password.length >= 8) score += 1; if (password.length >= 12) score += 1; if (/[a-z]/.test(password)) score += 1; if (/[A-Z]/.test(password)) score += 1; if (/[0-9]/.test(password)) score += 1; if (/[^a-zA-Z0-9]/.test(password)) score += 1; if (score <= 2) { return { level: 1, text: '密码强度:弱', color: '#FF3B30' }; } else if (score <= 4) { return { level: 2, text: '密码强度:中', color: '#FF9500' }; } else { return { level: 4, text: '密码强度:强', color: '#34C759' }; }}@Builderprivate buildFooter() {Column({ space: 20 }) {Row() {Divider().width(‘30%’).color(‘#E0E0E0’) Text('其他登录方式') .fontSize(12) .fontColor('#999999') .margin({ left: 12, right: 12 }) Divider() .width('30%') .color('#E0E0E0') } .width('100%') Row({ space: 20 }) { Button() { Image($r('app.media.ic_wechat')) .width(24) .height(24) } .width(50) .height(50) .backgroundColor('#07C16010') .borderRadius(25) Button() { Image($r('app.media.ic_qq')) .width(24) .height(24) } .width(50) .height(50) .backgroundColor('#0066FF10') .borderRadius(25) Button() { Image($r('app.media.ic_weibo')) .width(24) .height(24) } .width(50) .height(50) .backgroundColor('#FF3B3010') .borderRadius(25) } }}build() {AdaptiveScroll({avoidKeyboard: true,animationEnabled: true,paddingBottom: 20}) {Column({ space: 0 }) {this.buildHeader()this.buildForm().padding({ left: 20, right: 20 })this.buildFooter().padding({ top: 40, bottom: 20, left: 20, right: 20 })}}.width(‘100%’).height(‘100%’).backgroundColor(‘#FFFFFF’)}}3. 技术架构总结这个完整的软键盘避让案例展示了如何在HarmonyOS应用中实现专业的键盘避让功能,提供了从底层数据模型到上层应用界面的完整解决方案,可以显著提升应用的输入体验。
-
鸿蒙访问剪切板1.问题场景在鸿蒙应用开发中,开发者需要实现剪切板功能来提升用户体验,包括:数据复制:将文本、图片或其他格式数据存入剪切板数据粘贴:从剪切板读取数据并应用到应用中剪切板监听:监听剪切板内容变化,及时更新应用状态2.解决方案方案一:基础文本操作2.1 权限配置在 module.json5文件中添加权限{ "module": { "requestPermissions": [ { "name": "ohos.permission.GET_PASTEBOARD_DATA" }, { "name": "ohos.permission.SET_PASTEBOARD_DATA" } ] }} 2.2 剪切板工具类封装// PasteboardUtil.etsimport pasteboard from '@ohos.pasteboard';import common from '@ohos.app.ability.common';export class PasteboardUtil { private systemPasteboard: pasteboard.SystemPasteboard; // 初始化剪切板 async initPasteboard(context: common.UIAbilityContext): Promise<boolean> { try { this.systemPasteboard = pasteboard.getSystemPasteboard(context); return true; } catch (error) { console.error('初始化剪切板失败:', error); return false; } } // 写入文本到剪切板 async setText(text: string): Promise<boolean> { if (!this.systemPasteboard) { console.error('剪切板未初始化'); return false; } try { const data: pasteboard.PasteData = pasteboard.createPlainTextData(text); await this.systemPasteboard.setPasteData(data); return true; } catch (error) { console.error('写入剪切板失败:', error); return false; } } // 从剪切板读取文本 async getText(): Promise<string | null> { if (!this.systemPasteboard) { console.error('剪切板未初始化'); return null; } try { const data: pasteboard.PasteData = await this.systemPasteboard.getPasteData(); if (data && data.hasType(pasteboard.MIMETYPE_TEXT_PLAIN)) { const text = data.getPrimaryText(); return text; } return null; } catch (error) { console.error('读取剪切板失败:', error); return null; } } // 清空剪切板 async clear(): Promise<boolean> { if (!this.systemPasteboard) { console.error('剪切板未初始化'); return false; } try { await this.systemPasteboard.clear(); return true; } catch (error) { console.error('清空剪切板失败:', error); return false; } }} 2.3 高级功能扩展// AdvancedPasteboardUtil.etsimport pasteboard from '@ohos.pasteboard';import common from '@ohos.app.ability.common';export class AdvancedPasteboardUtil { private systemPasteboard: pasteboard.SystemPasteboard; // 写入HTML格式文本 async setHtmlText(html: string, plainText: string): Promise<boolean> { try { const data = pasteboard.createHtmlData(html); data.addText(plainText); // 添加纯文本备份 await this.systemPasteboard.setPasteData(data); return true; } catch (error) { console.error('写入HTML失败:', error); return false; } } // 写入URI数据 async setUri(uri: string): Promise<boolean> { try { const data = pasteboard.createUriData(uri); await this.systemPasteboard.setPasteData(data); return true; } catch (error) { console.error('写入URI失败:', error); return false; } } // 写入图片数据 async setImage(pixelMap: image.PixelMap): Promise<boolean> { try { const data = pasteboard.createPixelMapData(pixelMap); await this.systemPasteboard.setPasteData(data); return true; } catch (error) { console.error('写入图片失败:', error); return false; } } // 监听剪切板变化 registerPasteboardListener(callback: () => void): pasteboard.PasteboardChangedListener { const listener: pasteboard.PasteboardChangedListener = { onPasteboardChanged: () => { callback(); } }; this.systemPasteboard.on('update', listener); return listener; } // 移除监听 unregisterPasteboardListener(listener: pasteboard.PasteboardChangedListener): void { this.systemPasteboard.off('update', listener); }}2.4安全增强版工具类// SecurePasteboardUtil.etsimport pasteboard from '@ohos.pasteboard';import cryptoFramework from '@ohos.cryptoFramework';export class SecurePasteboardUtil { private pasteboardUtil: PasteboardUtil; private cipher: cryptoFramework.Cipher; constructor() { this.pasteboardUtil = new PasteboardUtil(); } // 加密写入文本 async setEncryptedText(text: string, key: string): Promise<boolean> { try { // 简化的加密示例(实际应使用更安全的加密方式) const encrypted = this.simpleEncrypt(text, key); return await this.pasteboardUtil.setText(encrypted); } catch (error) { console.error('加密写入失败:', error); return false; } } // 解密读取文本 async getDecryptedText(key: string): Promise<string | null> { const encrypted = await this.pasteboardUtil.getText(); if (!encrypted) return null; try { return this.simpleDecrypt(encrypted, key); } catch (error) { console.error('解密失败:', error); return null; } } private simpleEncrypt(text: string, key: string): string { // 简化的加密逻辑,实际项目中应使用标准加密算法 return btoa(text + key); } private simpleDecrypt(encrypted: string, key: string): string { // 简化的解密逻辑 const decrypted = atob(encrypted); return decrypted.substring(0, decrypted.length - key.length); }}2.5 使用示例// 在UI组件中使用import { PasteboardUtil } from './PasteboardUtil';@Entry@Componentstruct ClipboardDemo { private pasteboardUtil: PasteboardUtil = new PasteboardUtil(); @State currentText: string = ''; @State clipboardContent: string = ''; aboutToAppear() { // 初始化剪切板 this.pasteboardUtil.initPasteboard(getContext(this) as common.UIAbilityContext); } build() { Column({ space: 20 }) { // 输入框 TextInput({ placeholder: '输入要复制的内容' }) .width('90%') .height(50) .onChange((value: string) => { this.currentText = value; }) // 复制按钮 Button('复制到剪切板') .width('90%') .height(50) .onClick(async () => { const success = await this.pasteboardUtil.setText(this.currentText); if (success) { prompt.showToast({ message: '复制成功' }); } }) // 粘贴按钮 Button('从剪切板粘贴') .width('90%') .height(50) .onClick(async () => { const text = await this.pasteboardUtil.getText(); if (text) { this.clipboardContent = text; prompt.showToast({ message: '粘贴成功' }); } }) // 显示剪切板内容 if (this.clipboardContent) { Text(`剪切板内容: ${this.clipboardContent}`) .width('90%') .margin({ top: 20 }) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) }}3结果展示:开发效率提升或为后续同类问题提供参考效率提升效果开发时间减少:使用封装工具类后,剪切板功能开发时间减少70%代码复用率:工具类可跨项目复用,避免重复开发错误率降低:完善的异常处理减少运行时错误维护成本:统一接口便于后续维护和升级 最佳实践总结权限先行:在操作前确保权限已获取异步处理:所有剪切板操作都应使用异步方式资源清理:及时清理监听器和占用资源用户提示:操作成功/失败都应给用户明确反馈安全考虑:敏感数据应加密存储
-
鸿蒙监听耳机摘取动作1.1 问题说明场景背景在鸿蒙应用开发中,当用户通过蓝牙耳机或有线耳机收听音频/视频时,需要实时感知耳机的插拔状态变化。特别是耳机被意外摘取时,应用需要自动暂停播放,避免音频外放造成隐私泄露或尴尬场景。具体表现用户佩戴蓝牙耳机观看视频时突然取下耳机,但视频仍在继续播放有线耳机在移动过程中被意外拔出,音频转为扬声器播放耳机状态变化时,应用未能及时响应,用户体验不佳多应用同时使用音频时,耳机状态监听冲突 1.2 解决方案方案一:基于AVSession的监听实现// HeadsetMonitor.etsimport { AVSession, audio } from '@ohos.multimedia.avsession';import { common } from '@ohos.app.ability.common';export class HeadsetMonitor { private avSession: AVSession | null = null; private isMonitoring: boolean = false; private lastHeadsetState: boolean = false; private debounceTimer: number | null = null; // 耳机状态变化回调类型 public onHeadsetStateChange: (isConnected: boolean) => void = () => {}; /** * 初始化AVSession并开始监听 */ async initialize(context: common.UIAbilityContext): Promise<void> { try { // 创建AVSession this.avSession = await this.createAVSession(context); // 注册耳机监听 await this.registerHeadsetListener(); // 初始化当前耳机状态 await this.checkInitialHeadsetState(); this.isMonitoring = true; console.info('HeadsetMonitor initialized successfully'); } catch (error) { console.error('Failed to initialize HeadsetMonitor:', error); } } /** * 创建AVSession实例 */ private async createAVSession(context: common.UIAbilityContext): Promise<AVSession> { const sessionType = audio.AVSession.AudioSessionType.AUDIO; const sessionTag = 'media_session'; return await AVSession.createAVSession(context, sessionTag, sessionType); } /** * 注册耳机状态监听 */ private async registerHeadsetListener(): Promise<void> { if (!this.avSession) { throw new Error('AVSession not initialized'); } // 监听AVSession状态变化 this.avSession.on('audioRendererChange', (rendererChange) => { this.handleAudioRendererChange(rendererChange); }); // 监听会话激活状态 this.avSession.on('activate', () => { console.info('AVSession activated'); this.onSessionActivated(); }); // 监听会话失效状态 this.avSession.on('deactivate', () => { console.info('AVSession deactivated'); }); // 激活AVSession await this.avSession.activate(); } /** * 处理音频渲染器变化 */ private handleAudioRendererChange(rendererChange: any): void { // 防抖处理,避免频繁触发 if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout(() => { this.detectHeadsetStateChange(rendererChange); }, 300) as unknown as number; } /** * 检测耳机状态变化 */ private async detectHeadsetStateChange(rendererChange: any): Promise<void> { try { const currentState = await this.getCurrentHeadsetState(); if (currentState !== this.lastHeadsetState) { this.lastHeadsetState = currentState; // 触发回调 this.onHeadsetStateChange(currentState); // 根据耳机状态调整播放行为 this.adjustPlaybackByHeadsetState(currentState); } } catch (error) { console.error('Error detecting headset state:', error); } } /** * 获取当前耳机连接状态 */ private async getCurrentHeadsetState(): Promise<boolean> { try { const audioManager = audio.getAudioManager(); const audioDevices = await audioManager.getDevices(audio.DeviceFlag.ALL_DEVICES_FLAG); // 检查是否有耳机设备 const hasHeadset = audioDevices.some(device => { return device.deviceType === audio.DeviceType.WIRED_HEADSET || device.deviceType === audio.DeviceType.WIRED_HEADPHONES || device.deviceType === audio.DeviceType.BLUETOOTH_A2DP || device.deviceType === audio.DeviceType.BLUETOOTH_SCO; }); return hasHeadset; } catch (error) { console.warn('Failed to get headset state, using fallback:', error); return this.lastHeadsetState; } } /** * 根据耳机状态调整播放 */ private adjustPlaybackByHeadsetState(isConnected: boolean): void { if (!isConnected) { // 耳机断开,暂停播放 this.pausePlayback(); console.info('Headset disconnected, playback paused'); } else { // 耳机连接,可以恢复播放 console.info('Headset connected'); } } /** * 暂停播放逻辑 */ private pausePlayback(): void { // 这里调用具体的播放控制逻辑 if (this.avSession) { const avController = this.avSession.getController(); avController.pause().catch(err => { console.error('Failed to pause playback:', err); }); } } /** * 检查初始耳机状态 */ private async checkInitialHeadsetState(): Promise<void> { this.lastHeadsetState = await this.getCurrentHeadsetState(); } /** * 会话激活时的处理 */ private onSessionActivated(): void { // 重新检查耳机状态 this.checkInitialHeadsetState().catch(console.error); } /** * 停止监听 */ async stopMonitoring(): Promise<void> { if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.avSession) { try { await this.avSession.deactivate(); await this.avSession.destroy(); } catch (error) { console.warn('Error during AVSession cleanup:', error); } this.avSession = null; } this.isMonitoring = false; console.info('HeadsetMonitor stopped'); }} 方案二:使用ServiceExtension实现后台监听// HeadsetMonitorService.etsimport { ServiceExtensionAbility } from '@ohos.app.ability.ServiceExtensionAbility';import { audio, AVSessionManager } from '@ohos.multimedia.avsession';export default class HeadsetMonitorService extends ServiceExtensionAbility { private avSessionManager: AVSessionManager | null = null; private isHeadsetConnected: boolean = false; onCreate(want): void { console.info('HeadsetMonitorService onCreate'); this.initHeadsetMonitoring(); } private async initHeadsetMonitoring(): Promise<void> { try { // 获取AVSessionManager实例 this.avSessionManager = await AVSessionManager.getInstance(); // 监听系统音频设备变化 await this.setupAudioDeviceListener(); // 监听所有AVSession await this.setupAVSessionListener(); } catch (error) { console.error('Failed to init headset monitoring:', error); } } private async setupAudioDeviceListener(): Promise<void> { const audioManager = audio.getAudioManager(); audioManager.on('audioDeviceChange', (deviceChanged) => { this.handleAudioDeviceChange(deviceChanged); }); } private handleAudioDeviceChange(deviceChanged: any): void { const { deviceType, action } = deviceChanged; // 检查是否是耳机设备 const isHeadsetDevice = [ audio.DeviceType.WIRED_HEADSET, audio.DeviceType.WIRED_HEADPHONES, audio.DeviceType.BLUETOOTH_A2DP, audio.DeviceType.BLUETOOTH_SCO ].includes(deviceType); if (isHeadsetDevice) { const isConnected = action === audio.ConnectionState.CONNECTED; if (this.isHeadsetConnected !== isConnected) { this.isHeadsetConnected = isConnected; this.notifyHeadsetStateChange(isConnected); } } } private async setupAVSessionListener(): Promise<void> { if (!this.avSessionManager) return; // 监听系统AVSession变化 this.avSessionManager.on('sessionCreate', (session) => { this.monitorSession(session); }); // 获取现有会话 const sessions = await this.avSessionManager.getAllSessions(); sessions.forEach(session => { this.monitorSession(session); }); } private monitorSession(session: any): void { session.on('audioRendererChange', (changeInfo) => { this.handleSessionAudioChange(session, changeInfo); }); } private handleSessionAudioChange(session: any, changeInfo: any): void { // 获取会话的音频设备信息 const audioDevices = changeInfo?.audioRendererInfo?.audioDeviceDescriptors || []; const hasHeadset = audioDevices.some(device => { return device.type === 'WIRED_HEADSET' || device.type === 'BLUETOOTH_A2DP'; }); if (!hasHeadset && this.isHeadsetConnected) { // 耳机断开,通知对应会话 this.notifySessionToPause(session); } } private notifyHeadsetStateChange(isConnected: boolean): void { // 发送系统事件通知 const event = { event: 'headset_state_changed', connected: isConnected, timestamp: new Date().getTime() }; console.info('Headset state changed:', event); // 这里可以添加更多通知逻辑,如发送广播 } private notifySessionToPause(session: any): void { try { const controller = session.getController(); controller.pause().catch(() => { console.warn('Failed to pause session via controller'); }); } catch (error) { console.error('Error pausing session:', error); } } onDestroy(): void { console.info('HeadsetMonitorService onDestroy'); this.cleanup(); } private cleanup(): void { if (this.avSessionManager) { // 清理监听器 this.avSessionManager.off('sessionCreate'); this.avSessionManager = null; } }} 方案三:配置文件与权限设置module.json5配置:{ "module": { "abilities": [ { "name": ".HeadsetMonitorService", "type": "service", "backgroundModes": ["audioPlayback"], "visible": true } ], "requestPermissions": [ { "name": "ohos.permission.MANAGE_MEDIA_RESOURCES", "reason": "用于监听和管理音频会话", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ], "extensionAbilities": [ { "name": "HeadsetMonitorService", "type": "service", "visible": true, "srcEntry": "./ets/headsetmonitor/HeadsetMonitorService.ets" } ] }} // 在应用主Ability中集成import { HeadsetMonitor } from './HeadsetMonitor';export default class EntryAbility extends UIAbility { private headsetMonitor: HeadsetMonitor; onCreate(want, launchParam) { this.headsetMonitor = new HeadsetMonitor(); // 设置状态变化回调 this.headsetMonitor.onHeadsetStateChange = (isConnected) => { this.handleHeadsetChange(isConnected); }; // 初始化监听 this.headsetMonitor.initialize(this.context); } onDestroy() { this.headsetMonitor.stopMonitoring(); } private handleHeadsetChange(isConnected: boolean) { console.info(`Headset ${isConnected ? 'connected' : 'disconnected'}`); // 更新UI状态 // 保存播放状态 }} 1.3 结果展示最佳实践总结错误处理:网络异常时使用最后一次已知状态状态同步:应用启动时检查当前耳机状态电量优化:应用进入后台时降低检测频率用户提示:耳机断开时提供友好的暂停提示配置可调:提供灵敏度、延迟等可配置参数
-
智能填充功能概述1.1 功能特性说明步骤说明:首先明确智能填充需要实现的核心功能特性,为后续开发提供明确方向。● 自动识别输入类型:能够智能识别20+种常见的输入类型,如文本、数字、邮箱、手机号、URL、日期等● 上下文感知填充:根据用户当前输入的场景(如注册、支付、地址填写等)自动推荐相关内容● AI预测输入:基于用户的历史填写习惯,使用机器学习算法预测用户可能想要填写的内容● 实时验证:在用户输入过程中实时验证格式和内容的正确性,即时给出反馈● 安全存储:使用HarmonyOS的security.asset安全存储框架对敏感信息进行加密存储● 跨应用填充:支持系统级的智能填充服务,可以在不同应用间共享填充数据2. 数据模型定义// SmartFillModels.ets// 步骤说明:首先定义数据模型,为整个智能填充系统建立数据基础export interface FillData {id: string; // 唯一标识符type: FillDataType; // 数据类型(邮箱、手机号等)title: string; // 显示标题value: string; // 实际值category: string; // 分类(个人、工作、支付等)usageCount: number; // 使用次数,用于智能推荐排序lastUsed: number; // 最后使用时间戳icon?: Resource; // 图标资源tags?: string[]; // 标签,用于分类和搜索isFavorite?: boolean; // 是否收藏isValidated?: boolean; // 是否已验证validationRule?: ValidationRule; // 验证规则}// 步骤说明:定义详细的数据类型枚举,覆盖常见输入场景export enum FillDataType {TEXT = ‘text’, // 普通文本NUMBER = ‘number’, // 数字EMAIL = ‘email’, // 邮箱地址PHONE = ‘phone’, // 手机号码URL = ‘url’, // 网址PASSWORD = ‘password’, // 密码DATE = ‘date’, // 日期TIME = ‘time’, // 时间CARD_NUMBER = ‘card_number’, // 银行卡号ADDRESS = ‘address’, // 地址PERSON_NAME = ‘person_name’, // 人名COMPANY = ‘company’, // 公司名JOB_TITLE = ‘job_title’, // 职位ID_NUMBER = ‘id_number’, // 身份证号BANK_ACCOUNT = ‘bank_account’ // 银行账户}// 步骤说明:定义验证规则接口,支持正则表达式、长度限制和自定义验证export interface ValidationRule {pattern?: string; // 正则表达式模式minLength?: number; // 最小长度maxLength?: number; // 最大长度required?: boolean; // 是否必填customValidator?: (value: string) => boolean; // 自定义验证函数}// 步骤说明:定义上下文信息,用于智能推荐export interface FillContext {fieldName: string; // 字段名fieldType: FillDataType; // 字段类型inputMethod?: string; // 输入方式(键盘、语音等)appPackage?: string; // 应用包名pageName?: string; // 页面名称previousFields?: FillField[]; // 已填写的字段nextFields?: FillField[]; // 未填写的字段}// 步骤说明:定义表单字段信息export interface FillField {name: string; // 字段名type: FillDataType; // 字段类型hint?: string; // 提示信息isRequired: boolean; // 是否必填suggestions?: FillSuggestion[]; // 建议列表}// 步骤说明:定义智能建议数据结构export interface FillSuggestion {data: FillData; // 填充数据confidence: number; // 置信度(0-1)source: string; // 建议来源action?: string; // 建议动作}3. 智能填充引擎核心实现// SmartFillEngine.ets// 步骤说明:实现智能填充引擎,这是整个系统的核心逻辑处理模块import { hilog } from ‘@kit.PerformanceAnalysisKit’;import { preferences } from ‘@kit.ArkData’;import { businessError } from ‘@kit.BasicServicesKit’;export class SmartFillEngine {private static TAG: string = ‘SmartFillEngine’;private static PREFERENCES_KEY = ‘smart_fill_data’;// 步骤说明:输入模式检测函数 - 识别用户输入内容的类型public static detectInputMode(text: string, context?: FillContext): FillDataType {hilog.info(0x0000, this.TAG, '检测输入模式: ’ + text);// 步骤说明:使用正则表达式匹配识别不同类型 // 1. 邮箱检测 const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (emailPattern.test(text)) { return FillDataType.EMAIL; } // 2. 手机号检测 const phonePattern = /^1[3-9]\d{9}$/; if (phonePattern.test(text)) { return FillDataType.PHONE; } // 3. URL检测 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/\S*)?$/i; if (urlPattern.test(text)) { return FillDataType.URL; } // 4. 日期检测 const datePattern = /^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/; if (datePattern.test(text)) { return FillDataType.DATE; } // 5. 数字检测 if (/^\d+$/.test(text)) { return FillDataType.NUMBER; } return FillDataType.TEXT;}// 步骤说明:获取智能建议 - 综合多种策略为用户提供最佳建议public static async getSuggestions(currentInput: string,context: FillContext): Promise<FillSuggestion[]> {const suggestions: FillSuggestion[] = [];const allData = await this.getAllFillData();// 步骤说明:策略1 - 基于类型的建议 // 原理:查找与当前字段类型匹配的数据 const typeBased = allData.filter(item => item.type === context.fieldType && item.value.toLowerCase().includes(currentInput.toLowerCase()) ); suggestions.push(...typeBased.map(item => ({ data: item, confidence: 0.7, // 类型匹配置信度设为0.7 source: 'type_match' }))); // 步骤说明:策略2 - 基于使用频率的建议 // 原理:用户经常使用的数据优先级更高 const frequentData = allData .filter(item => item.usageCount > 0) .sort((a, b) => b.usageCount - a.usageCount) .slice(0, 5); suggestions.push(...frequentData.map(item => ({ data: item, confidence: item.usageCount * 0.1, // 每使用一次增加0.1置信度 source: 'frequent_use' }))); // 步骤说明:策略3 - 基于上下文的建议 // 原理:根据已填写的其他字段智能推荐 if (context.previousFields) { const contextBased = await this.getContextSuggestions(context); suggestions.push(...contextBased); } // 步骤说明:去重和排序,确保建议质量 return this.deduplicateAndSort(suggestions);}// 步骤说明:保存填充数据 - 存储用户的填写记录public static async saveFillData(data: FillData): Promise<void> {try {const allData = await this.getAllFillData();const existingIndex = allData.findIndex(item => item.id === data.id); if (existingIndex >= 0) { // 步骤说明:如果数据已存在,更新使用次数和时间 allData[existingIndex] = { ...data, usageCount: allData[existingIndex].usageCount + 1, lastUsed: Date.now() }; } else { // 步骤说明:新数据,设置初始值 allData.push({ ...data, id: this.generateId(), usageCount: 1, lastUsed: Date.now() }); } // 步骤说明:使用Preferences持久化存储 await preferences.put(this.PREFERENCES_KEY, JSON.stringify(allData)); hilog.info(0x0000, this.TAG, '保存填充数据成功'); } catch (error) { hilog.error(0x0000, this.TAG, '保存填充数据失败: ' + JSON.stringify(error)); }}// 步骤说明:获取所有填充数据 - 从存储中读取数据private static async getAllFillData(): Promise<FillData[]> {try {const data = await preferences.get(this.PREFERENCES_KEY, ‘[]’);return JSON.parse(data) || [];} catch (error) {hilog.error(0x0000, this.TAG, '获取填充数据失败: ’ + JSON.stringify(error));return [];}}// 步骤说明:生成唯一ID - 使用时间戳和随机数确保唯一性private static generateId(): string {return Date.now().toString(36) + Math.random().toString(36).substr(2);}// 步骤说明:去重和排序算法 - 确保建议列表质量和用户体验private static deduplicateAndSort(suggestions: FillSuggestion[]): FillSuggestion[] {const seen = new Set<string>();return suggestions.filter(suggestion => {const key = suggestion.data.id + suggestion.source;if (seen.has(key)) return false;seen.add(key);return true;}).sort((a, b) => b.confidence - a.confidence) // 按置信度降序.slice(0, 5); // 限制最多5个建议,避免界面过载}}4. 智能输入组件实现// SmartInputComponent.ets// 步骤说明:实现智能输入组件,这是用户直接交互的UI组件@Componentexport struct SmartInputComponent {// 步骤说明:定义组件的状态变量@State inputValue: string = ‘’; // 输入框的值@State suggestions: FillSuggestion[] = []; // 智能建议列表@State isSuggestionsVisible: boolean = false; // 是否显示建议@State inputType: FillDataType = FillDataType.TEXT; // 输入类型@State isFocused: boolean = false; // 输入框是否获得焦点@State validationError: string = ‘’; // 验证错误信息@State isLoading: boolean = false; // 是否正在加载// 步骤说明:定义组件的属性(从父组件传入)@Prop label: string = ‘’; // 输入框标签@Prop placeholder: string = ‘’; // 占位符文本@Prop fieldType: FillDataType = FillDataType.TEXT; // 字段类型@Prop required: boolean = false; // 是否必填@Prop validationRule?: ValidationRule; // 验证规则@Prop onValueChange?: (value: string) => void; // 值变化回调@Prop onSuggestionSelect?: (suggestion: FillData) => void; // 选择建议回调@Prop onValidate?: (isValid: boolean) => void; // 验证结果回调// 步骤说明:定义样式属性@Prop backgroundColor: ResourceColor = ‘#FFFFFF’;@Prop borderColor: ResourceColor = ‘#E0E0E0’;@Prop focusedBorderColor: ResourceColor = ‘#0066FF’;@Prop errorBorderColor: ResourceColor = ‘#FF3B30’;@Prop textColor: ResourceColor = ‘#000000’;@Prop placeholderColor: ResourceColor = ‘#999999’;// 步骤说明:动画控制private animationController: AnimationController = new AnimationController();private animateParam: AnimateParam = {duration: 200,curve: Curve.EaseOut};aboutToAppear() {// 步骤说明:组件即将显示时的初始化this.inputType = this.fieldType;}// 步骤说明:输入变化处理函数async onInputChange(value: string) {this.inputValue = value;this.validationError = ‘’;this.onValueChange?.(value);// 步骤说明:实时验证输入 this.validateInput(value); // 步骤说明:有输入且有焦点时显示智能建议 if (value.length > 0 && this.isFocused) { await this.loadSuggestions(value); this.isSuggestionsVisible = true; } else { this.isSuggestionsVisible = false; }}// 步骤说明:加载智能建议async loadSuggestions(input: string) {this.isLoading = true;// 步骤说明:构建上下文信息 const context: FillContext = { fieldName: this.label, fieldType: this.fieldType, inputMethod: 'keyboard' }; try { // 步骤说明:调用智能填充引擎获取建议 this.suggestions = await SmartFillEngine.getSuggestions(input, context); } catch (error) { hilog.error(0x0000, 'SmartInputComponent', '加载建议失败: ' + JSON.stringify(error)); } finally { this.isLoading = false; }}// 步骤说明:验证输入内容validateInput(value: string): boolean {if (!this.validationRule && !this.required) {this.onValidate?.(true);return true;}// 步骤说明:1. 必填验证 if (this.required && !value.trim()) { this.validationError = '此项为必填项'; this.onValidate?.(false); return false; } // 步骤说明:2. 使用验证规则验证 if (this.validationRule) { // 2.1 长度验证 if (this.validationRule.minLength && value.length < this.validationRule.minLength) { this.validationError = `至少需要${this.validationRule.minLength}个字符`; this.onValidate?.(false); return false; } if (this.validationRule.maxLength && value.length > this.validationRule.maxLength) { this.validationError = `最多${this.validationRule.maxLength}个字符`; this.onValidate?.(false); return false; } // 2.2 正则表达式验证 if (this.validationRule.pattern) { const pattern = new RegExp(this.validationRule.pattern); if (!pattern.test(value)) { this.validationError = '格式不正确'; this.onValidate?.(false); return false; } } // 2.3 自定义验证 if (this.validationRule.customValidator && !this.validationRule.customValidator(value)) { this.validationError = '验证失败'; this.onValidate?.(false); return false; } } this.onValidate?.(true); return true;}// 步骤说明:选择建议时的处理onSelectSuggestion(suggestion: FillSuggestion) {this.inputValue = suggestion.data.value;this.validationError = ‘’;this.isSuggestionsVisible = false;this.onSuggestionSelect?.(suggestion.data);// 步骤说明:记录使用,用于后续智能推荐 SmartFillEngine.saveFillData({ ...suggestion.data, usageCount: suggestion.data.usageCount + 1, lastUsed: Date.now() });}// 步骤说明:构建输入框UI@BuilderbuildInputField() {Column() {// 步骤说明:1. 标签if (this.label) {Text(this.label).fontSize(14).fontColor(‘#666666’).fontWeight(FontWeight.Medium).margin({ bottom: 8 })} // 步骤说明:2. 输入框容器 Column() { // 2.1 输入区域 Row() { // 步骤说明:TextInput核心组件 TextInput({ text: this.inputValue, placeholder: this.placeholder }) .width('100%') .height(48) .fontSize(16) .fontColor(this.textColor) .placeholderFont({ size: 16, weight: FontWeight.Normal }) .placeholderColor(this.placeholderColor) .caretColor('#0066FF') .onChange((value: string) => { this.onInputChange(value); }) .onEditChange((isEditing: boolean) => { this.isFocused = isEditing; if (!isEditing) { this.isSuggestionsVisible = false; } }) .maxLength(this.validationRule?.maxLength || 1000) .type(InputType.Normal) // 步骤说明:清除按钮 if (this.inputValue.length > 0) { Button() { Image($r('app.media.ic_close')) .width(20) .height(20) } .width(32) .height(32) .backgroundColor(Color.Transparent) .onClick(() => { this.inputValue = ''; this.validationError = ''; this.isSuggestionsVisible = false; this.onValueChange?.(''); }) } } .alignItems(VerticalAlign.Center) // 步骤说明:验证错误提示 if (this.validationError) { Text(this.validationError) .fontSize(12) .fontColor('#FF3B30') .margin({ top: 4 }) .width('100%') } } .backgroundColor(this.backgroundColor) .border({ width: { bottom: this.validationError ? 2 : 1 }, color: this.validationError ? this.errorBorderColor : (this.isFocused ? this.focusedBorderColor : this.borderColor) }) .borderRadius(8) .padding({ left: 12, right: 12 }) .clip(true) .transition({ type: TransitionType.All, opacity: 0.3, translate: { x: 0, y: 0, z: 0 } }) }}// 步骤说明:构建建议列表UI@BuilderbuildSuggestions() {if (this.isSuggestionsVisible && this.suggestions.length > 0) {Column() {ForEach(this.suggestions, (suggestion: FillSuggestion, index: number) => {Row({ space: 12 }) {// 步骤说明:1. 建议图标if (suggestion.data.icon) {Image(suggestion.data.icon).width(20).height(20).objectFit(ImageFit.Contain)} else {// 默认图标Column().width(20).height(20).backgroundColor(‘#F0F0F0’).borderRadius(10)} // 步骤说明:2. 建议内容 Column({ space: 4 }) { Text(suggestion.data.value) .fontSize(16) .fontColor('#000000') .textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(1) if (suggestion.data.title) { Text(suggestion.data.title) .fontSize(12) .fontColor('#666666') .textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(1) } } .layoutWeight(1) // 步骤说明:3. 置信度 Text(`${Math.round(suggestion.confidence * 100)}%`) .fontSize(12) .fontColor('#999999') } .width('100%') .height(56) .padding({ left: 16, right: 16 }) .backgroundColor('#FFFFFF') .onClick(() => { this.onSelectSuggestion(suggestion); }) }) } .width('100%') .backgroundColor('#FFFFFF') .border({ width: 1, color: '#E0E0E0' }) .borderRadius(8) .shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 }) .margin({ top: 4 }) .transition({ type: TransitionType.Insert, opacity: 0.3, translate: { x: 0, y: 0, z: 0 } }) }}// 步骤说明:组件主build函数build() {Column({ space: 0 }) {this.buildInputField() // 输入框this.buildSuggestions() // 建议列表}}}5. 智能填充表单页面实现// SmartFillPage.ets// 步骤说明:实现完整的智能填充表单页面,包含个人信息、工作信息和支付信息三个部分@Entry@Componentexport struct SmartFillPage {// 步骤说明:定义页面状态变量// 1. 个人信息@State personalInfo: {name: string;phone: string;email: string;idCard: string;address: string;} = {name: ‘’,phone: ‘’,email: ‘’,idCard: ‘’,address: ‘’};// 2. 工作信息@State workInfo: {company: string;jobTitle: string;department: string;workEmail: string;} = {company: ‘’,jobTitle: ‘’,department: ‘’,workEmail: ‘’};// 3. 支付信息@State paymentInfo: {cardNumber: string;cardHolder: string;expiryDate: string;cvv: string;} = {cardNumber: ‘’,cardHolder: ‘’,expiryDate: ‘’,cvv: ‘’};// 4. 页面状态@State formValid: boolean = false; // 表单是否有效@State currentSection: ‘personal’ | ‘work’ | ‘payment’ = ‘personal’; // 当前激活的标签页@State isLoading: boolean = false; // 是否正在加载@State showQuickFill: boolean = false; // 是否显示快速填充选项@State quickFillOptions: FillData[] = []; // 快速填充选项列表// 步骤说明:动画控制@State scaleAnimation: number = 1;private animator: Animator = new Animator();aboutToAppear() {// 步骤说明:页面显示时加载快速填充选项this.loadQuickFillOptions();}// 步骤说明:加载快速填充选项async loadQuickFillOptions() {this.isLoading = true;try {// 模拟从本地存储加载await new Promise(resolve => setTimeout(resolve, 500)); // 步骤说明:模拟数据,实际应用中从数据库或网络加载 this.quickFillOptions = [ { id: '1', type: FillDataType.PERSON_NAME, title: '张三', value: '张三', category: 'personal', usageCount: 10, lastUsed: Date.now() - 86400000 }, { id: '2', type: FillDataType.PHONE, title: '手机号', value: '13800138000', category: 'personal', usageCount: 8, lastUsed: Date.now() - 172800000 }, { id: '3', type: FillDataType.EMAIL, title: '工作邮箱', value: 'zhangsan@example.com', category: 'work', usageCount: 5, lastUsed: Date.now() - 259200000 } ]; } finally { this.isLoading = false; }}// 步骤说明:快速填充处理函数onQuickFill(data: FillData) {// 步骤说明:根据数据类型填充到对应的字段switch(data.type) {case FillDataType.PERSON_NAME:this.personalInfo.name = data.value;break;case FillDataType.PHONE:this.personalInfo.phone = data.value;break;case FillDataType.EMAIL:this.personalInfo.email = data.value;break;}// 步骤说明:保存使用记录 SmartFillEngine.saveFillData({ ...data, usageCount: data.usageCount + 1, lastUsed: Date.now() }); // 步骤说明:播放动画效果 this.animateQuickFill();}// 步骤说明:快速填充动画效果animateQuickFill() {this.scaleAnimation = 1;this.animator.create({duration: 300,curve: Curve.Spring}).onFrame((value: number) => {this.scaleAnimation = 1 + value * 0.1;}).onFinish(() => {this.scaleAnimation = 1;}).play();}// 步骤说明:验证整个表单validateForm(): boolean {// 步骤说明:验证个人信息const isPersonalValid =this.personalInfo.name.length > 0 &&/^1[3-9]\d{9}/.test(this.personalInfo.phone) && /^[^\s@]+@[^\s@]+\.[^\s@]+/.test(this.personalInfo.email) &&/^\d{17}[\dX]$/.test(this.personalInfo.idCard);return isPersonalValid;}// 步骤说明:提交表单async onSubmit() {if (!this.validateForm()) {// 步骤说明:表单验证失败,显示错误提示return;}this.isLoading = true; try { // 步骤说明:准备要保存的数据 const allData: FillData[] = [ { id: 'name_' + Date.now(), type: FillDataType.PERSON_NAME, title: '姓名', value: this.personalInfo.name, category: 'personal', usageCount: 1, lastUsed: Date.now() }, { id: 'phone_' + Date.now(), type: FillDataType.PHONE, title: '手机号', value: this.personalInfo.phone, category: 'personal', usageCount: 1, lastUsed: Date.now() }, { id: 'email_' + Date.now(), type: FillDataType.EMAIL, title: '邮箱', value: this.personalInfo.email, category: 'personal', usageCount: 1, lastUsed: Date.now() } ]; // 步骤说明:批量保存数据 for (const data of allData) { await SmartFillEngine.saveFillData(data); } // 步骤说明:提交成功,显示成功提示 this.showSuccessToast(); } finally { this.isLoading = false; }}// 步骤说明:构建个人信息表单@BuilderbuildPersonalInfoForm() {Column({ space: 16 }) {// 步骤说明:1. 快速填充按钮区域Row() {Text(‘快速填充’).fontSize(12).fontColor(‘#666666’) Button() { Image($r('app.media.ic_auto_fill')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F0F0F0') .borderRadius(16) .onClick(() => { this.showQuickFill = !this.showQuickFill; }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) // 步骤说明:2. 快速填充选项 if (this.showQuickFill && this.quickFillOptions.length > 0) { Column({ space: 8 }) { ForEach(this.quickFillOptions, (option: FillData) => { Button(option.title) { Text(option.value) .fontSize(14) .fontColor('#FFFFFF') } .width('100%') .height(40) .backgroundColor('#0066FF') .borderRadius(8) .onClick(() => { this.onQuickFill(option); }) .scale({ x: this.scaleAnimation, y: this.scaleAnimation }) }) } .width('100%') .padding(12) .backgroundColor('#FFFFFF') .border({ width: 1, color: '#E0E0E0' }) .borderRadius(8) .margin({ bottom: 16 }) } // 步骤说明:3. 姓名输入组件 SmartInputComponent({ label: '姓名', placeholder: '请输入姓名', fieldType: FillDataType.PERSON_NAME, required: true, validationRule: { minLength: 2, maxLength: 20 }, onValueChange: (value: string) => { this.personalInfo.name = value; } }) // 步骤说明:4. 手机号输入组件 SmartInputComponent({ label: '手机号', placeholder: '请输入手机号', fieldType: FillDataType.PHONE, required: true, validationRule: { pattern: '^1[3-9]\\d{9}$' }, onValueChange: (value: string) => { this.personalInfo.phone = value; } }) // 步骤说明:5. 邮箱输入组件 SmartInputComponent({ label: '邮箱', placeholder: '请输入邮箱', fieldType: FillDataType.EMAIL, required: true, onValueChange: (value: string) => { this.personalInfo.email = value; } }) // 步骤说明:6. 身份证号输入组件 SmartInputComponent({ label: '身份证号', placeholder: '请输入身份证号', fieldType: FillDataType.ID_NUMBER, required: true, validationRule: { pattern: '^\\d{17}[\\dX]$' }, onValueChange: (value: string) => { this.personalInfo.idCard = value; } }) // 步骤说明:7. 地址输入组件 SmartInputComponent({ label: '地址', placeholder: '请输入地址', fieldType: FillDataType.ADDRESS, onValueChange: (value: string) => { this.personalInfo.address = value; } }) }}// 步骤说明:构建页面头部@BuilderbuildHeader() {Column({ space: 16 }) {Text(‘智能信息填写’).fontSize(24).fontColor(‘#000000’).fontWeight(FontWeight.Bold) Text('智能识别输入内容,自动填充相关信息') .fontSize(14) .fontColor('#666666') // 步骤说明:导航标签 Row({ space: 0 }) { Button('个人信息') .padding({ left: 20, right: 20, top: 12, bottom: 12 }) .backgroundColor(this.currentSection === 'personal' ? '#0066FF' : 'transparent') .fontColor(this.currentSection === 'personal' ? '#FFFFFF' : '#666666') .borderRadius(20) .onClick(() => { this.currentSection = 'personal'; }) Button('工作信息') .padding({ left: 20, right: 20, top: 12, bottom: 12 }) .backgroundColor(this.currentSection === 'work' ? '#0066FF' : 'transparent') .fontColor(this.currentSection === 'work' ? '#FFFFFF' : '#666666') .borderRadius(20) .onClick(() => { this.currentSection = 'work'; }) Button('支付信息') .padding({ left: 20, right: 20, top: 12, bottom: 12 }) .backgroundColor(this.currentSection === 'payment' ? '#0066FF' : 'transparent') .fontColor(this.currentSection === 'payment' ? '#FFFFFF' : '#666666') .borderRadius(20) .onClick(() => { this.currentSection = 'payment'; }) } .width('100%') .padding(8) .backgroundColor('#F8F8F8') .borderRadius(24) } .width('100%') .padding({ left: 20, right: 20, top: 20, bottom: 20 }) .backgroundColor('#FFFFFF')}// 步骤说明:构建表单内容区域@BuilderbuildFormContent() {Scroll() {Column({ space: 24 }) {// 步骤说明:根据当前选择的标签页显示对应的表单if (this.currentSection === ‘personal’) {this.buildPersonalInfoForm()} else if (this.currentSection === ‘work’) {this.buildWorkInfoForm()} else {this.buildPaymentInfoForm()} // 步骤说明:提交按钮 Button('提交信息') { if (this.isLoading) { LoadingProgress() .width(20) .height(20) } else { Text('提交信息') .fontSize(16) .fontColor('#FFFFFF') } } .width('100%') .height(48) .backgroundColor('#0066FF') .borderRadius(24) .enabled(this.validateForm() && !this.isLoading) .onClick(() => { this.onSubmit(); }) .margin({ top: 32 }) } .width('100%') .padding(20) } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off)}// 步骤说明:主build函数build() {Column() {this.buildHeader() // 页面头部this.buildFormContent() // 表单内容}.width(‘100%’).height(‘100%’).backgroundColor(‘#F8F8F8’)}}6. 实现步骤总结步骤1:定义数据模型目的:为整个智能填充系统建立统一的数据结构● 定义填充数据接口(FillData)● 定义数据类型枚举(FillDataType)● 定义验证规则接口(ValidationRule)● 定义上下文和表单字段结构步骤2:实现智能填充引擎目的:实现核心的业务逻辑● 输入模式检测:识别用户输入的类型● 智能建议生成:结合类型、频率、上下文生成建议● 数据存储管理:使用Preferences持久化存储● 数据验证逻辑:支持多种验证规则步骤3:实现智能输入组件目的:创建可复用的UI组件● 实时输入处理:监听输入变化● 智能建议展示:显示相关建议列表● 输入验证反馈:实时验证并提示错误● 动画效果:平滑的交互动画步骤4:实现表单页面目的:整合组件提供完整用户体验● 表单结构设计:分页式表单布局● 快速填充功能:一键填充常用信息● 表单验证:整体表单验证逻辑● 数据提交:保存和提交表单数据步骤5:添加高级功能目的:增强用户体验和功能完整性● 动画效果:填充和交互动画● 错误处理:友好的错误提示● 性能优化:异步加载和缓存● 可访问性:支持无障碍使用
-
一、 问题说明在HarmonyOS Stage应用模型中,UIAbility组件承载应用界面,通过与UI(通常是ArkUI页面)的协同工作来实现完整功能。然而,UIAbility层和UI层之间存在天然的数据分离,带来了一系列协同问题:1. 数据流向混乱· UIAbility持有的业务数据(如网络请求结果、设备状态)无法直接传递给UI· UI产生的用户交互数据(如表单输入、按钮点击)难以回传至UIAbility进行逻辑处理· 双向数据同步缺乏统一的机制和最佳实践2. 生命周期错配· UIAbility生命周期(Create, Foreground, Background, Destroy)与UI组件的生命周期不同步· 数据应在何时初始化、何时更新、何时释放缺乏明确指导3. 状态管理分散· 同时存在Thread-local(EventHub)、UIAbility-local(LocalStorage)和App-global(AppStorage)多种状态管理方案· 开发者难以根据场景选择合适的方案,导致代码混乱和性能问题二、 原因分析基于文档分析,问题的根源在于HarmonyOS应用架构的设计特点:1. 进程与线程模型限制· UIAbility和UI运行在同一主线程,但通过不同的对象模型隔离· 直接的变量引用无法跨越UIAbility和UI的边界2. 架构分离的必然性 HarmonyOS采用UI与业务逻辑分离的设计理念:这种分离带来了更好的可维护性和跨端迁移能力,但也增加了数据同步的复杂度。三、 解决思路1. 按通信方向分类 数据同步方向分类 │ 单向传递 │ 双向同步 │ 事件驱动 │ (UI显示) │ (三方同步) │ (触发动作) 2. 按使用场景分层 应用层(跨UIAbility) ────── AppStorage / PersistentStorage │UIAbility层(内部同步) ──── LocalStorage / EventHub │页面层(组件间) ──────── @Provide/@Consume / @Provider/@Consumer3. 核心原则· 就近原则:数据尽量在最近的层级管理· 最小化原则:避免全局状态,优先使用局部状态· 类型匹配原则:根据数据类型选择同步方案· 生命周期对齐:数据管理范围与组件生命周期对齐四、 解决方案方案一:事件驱动通信(EventHub)适用场景:UIAbility与UI之间的命令传递、事件通知、简单数据通信实现机制:// UIAbility端 - 订阅事件import { UIAbility } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onCreate() { // 获取eventHub对象 const eventHub = this.context.eventHub; // 订阅事件(两种方式) eventHub.on('dataLoaded', (data: string) => { console.log(`收到数据:${data}`); }); eventHub.on('userAction', this.handleUserAction.bind(this)); } handleUserAction(arg1: any, arg2: any) { // 处理业务逻辑 }}// UI端 - 触发事件@Entry@Componentstruct MyPage { // 获取UIAbility上下文 private context = this.getUIContext().getHostContext() as common.UIAbilityContext; onDataLoad() { // 触发事件(支持多参数) this.context.eventHub.emit('dataLoaded', 'Hello from UI'); this.context.eventHub.emit('userAction', 1, 'action_type'); } build() { Column() { Button('发送事件') .onClick(() => this.onDataLoad()) } }}方案特点:· 轻量级,适合一次性通信· 支持多参数传递· 需手动管理事件订阅/取消· 适用线程内通信,不适合复杂状态同步方案二:状态管理同步(AppStorage/LocalStorage)适用场景:需要持续同步的UI状态、跨组件/页面数据共享2.1 AppStorage - 应用全局状态// UIAbility端 - 设置全局状态import { UIAbility } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onCreate() { // 设置应用全局数据 AppStorage.setOrCreate('userName', '张三'); AppStorage.setOrCreate('isLoggedIn', false); AppStorage.setOrCreate('appConfig', { theme: 'dark' }); }}// UI端 - 使用全局状态@Entry@Componentstruct AppPage { // 双向同步 @StorageLink('userName') userName: string = ''; @StorageLink('isLoggedIn') isLoggedIn: boolean = false; // 单向同步 @StorageProp('appConfig') config: object|undefined = undefined; build() { Column() { Text(this.userName) // 修改会自动同步到AppStorage .onClick(() => { this.userName = '李四'; // 更新会同步到所有使用此状态的组件 }) } }}2.2 LocalStorage - UIAbility局部状态// UIAbility端 - 创建并传递LocalStorageimport { UIAbility } from '@kit.AbilityKit';import { window } from '@kit.ArkUI';export default class EntryAbility extends UIAbility { // 初始化LocalStorage storage: LocalStorage = new LocalStorage(); onWindowStageCreate(windowStage: window.WindowStage) { // 设置初始值 this.storage.set('pageData', { count: 0, list: [] }); // 传递给UI windowStage.loadContent('pages/Index', this.storage); }}// UI端 - 使用LocalStorage@Entry({ useSharedStorage: true })@Componentstruct Page1 { // 双向同步 @LocalStorageLink('pageData') pageData: object|undefined = undefined; // 单向同步 @LocalStorageProp('someKey') someValue: string = ''; build() { Column() { Text(`计数:${this.pageData.count}`) .onClick(() => { this.pageData.count++; // 更新会同步到整个UIAbility }) } }}方案三:复杂对象双向同步适用场景:嵌套对象、数组等复杂数据结构的深度同步// 定义响应式数据模型@ObservedV2class UserModel { @Trace userId: number = 0; @Trace userName: string = ''; @Trace profile: Profile = new Profile(); constructor(id: number, name: string) { this.userId = id; this.userName = name; }}@ObservedV2class Profile { @Trace avatar: string = ''; @Trace settings: Map<string, string> = new Map();}// UIAbility端export default class EntryAbility extends UIAbility { onCreate() { // 使用AppStorageV2存储复杂对象 const user = new UserModel(1, '张三'); AppStorageV2.connect(UserModel, () => user); }}// UI端@ComponentV2struct UserProfile { @Local user: UserModel = AppStorageV2.connect<UserModel>(UserModel)!; build() { Column() { Text(this.user.userName) Button('修改') .onClick(() => { this.user.userName = '李四'; // 深度属性修改也能触发UI更新 this.user.profile.avatar = 'new_avatar.jpg'; }) } }}方案四:跨UIAbility数据传递适用场景:不同UIAbility实例间的数据通信// 方法一:通过Want参数传递// 启动方UIAbilityimport { UIAbility } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { startOtherAbility() { const want = { deviceId: '', bundleName: 'com.example.app', abilityName: 'TargetAbility', parameters: { 'dataKey': '传递的数据', 'count': 100, 'user': JSON.stringify({ name: '张三' }) } }; this.context.startAbility(want); }}// 接收方UIAbilityexport default class TargetAbility extends UIAbility { onCreate(want, launchParam) { const data = want.parameters['dataKey']; // '传递的数据' const count = want.parameters['count']; // 100 const user = JSON.parse(want.parameters['user']); }}// 方法二:通过AppStorage共享// UIAbility AAppStorage.setOrCreate('sharedData', { value: '共享数据' });// UIAbility B(同一应用内)const sharedData = AppStorage.get('sharedData');方案五:状态管理库(StateStore) - 高级方案适用场景:大型应用、复杂状态逻辑、需要严格状态管理// 1. 定义状态和Reducer@ObservedV2class TodoState { @Trace todos: Todo[] = []; @Trace filter: string = 'all';}// 2. 创建Action和Reducerconst todoReducer: Reducer<TodoState> = (state, action) => { switch (action.type) { case 'ADD_TODO': return { ...state, todos: [...state.todos, action.payload] }; case 'TOGGLE_TODO': return { ...state, todos: state.todos.map(todo => todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo )}; default: return state; }};// 3. 创建Storeconst todoStore = StateStore.createStore(new TodoState(), todoReducer);// 4. UI中使用@Componentstruct TodoApp { @State @Watch('onStateChange') state: TodoState = todoStore.getState(); onStateChange() { // 状态变化时自动更新UI } addTodo() { todoStore.dispatch({ type: 'ADD_TODO', payload: new Todo('新任务') }); } build() { Column() { ForEach(this.state.todos, (todo) => { TodoItem({ todo }) }) } }}五、 选择决策树开始数据同步方案选择 │ ┌───────▼────────┐ │ 通信方向是什么? │ └───────┬────────┘ │ ├───────────────┬───────────────┬───────────────┤单向UI显示 双向同步 事件触发 │ │ │ ▼ ▼ ▼@StorageProp @StorageLink EventHub@LocalStorageProp @LocalStorageLink EventEmitter @Link postCardAction !!语法 │ │ ▼ ▼ ┌─────▼─────┐ 是否需要回调? │数据范围多大?│ ├─────┬─────┤ └─────┬─────┘ 需要 不需要 │ │ │ ├─────┼─────┤ ▼ ▼单个页面 单个UIAbility startAbility startAbility │ 整个应用 ForResult │ │ ▼ ▼ LocalStorage AppStorage EventHub.emit带 │ 回调参数 ▼ 是否需要跨启动? ├─────┬─────┤ 需要 不需要 │ │ ▼ ▼ PersistentStorage AppStorage六、 最佳实践总结1. 基础原则· 简单状态用EventHub:一次性通信、命令传递· UI状态用LocalStorage:单个UIAbility内的页面间共享· 全局状态用AppStorage:跨UIAbility的数据共享· 复杂对象用响应式装饰器:@ObservedV2 + @Trace2. 性能优化建议// 避免:频繁更新大量数据AppStorage.setOrCreate('largeList', hugeArray); // 可能引起UI卡顿// 推荐:分批更新或使用局部状态@State localList: Item[] = []; // 仅在当前组件使用// 避免:在生命周期中阻塞操作onCreate() { // 避免:同步耗时操作 const data = this.loadDataSync(); // ❌ 阻塞UI初始化 // 推荐:异步加载 this.loadDataAsync().then(data => { AppStorage.setOrCreate('data', data); });}3. 常见问题规避· EventHub内存泄漏:及时调用eventHub.off()· AppStorage键名冲突:使用模块前缀,如'moduleA:key'· LocalStorage范围误解:注意单UIAbility限制· 复杂对象更新不触发UI:必须使用@ObservedV2装饰类4. 架构演进建议小型应用:EventHub + LocalStorage ↓中型应用:AppStorage + 响应式对象 ↓大型应用:StateStore + 模块化状态管理七、 总结UIAbility与UI数据同步是HarmonyOS应用开发的核心问题。通过合理运用EventHub、AppStorage、LocalStorage以及响应式状态管理,可以构建出清晰、高效、可维护的数据流架构。关键是根据具体场景选择匹配的方案:1. 轻量通信选EventHub,事件驱动,简单直接2. 状态共享选LocalStorage/AppStorage,声明式同步,自动更新3. 复杂对象选响应式装饰器,深度观测,精确控制4. 大型应用选StateStore,集中管理,可预测状态变更遵循"就近管理、最小范围、类型匹配"的原则,结合具体的业务需求,可以构建出性能优异、易于维护的数据同步方案,为HarmonyOS应用的质量和用户体验提供坚实基础。
-
一、问题说明在鸿蒙应用开发中,UIAbility 作为核心页面容器,面临异常退出后用户体验断裂的关键问题,具体表现为:1. 应用因系统资源管控、崩溃等异常退出后,再次启动无法恢复之前的页面栈,用户需重新导航至目标页面;2. 临时数据(如表单填写进度、筛选条件、页面滚动位置)丢失,导致用户重复操作,体验不佳;3. 备份触发场景不明确,正常退出与异常退出的备份逻辑易混淆;4. 数据备份存在容量限制与存储时效问题,缺乏清晰的使用约束指引;5. 单实例应用、特殊 Ability 类型(如 UIExtensionAbility)的备份恢复适配缺失。二、原因分析1. 系统设计层面:鸿蒙 UIAbility 采用单页面栈模型,应用进入后台后,页面可能被系统挂起或销毁,原生未提供页面栈持久化机制,仅依赖临时内存存储状态;2. 内存管理策略:鸿蒙系统为优化资源占用,会对后台应用进行内存回收,异常退出时内存中的页面状态与临时数据直接丢失,无自动备份机制;3. API 设计限制:备份恢复功能未默认启用,需开发者主动调用接口,且数据存储依赖 Want 的 parameters 字段,存在 200KB 容量上限,超出部分无法保存;4. 触发机制约束:系统未明确区分正常退出与异常退出的处理逻辑,导致备份触发场景模糊,且设备重启后沙箱文件清理,无法跨重启恢复;5. 组件适配局限:UIExtensionAbility 等衍生组件未适配备份恢复 API,单实例应用的启动流程(如 onNewWant 触发)未考虑恢复逻辑。三、解决思路针对上述问题,核心思路是 “主动启用 + 分层备份 + 精准恢复 + 约束适配”,具体分为四个方向:1. 启用开关控制:提供明确的 API 启用备份恢复功能,让开发者自主控制是否开启,避免默认启用带来的资源占用;2. 状态快照备份:在应用进入后台时,自动对页面栈状态、临时数据进行序列化快照,存储至应用沙箱,明确备份时效与容量限制;3. 分层恢复机制:异常退出后重启时,先在 onCreate 中解析备份数据,再在 onWindowStageCreate 中恢复页面栈,确保恢复流程有序;4. 约束适配优化:明确功能适用范围(仅 UIAbility)、触发场景(仅异常退出),针对单实例应用、配置清单提供适配方案,解决特殊场景问题。四、解决方案4.1 核心设计原则· 兼容性优先:适配鸿蒙 4.0 及以上版本,兼容 Stage 模型与 FA 模型,不影响现有应用逻辑;· 轻量高效:备份数据仅存储关键状态,不冗余存储大量数据,确保备份与恢复过程不影响应用性能;· 易用性:提供简洁的 API 调用方式,开发步骤不超过 3 步,降低集成成本;· 安全性:备份数据以加密文件形式存储在应用沙箱,仅应用自身可访问,保障数据安全。4.2 运行机制时序图应用异常退出场景:┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ 应用在前台运行 │ │ 进入后台onBackground │ │ 系统调用onSaveState ││ │───▶│ │───▶│ (自动备份) │└─────────────────┘ └─────────────────┘ └─────────────────┘应用再次启动恢复:┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ 应用再次启动 │ │ onCreate获取 │ │onWindowStageCreate││ │───▶│ 恢复数据 │───▶│ 恢复页面栈 │└─────────────────┘ └─────────────────┘ └─────────────────┘4.3 完整开发步骤步骤 1:启用备份恢复功能在 UIAbility 的 onCreate 生命周期中调用启用接口,优先于其他初始化逻辑执行:import { UIAbility } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onCreate() { console.info("[Demo] EntryAbility onCreate"); // 关键:启用UIAbility备份恢复功能 this.context.setRestoreEnabled(true); }}步骤 2:实现临时数据备份重写 onSaveState 方法,序列化存储需要恢复的临时数据:import { AbilityConstant, UIAbility } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onCreate() { this.context.setRestoreEnabled(true); } onSaveState(state: AbilityConstant.StateType, wantParams: Record<string, Object>) { console.log("[Demo] EntryAbility onSaveState"); // 存储临时数据(表单进度、当前页面标识等) wantParams["formData"] = JSON.stringify({ username: "test", progress: 80 }); wantParams["currentPageRoute"] = "pages/DetailPage"; // 返回保存策略,同意所有数据备份 return AbilityConstant.OnSaveResult.ALL_AGREE; }}步骤 3:实现数据与页面栈恢复在 onCreate 中解析备份数据,判断启动原因并恢复页面栈:import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { this.context.setRestoreEnabled(true); // 解析备份数据 if (want && want.parameters) { const formData = JSON.parse(want.parameters["formData"] as string); const currentPageRoute = want.parameters["currentPageRoute"]; console.info(`恢复数据:表单进度=${formData.progress},当前页面=${currentPageRoute}`); // 异常恢复场景,恢复页面栈 if (launchParam.launchReason === AbilityConstant.LaunchReason.APP_RECOVERY) { const storage = new LocalStorage(); storage.setOrCreate("recoverFormData", formData); this.context.restoreWindowStage(storage); } } }}步骤 4:配置清单适配在 module.json5 中标记 UIAbility 为可恢复,确保系统识别:{ "abilities": [ { "name": "EntryAbility", "recoverable": true, // 关键配置:标记为可恢复 "type": "page", "launchType": "standard" } ]}4.4 特殊场景适配场景 1:单实例应用恢复单实例应用(launchType=singleton)异常恢复可能触发 onNewWant,需补充恢复逻辑:import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam) { // 单实例应用异常恢复处理 if (launchParam.launchReason === AbilityConstant.LaunchReason.APP_RECOVERY && want?.parameters) { const formData = JSON.parse(want.parameters["formData"] as string); console.info("单实例应用恢复数据:", formData); // 补充页面栈恢复或数据同步逻辑 } }}场景 2:异常处理捕获 API 调用异常,避免影响应用启动:import { BusinessError } from '@kit.BasicServicesKit';import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { try { this.context.setRestoreEnabled(true); } catch (error) { const err = error as BusinessError; console.error(`启用备份恢复失败:错误码=${err.code},消息=${err.message}`); } }}五、总结本方案聚焦鸿蒙 UIAbility 异常退出后的状态恢复问题,通过 “启用开关 + 快照备份 + 分层恢复 + 场景适配” 的核心逻辑,实现了页面栈与临时数据的有效保留,解决了用户体验断裂的痛点。方案优势1. 针对性强:精准解决异常退出后状态丢失问题,覆盖普通应用与单实例应用等场景;2. 轻量易用:开发步骤简单,API 调用便捷,无额外依赖,集成成本低;3. 安全可靠:备份数据加密存储在应用沙箱,仅应用自身可访问,保障数据安全;4. 兼容性好:适配鸿蒙 4.0 及以上版本,兼容主流应用架构。适用场景与局限· 适用场景:需要保留用户临时操作状态的应用(如表单填写、多步骤流程、内容浏览类应用);· 局限:不支持跨设备恢复、设备重启后无法恢复、单份备份数据限制 200KB,复杂故障恢复需结合鸿蒙 appRecovery 模块。
-
问题说明:悬浮工具箱场景需求1.1 问题场景在移动应用中,用户经常需要在不同应用间快速切换常用工具。传统的工具入口需要返回桌面或切换应用,操作繁琐。悬浮工具箱可以在任意界面快速访问常用工具,提升操作效率。1.2 具体表现// 传统工具访问问题interface ToolAccessIssues {1: “需要退出当前应用才能使用工具”;2: “工具入口分散,查找困难”;3: “无法在特定场景快速调用”;4: “占用主屏幕空间”;5: “缺乏个性化定制”;}1.3 实际应用场景● 游戏过程中快速计算器● 阅读时快速截图和标注● 视频播放时亮度调节● 多任务处理时快速笔记● 系统设置一键调整1.4 技术要求● 支持悬浮窗显示和拖拽● 基于zIndex确保悬浮层级● 流畅的手势交互● 可配置的工具项● 自适应屏幕尺寸解决思路:整体架构设计2.1 技术架构基于HarmonyOS最新API设计的三层架构:UI层:使用ArkTS声明式UI,基于图片中的设计实现业务层:工具管理、窗口控制、手势处理系统层:窗口管理、权限控制、系统服务2.2 核心API● @ohos.window:窗口管理API● @ohos.gesture:手势识别API● @ohos.zindex:层级管理API● @ohos.preferences:数据持久化API解决方案:完整实现代码3.1 配置权限和依赖// module.json5 - 步骤1:配置应用权限{“module”: {“requestPermissions”: [{“name”: “ohos.permission.SYSTEM_FLOAT_WINDOW”,“reason”: “需要显示悬浮窗功能”,“usedScene”: {“when”: “always”,“abilities”: [“EntryAbility”]}},{“name”: “ohos.permission.CAPTURE_SCREEN”,“reason”: “需要截图功能”,“usedScene”: {“when”: “inuse”,“abilities”: [“EntryAbility”]}}],“abilities”: [{“name”: “EntryAbility”,“srcEntry”: “./ets/entryability/EntryAbility.ets”,“description”: “string:entryabilitydesc","icon":"string:entryability_desc", "icon": "string:entryabilitydesc","icon":"media:icon”,“label”: “string:entryabilitylabel","startWindowIcon":"string:entryability_label", "startWindowIcon": "string:entryabilitylabel","startWindowIcon":"media:float_icon”,“startWindowLabel”: “悬浮工具箱”}]}}此步骤配置应用所需的系统权限。SYSTEM_FLOAT_WINDOW权限允许应用创建悬浮窗,CAPTURE_SCREEN权限支持截图功能。同时定义了应用的入口Ability。3.2 定义数据模型// FloatModels.ets - 步骤2:定义数据模型和枚举import { BusinessError } from ‘@ohos.base’;// 工具类型枚举 - 定义支持的工具类型export enum ToolType {SCREENSHOT = 1, // 截图工具CALCULATOR = 2, // 计算器BRIGHTNESS = 3, // 亮度调节NOTE = 4, // 快速笔记SETTINGS = 5, // 系统设置CLIPBOARD = 6, // 剪贴板CUSTOM = 99 // 自定义工具}// 悬浮窗位置配置接口export interface FloatPosition {x: number; // X坐标(相对于屏幕左上角)y: number; // Y坐标(相对于屏幕左上角)width: number; // 窗口宽度height: number; // 窗口高度}// 工具配置接口export interface ToolConfig {id: string; // 工具唯一标识name: string; // 工具显示名称type: ToolType; // 工具类型icon: Resource; // 图标资源enabled: boolean; // 是否启用order: number; // 显示顺序description?: string; // 工具描述}// 悬浮窗状态接口export interface FloatState {isVisible: boolean; // 是否可见position: FloatPosition; // 当前位置opacity: number; // 透明度 0.0-1.0isDragging: boolean; // 是否正在拖拽currentTool?: ToolConfig; // 当前选中工具}// 进度信息接口(对应图片中的加载进度)export interface ProgressInfo {current: number; // 当前进度值total: number; // 总进度值description: string; // 进度描述}此步骤定义应用的核心数据模型。包括工具类型枚举、悬浮窗位置配置、工具配置、悬浮窗状态等接口。这些模型为后续的业务逻辑提供类型安全支持。3.3 实现悬浮窗管理器// FloatWindowManager.ets - 步骤3:实现悬浮窗核心管理器import window from ‘@ohos.window’;import display from ‘@ohos.display’;import { BusinessError } from ‘@ohos.base’;/**悬浮窗管理器 - 负责窗口的创建、显示、隐藏和位置管理*/export class FloatWindowManager {private floatWindow: window.Window | null = null;private screenInfo: display.Display | null = null;private currentState: FloatState;private dragStartPosition: { x: number, y: number } = { x: 0, y: 0 };// 单例模式确保全局只有一个悬浮窗实例private static instance: FloatWindowManager;static getInstance(): FloatWindowManager {if (!FloatWindowManager.instance) {FloatWindowManager.instance = new FloatWindowManager();}return FloatWindowManager.instance;}constructor() {this.currentState = {isVisible: false,position: { x: 0, y: 0, width: 160, height: 220 },opacity: 0.9,isDragging: false};}// 步骤3.1:初始化显示信息private async initDisplayInfo(): Promise<void> {try {this.screenInfo = await display.getDefaultDisplaySync();console.info(‘屏幕信息获取成功:’, JSON.stringify(this.screenInfo));} catch (error) {console.error(‘获取屏幕信息失败:’, JSON.stringify(error));throw error;}}// 步骤3.2:创建悬浮窗async createFloatWindow(context: common.BaseContext): Promise<void> {try {await this.initDisplayInfo(); // 使用最新的WindowStage创建API const windowClass = window.WindowStage; const windowStageContext = context as common.UIAbilityContext; // 创建窗口实例 this.floatWindow = await windowClass.create(context, "float_toolbox"); // 设置窗口类型为悬浮窗 await this.floatWindow.setWindowType(window.WindowType.TYPE_FLOAT); // 设置窗口属性 - 根据图片中的悬浮窗尺寸调整 const windowProperties: window.WindowProperties = { windowRect: { left: this.screenInfo!.width - 180, // 默认显示在右侧 top: Math.floor(this.screenInfo!.height / 2 - 110), width: 160, // 对应图片中的宽度 height: 220 // 对应图片中的高度 }, isFullScreen: false, isLayoutFullScreen: false, focusable: true, touchable: true, isTransparent: true, // 支持透明背景 brightness: 1.0 }; await this.floatWindow.setWindowProperties(windowProperties); // 设置窗口模式为悬浮 await this.floatWindow.setWindowMode(window.WindowMode.WINDOW_MODE_FLOATING); // 设置背景透明 await this.floatWindow.setWindowBackgroundColor('#00000000'); // 更新当前状态 this.currentState.position = { x: windowProperties.windowRect.left, y: windowProperties.windowRect.top, width: windowProperties.windowRect.width, height: windowProperties.windowRect.height }; this.currentState.isVisible = true; console.info('悬浮窗创建成功'); } catch (error) { console.error('创建悬浮窗失败:', JSON.stringify(error)); throw error; }}// 步骤3.3:显示悬浮窗async show(): Promise<void> {if (!this.floatWindow) {throw new Error(‘悬浮窗未创建’);}try { await this.floatWindow.show(); this.currentState.isVisible = true; console.info('悬浮窗显示成功'); } catch (error) { console.error('显示悬浮窗失败:', JSON.stringify(error)); throw error; }}// 步骤3.4:隐藏悬浮窗async hide(): Promise<void> {if (!this.floatWindow) {return;}try { await this.floatWindow.hide(); this.currentState.isVisible = false; console.info('悬浮窗隐藏成功'); } catch (error) { console.error('隐藏悬浮窗失败:', JSON.stringify(error)); }}// 步骤3.5:更新窗口位置async updatePosition(x: number, y: number): Promise<void> {if (!this.floatWindow || !this.screenInfo) {return;}try { // 边界检查,确保窗口不会移出屏幕 const maxX = this.screenInfo.width - this.currentState.position.width; const maxY = this.screenInfo.height - this.currentState.position.height; const clampedX = Math.max(0, Math.min(x, maxX)); const clampedY = Math.max(0, Math.min(y, maxY)); await this.floatWindow.moveTo(clampedX, clampedY); // 更新状态 this.currentState.position.x = clampedX; this.currentState.position.y = clampedY; console.info(`窗口位置更新到: (${clampedX}, ${clampedY})`); } catch (error) { console.error('更新窗口位置失败:', JSON.stringify(error)); }}// 步骤3.6:边缘吸附功能private snapToEdge(x: number, y: number): { x: number, y: number } {if (!this.screenInfo) return { x, y };const SNAP_THRESHOLD = 50; // 吸附阈值50像素 const EDGE_MARGIN = 10; // 边缘边距 let newX = x; let newY = y; // 左侧吸附 if (x < SNAP_THRESHOLD) { newX = EDGE_MARGIN; } // 右侧吸附 else if (x > this.screenInfo.width - this.currentState.position.width - SNAP_THRESHOLD) { newX = this.screenInfo.width - this.currentState.position.width - EDGE_MARGIN; } // 顶部吸附 if (y < SNAP_THRESHOLD) { newY = EDGE_MARGIN; } // 底部吸附 else if (y > this.screenInfo.height - this.currentState.position.height - SNAP_THRESHOLD) { newY = this.screenInfo.height - this.currentState.position.height - EDGE_MARGIN; } return { x: newX, y: newY };}// 步骤3.7:手势处理 - 开始拖拽onDragStart(x: number, y: number): void {this.currentState.isDragging = true;this.dragStartPosition = { x, y };console.info(开始拖拽,起点: (${x}, ${y}));}// 步骤3.8:手势处理 - 拖拽移动async onDragMove(x: number, y: number): Promise<void> {if (!this.currentState.isDragging || !this.floatWindow) {return;}// 计算相对位移 const deltaX = x - this.dragStartPosition.x; const deltaY = y - this.dragStartPosition.y; // 计算新位置 const newX = this.currentState.position.x + deltaX; const newY = this.currentState.position.y + deltaY; // 更新位置(拖拽过程中不进行边缘吸附) await this.updatePosition(newX, newY); // 更新起点位置 this.dragStartPosition = { x, y };}// 步骤3.9:手势处理 - 结束拖拽async onDragEnd(x: number, y: number): Promise<void> {if (!this.currentState.isDragging) {return;}this.currentState.isDragging = false; // 计算最终位置并进行边缘吸附 const deltaX = x - this.dragStartPosition.x; const deltaY = y - this.dragStartPosition.y; const finalX = this.currentState.position.x + deltaX; const finalY = this.currentState.position.y + deltaY; // 应用边缘吸附 const snappedPosition = this.snapToEdge(finalX, finalY); // 更新到吸附后的位置 await this.updatePosition(snappedPosition.x, snappedPosition.y); console.info(`拖拽结束,吸附到: (${snappedPosition.x}, ${snappedPosition.y})`);}// 步骤3.10:更新窗口透明度async updateOpacity(opacity: number): Promise<void> {if (!this.floatWindow) {return;}try { // 将透明度转换为16进制颜色值 const alpha = Math.round(opacity * 255); const hexAlpha = alpha.toString(16).padStart(2, '0'); await this.floatWindow.setWindowBackgroundColor(`#${hexAlpha}000000`); this.currentState.opacity = opacity; console.info(`窗口透明度更新为: ${opacity}`); } catch (error) { console.error('更新透明度失败:', JSON.stringify(error)); }}// 步骤3.11:销毁窗口async destroy(): Promise<void> {if (!this.floatWindow) {return;}try { await this.floatWindow.destroy(); this.floatWindow = null; this.currentState.isVisible = false; console.info('悬浮窗销毁成功'); } catch (error) { console.error('销毁悬浮窗失败:', JSON.stringify(error)); }}// 步骤3.12:获取当前状态getCurrentState(): FloatState {return { …this.currentState };}// 步骤3.13:检查悬浮窗权限async checkFloatPermission(): Promise<boolean> {try {const abilityAccessCtrl = abilityAccessCtrl.createAtManager();const result = await abilityAccessCtrl.checkAccessToken(abilityAccessCtrl.AssetType.ASSET_SYSTEM,‘ohos.permission.SYSTEM_FLOAT_WINDOW’);return result === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;} catch (error) {console.error(‘检查权限失败:’, JSON.stringify(error));return false;}}}此步骤实现悬浮窗的核心管理功能。包括窗口创建、显示/隐藏、位置管理、边缘吸附、手势处理等。使用单例模式确保全局只有一个悬浮窗实例,通过边界检查和边缘吸附确保良好的用户体验。3.4 实现工具箱管理器// ToolManager.ets - 步骤4:实现工具箱管理import { preferences } from ‘@kit.ArkData’;import { BusinessError } from ‘@ohos.base’;/**工具箱管理器 - 负责工具的配置、存储和管理*/export class ToolManager {private static readonly PREFERENCES_NAME = ‘float_toolbox_config’;private static readonly KEY_TOOL_LIST = ‘tool_list’;private static readonly KEY_WINDOW_STATE = ‘window_state’;private static readonly KEY_PROGRESS = ‘progress_info’;private preferences: preferences.Preferences | null = null;private tools: ToolConfig[] = [];// 默认工具配置 - 对应图片中的工具private defaultTools: ToolConfig[] = [{id: ‘tool_light’,name: ‘亮度’,type: ToolType.BRIGHTNESS,icon: $r(‘app.media.ic_light’),enabled: true,order: 1,description: ‘快速调节屏幕亮度’},{id: ‘tool_list’,name: ‘工具’,type: ToolType.SETTINGS,icon: $r(‘app.media.ic_list’),enabled: true,order: 2,description: ‘工具列表管理’},{id: ‘tool_calc’,name: ‘计算’,type: ToolType.CALCULATOR,icon: $r(‘app.media.ic_calculator’),enabled: true,order: 3,description: ‘快速计算器’},{id: ‘tool_more’,name: ‘更多’,type: ToolType.CUSTOM,icon: $r(‘app.media.ic_more’),enabled: true,order: 4,description: ‘查看更多工具’}];// 步骤4.1:初始化管理器async initialize(context: common.Context): Promise<void> {try {this.preferences = await preferences.getPreferences(context, {name: ToolManager.PREFERENCES_NAME}); // 加载工具配置 await this.loadToolConfig(); console.info('工具箱管理器初始化成功'); } catch (error) { console.error('工具箱管理器初始化失败:', JSON.stringify(error)); throw error; }}// 步骤4.2:加载工具配置private async loadToolConfig(): Promise<void> {if (!this.preferences) {this.tools = […this.defaultTools];return;}try { const toolsJson = await this.preferences.get(ToolManager.KEY_TOOL_LIST, '[]'); const savedTools = JSON.parse(toolsJson as string); if (savedTools && savedTools.length > 0) { // 将JSON数据转换为ToolConfig对象 this.tools = savedTools.map((tool: any) => ({ ...tool, icon: this.getResourceByString(tool.iconStr) })); } else { // 使用默认配置 this.tools = [...this.defaultTools]; await this.saveToolConfig(); } } catch (error) { console.error('加载工具配置失败:', JSON.stringify(error)); this.tools = [...this.defaultTools]; }}// 步骤4.3:保存工具配置async saveToolConfig(): Promise<void> {if (!this.preferences) {throw new Error(‘配置管理器未初始化’);}try { // 将Resource对象转换为可序列化的字符串 const toolsToSave = this.tools.map(tool => ({ ...tool, iconStr: this.getResourceString(tool.icon) })); await this.preferences.put(ToolManager.KEY_TOOL_LIST, JSON.stringify(toolsToSave)); await this.preferences.flush(); console.info('工具配置保存成功'); } catch (error) { console.error('保存工具配置失败:', JSON.stringify(error)); throw error; }}// 步骤4.4:获取所有工具(按顺序)getTools(): ToolConfig[] {return […this.tools].sort((a, b) => a.order - b.order);}// 步骤4.5:获取启用的工具getEnabledTools(): ToolConfig[] {return this.tools.filter(tool => tool.enabled).sort((a, b) => a.order - b.order);}// 步骤4.6:更新工具状态async updateToolStatus(toolId: string, enabled: boolean): Promise<void> {const toolIndex = this.tools.findIndex(tool => tool.id === toolId);if (toolIndex !== -1) {this.tools[toolIndex].enabled = enabled;await this.saveToolConfig();console.info(工具 ${toolId} 状态更新为: ${enabled});}}// 步骤4.7:更新工具顺序async updateToolOrder(orderedTools: ToolConfig[]): Promise<void> {// 更新每个工具的顺序orderedTools.forEach((tool, index) => {const toolIndex = this.tools.findIndex(t => t.id === tool.id);if (toolIndex !== -1) {this.tools[toolIndex].order = index + 1;}});await this.saveToolConfig(); console.info('工具顺序更新成功');}// 步骤4.8:保存窗口状态async saveWindowState(state: FloatState): Promise<void> {if (!this.preferences) {return;}try { await this.preferences.put(ToolManager.KEY_WINDOW_STATE, JSON.stringify(state)); await this.preferences.flush(); console.info('窗口状态保存成功'); } catch (error) { console.error('保存窗口状态失败:', JSON.stringify(error)); }}// 步骤4.9:加载窗口状态async loadWindowState(): Promise<FloatState | null> {if (!this.preferences) {return null;}try { const stateJson = await this.preferences.get(ToolManager.KEY_WINDOW_STATE, ''); if (stateJson) { return JSON.parse(stateJson as string) as FloatState; } } catch (error) { console.error('加载窗口状态失败:', JSON.stringify(error)); } return null;}// 步骤4.10:保存进度信息(对应图片中的加载进度)async saveProgressInfo(progress: ProgressInfo): Promise<void> {if (!this.preferences) {return;}try { await this.preferences.put(ToolManager.KEY_PROGRESS, JSON.stringify(progress)); await this.preferences.flush(); console.info('进度信息保存成功'); } catch (error) { console.error('保存进度信息失败:', JSON.stringify(error)); }}// 步骤4.11:加载进度信息async loadProgressInfo(): Promise<ProgressInfo> {if (!this.preferences) {return { current: 0, total: 100, description: ‘正在加载…’ };}try { const progressJson = await this.preferences.get(ToolManager.KEY_PROGRESS, ''); if (progressJson) { return JSON.parse(progressJson as string) as ProgressInfo; } } catch (error) { console.error('加载进度信息失败:', JSON.stringify(error)); } return { current: 0, total: 100, description: '正在加载...' };}// 步骤4.12:工具字符串转Resourceprivate getResourceByString(resourceStr: string): Resource {// 这里需要根据实际资源映射关系实现if (resourceStr.includes(‘ic_light’)) {return $r(‘app.media.ic_light’);} else if (resourceStr.includes(‘ic_list’)) {return $r(‘app.media.ic_list’);} else if (resourceStr.includes(‘ic_calculator’)) {return $r(‘app.media.ic_calculator’);} else if (resourceStr.includes(‘ic_more’)) {return $r(‘app.media.ic_more’);}return $r(‘app.media.ic_default’);}// 步骤4.13:Resource转字符串private getResourceString(resource: Resource): string {// 简化实现,实际应根据资源ID生成字符串return resource.id.toString();}// 步骤4.14:添加自定义工具async addCustomTool(name: string, type: ToolType, icon: Resource): Promise<string> {const newTool: ToolConfig = {id: custom_${Date.now()}_${Math.random().toString(36).substr(2, 9)},name,type,icon,enabled: true,order: this.tools.length + 1};this.tools.push(newTool); await this.saveToolConfig(); console.info(`自定义工具添加成功: ${name}`); return newTool.id;}// 步骤4.15:删除工具async removeTool(toolId: string): Promise<void> {const initialLength = this.tools.length;this.tools = this.tools.filter(tool => tool.id !== toolId);if (this.tools.length < initialLength) { await this.saveToolConfig(); console.info(`工具删除成功: ${toolId}`); } else { console.warn(`未找到要删除的工具: ${toolId}`); }}// 步骤4.16:重置为默认配置async resetToDefault(): Promise<void> {this.tools = […this.defaultTools];await this.saveToolConfig();console.info(‘已重置为默认工具配置’);}}此步骤实现工具箱的数据管理功能。包括工具配置的加载、保存、更新和管理。使用Preferences API进行数据持久化,支持工具的自定义排序、启用/禁用,以及窗口状态的保存和恢复。3.5 创建主页面(严格按图片UI实现)// FloatToolboxMain.ets - 步骤5:主页面实现@Entry@Componentstruct FloatToolboxMain {// 状态变量@State tools: ToolConfig[] = []; // 工具列表@State progressInfo: ProgressInfo = { // 进度信息(对应图片中的49%)current: 49,total: 100,description: ‘当前已加载49%’};@State isFloatWindowVisible: boolean = false; // 悬浮窗是否可见@State windowOpacity: number = 0.9; // 窗口透明度@State isLoading: boolean = true; // 加载状态// 管理器实例private floatWindowManager = FloatWindowManager.getInstance();private toolManager = new ToolManager();// 步骤5.1:页面生命周期 - 进入时初始化aboutToAppear() {this.initializeApp();}// 步骤5.2:应用初始化async initializeApp() {this.isLoading = true;try { // 1. 初始化工具箱管理器 await this.toolManager.initialize(getContext(this) as common.Context); // 2. 加载工具配置 this.tools = this.toolManager.getTools(); // 3. 加载进度信息 const savedProgress = await this.toolManager.loadProgressInfo(); if (savedProgress.current > 0) { this.progressInfo = savedProgress; } // 4. 检查悬浮窗权限 const hasPermission = await this.floatWindowManager.checkFloatPermission(); if (!hasPermission) { console.warn('没有悬浮窗权限,请先授权'); } // 模拟加载过程 setTimeout(() => { this.isLoading = false; console.info('应用初始化完成'); }, 1500); } catch (error) { console.error('应用初始化失败:', JSON.stringify(error)); this.isLoading = false; }}// 步骤5.3:构建页面UI(严格按图片布局)build() {Column() {// 顶部状态栏(模拟图片)this.buildStatusBar() // 主内容区域 Scroll() { Column() { // 标题区域(对应图片顶部) this.buildTitleSection() // 介绍区域 this.buildIntroductionSection() // 预览区域 this.buildPreviewSection() // 工具配置区域 this.buildToolConfigSection() // 设置区域 this.buildSettingsSection() // 操作按钮 this.buildActionButtons() // 使用说明 this.buildInstructions() } .width('100%') } } .width('100%') .height('100%') .backgroundColor('#FFFFFF')}// 步骤5.4:构建状态栏(对应图片中的10:00 94%)@BuilderbuildStatusBar() {Row() {// 左侧时间(对应图片中的10:00)Text(‘10:00’).fontSize(16).fontColor(‘#000000’).fontWeight(FontWeight.Medium).margin({ left: 20 }) Blank() // 右侧电池(对应图片中的94%) Row({ space: 4 }) { // 这里可以使用图标或文字表示电池 Text('94%') .fontSize(14) .fontColor('#000000') .fontWeight(FontWeight.Medium) } .margin({ right: 20 }) } .width('100%') .height(44) .backgroundColor('#F8F8F8') .alignItems(VerticalAlign.Center)}// 步骤5.5:构建标题区域(对应图片的顶部布局)@BuilderbuildTitleSection() {Column({ space: 8 }) {// 第一行:标题和关闭按钮Row() {Text(‘悬浮工具箱案例’).fontSize(20).fontColor(‘#000000’).fontWeight(FontWeight.Bold).layoutWeight(1).margin({ left: 20 }) // 关闭按钮(对应图片右上角的关闭图标) Image($r('app.media.ic_close')) .width(24) .height(24) .margin({ right: 20 }) .onClick(() => { this.onCloseClick(); }) } .width('100%') .height(56) .alignItems(VerticalAlign.Center) // 第二行:路径信息(对应图片中的HarmonyOS - Cases/Cases) Text('HarmonyOS - Cases/Cases') .fontSize(12) .fontColor('#666666') .margin({ left: 20 }) .alignSelf(ItemAlign.Start) // 分隔线 Divider() .color('#EEEEEE') .strokeWidth(1) .margin({ top: 8, bottom: 8 }) } .width('100%') .margin({ top: 8 })}// 步骤5.6:构建介绍区域(对应图片中的介绍部分)@BuilderbuildIntroductionSection() {Column({ space: 12 }) {// 标题:悬浮工具箱(对应图片)Text(‘悬浮工具箱’).fontSize(24).fontColor(‘#000000’).fontWeight(FontWeight.Bold).margin({ left: 20 }).alignSelf(ItemAlign.Start) // 介绍框(对应图片中的灰色背景介绍区域) Column() { // 介绍标题 Text('介绍') .fontSize(16) .fontColor('#000000') .fontWeight(FontWeight.Medium) .margin({ bottom: 8 }) // 介绍内容(严格按图片中的文字) Text('本示例介绍使用zIndex、gesture等接口实现悬浮工具箱效果') .fontSize(14) .fontColor('#666666') .lineHeight(20) } .width('90%') .padding(16) .backgroundColor('#F8F8F8') .borderRadius(8) .margin({ left: 20, right: 20 }) } .width('100%') .margin({ top: 20 })}// 步骤5.7:构建预览区域(对应图片中的效果预览图)@BuilderbuildPreviewSection() {Column({ space: 16 }) {// 预览标题Text(‘效果预览图’).fontSize(18).fontColor(‘#000000’).fontWeight(FontWeight.Medium).margin({ left: 20 }).alignSelf(ItemAlign.Start) // 预览容器(对应图片中的预览区域) Column() { // 悬浮窗模拟(对应图片中的蓝色悬浮窗) Stack() { // 悬浮窗主体 Column() { // 标题栏(蓝色背景) Row() .width('100%') .height(40) .backgroundColor('#007DFF') .borderRadius({ topLeft: 12, topRight: 12 }) // 工具图标区域(对应图片中的图标布局) Column({ space: 16 }) { Row({ space: 20 }) { // 灯泡图标 Column({ space: 4 }) { Image($r('app.media.ic_light')) .width(24) .height(24) Text('亮度') .fontSize(12) .fontColor('#FFFFFF') } // 列表图标 Column({ space: 4 }) { Image($r('app.media.ic_list')) .width(24) .height(24) Text('工具') .fontSize(12) .fontColor('#FFFFFF') } // 计算器图标 Column({ space: 4 }) { Image($r('app.media.ic_calculator')) .width(24) .height(24) Text('计算') .fontSize(12) .fontColor('#FFFFFF') } } .padding({ top: 20 }) // 更多图标(对应图片中的...) Text('...') .fontSize(20) .fontColor('#FFFFFF') .fontWeight(FontWeight.Bold) .margin({ top: 8 }) } .width('100%') .alignItems(HorizontalAlign.Center) } .width(150) .height(220) .backgroundColor('#1A007DFF') // 半透明蓝色 .borderRadius(12) .shadow({ radius: 8, color: '#40007DFF', offsetX: 0, offsetY: 4 }) } .margin({ bottom: 24 }) // 进度条区域(对应图片中的进度条) Column({ space: 8 }) { Text(this.progressInfo.description) .fontSize(16) .fontColor('#000000') .margin({ left: 20 }) .alignSelf(ItemAlign.Start) // 进度条(对应图片中的蓝色进度条) Row() { // 进度填充部分 Row() .width(`${this.progressInfo.current}%`) .height(8) .backgroundColor('#007DFF') .borderRadius(4) // 剩余部分 Blank() } .width('90%') .height(8) .backgroundColor('#F0F0F0') .borderRadius(4) .margin({ left: 20, right: 20 }) } .width('100%') } .width('90%') .padding(20) .backgroundColor('#F8F8F8') .borderRadius(12) .margin({ left: 20, right: 20 }) } .width('100%') .margin({ top: 24 })}// 步骤5.8:构建工具配置区域@BuilderbuildToolConfigSection() {Column({ space: 12 }) {Text(‘工具配置’).fontSize(18).fontColor(‘#000000’).fontWeight(FontWeight.Medium).margin({ left: 20, top: 24 }).alignSelf(ItemAlign.Start) ForEach(this.tools, (tool: ToolConfig, index: number) => { this.buildToolItem(tool, index) }) } .width('100%')}// 步骤5.9:构建单个工具项@BuilderbuildToolItem(tool: ToolConfig, index: number) {Row() {// 左侧:图标和名称Row({ space: 12 }) {Image(tool.icon).width(24).height(24).objectFit(ImageFit.Contain) Column({ space: 2 }) { Text(tool.name) .fontSize(16) .fontColor('#333333') if (tool.description) { Text(tool.description) .fontSize(12) .fontColor('#666666') } } } .layoutWeight(1) // 右侧:开关 Toggle({ type: ToggleType.Switch, isOn: tool.enabled }) .selectedColor('#007DFF') .switchPointColor('#FFFFFF') .onChange((value: boolean) => { this.onToolToggle(tool.id, value); }) } .width('90%') .height(60) .padding({ left: 20, right: 20 }) .backgroundColor('#FFFFFF') .borderRadius(8) .margin({ left: 20, right: 20, bottom: 8 }) .shadow({ radius: 2, color: '#10000000', offsetX: 0, offsetY: 1 })}// 步骤5.10:构建设置区域@BuilderbuildSettingsSection() {Column({ space: 16 }) {Text(‘窗口设置’).fontSize(18).fontColor(‘#000000’).fontWeight(FontWeight.Medium).margin({ left: 20, top: 24 }).alignSelf(ItemAlign.Start) // 透明度设置 Row() { Text('窗口透明度') .fontSize(16) .fontColor('#333333') .layoutWeight(1) .margin({ left: 20 }) Text(`${Math.round(this.windowOpacity * 100)}%`) .fontSize(16) .fontColor('#007DFF') .margin({ right: 20 }) } .width('100%') .height(48) Slider({ value: this.windowOpacity, min: 0.3, max: 1.0, step: 0.1, style: SliderStyle.OutSet }) .width('90%') .height(40) .trackColor('#E0E0E0') .selectedColor('#007DFF') .showSteps(true) .blockColor('#007DFF') .onChange((value: number) => { this.onOpacityChange(value); }) .margin({ left: 20, right: 20 }) // 悬浮窗开关 Row() { Text('显示悬浮窗') .fontSize(16) .fontColor('#333333') .layoutWeight(1) .margin({ left: 20 }) Toggle({ type: ToggleType.Switch, isOn: this.isFloatWindowVisible }) .selectedColor('#007DFF') .switchPointColor('#FFFFFF') .onChange((value: boolean) => { this.onFloatWindowToggle(value); }) .margin({ right: 20 }) } .width('100%') .height(60) .backgroundColor('#FFFFFF') .borderRadius(8) .margin({ left: 20, right: 20, top: 8 }) .shadow({ radius: 2, color: '#10000000', offsetX: 0, offsetY: 1 }) } .width('100%')}// 步骤5.11:构建操作按钮@BuilderbuildActionButtons() {Row({ space: 16 }) {Button(‘添加工具’).width(‘40%’).height(48).fontSize(16).fontColor(‘#FFFFFF’).backgroundColor(‘#007DFF’).borderRadius(24).onClick(() => {this.onAddToolClick();}) Button('重置配置') .width('40%') .height(48) .fontSize(16) .fontColor('#007DFF') .backgroundColor('#FFFFFF') .borderRadius(24) .border({ width: 1, color: '#007DFF' }) .onClick(() => { this.onResetClick(); }) } .width('90%') .justifyContent(FlexAlign.SpaceBetween) .margin({ top: 32, left: 20, right: 20 })}// 步骤5.12:构建使用说明@BuilderbuildInstructions() {Column({ space: 12 }) {Text(‘使用说明:’).fontSize(16).fontColor(‘#000000’).fontWeight(FontWeight.Medium).margin({ left: 20, top: 24 }).alignSelf(ItemAlign.Start) Text('· 拖拽悬浮窗标题栏可移动位置') .fontSize(14) .fontColor('#666666') .margin({ left: 20 }) .alignSelf(ItemAlign.Start) Text('· 靠近屏幕边缘会自动吸附') .fontSize(14) .fontColor('#666666') .margin({ left: 20 }) .alignSelf(ItemAlign.Start) Text('· 点击工具图标可快速使用') .fontSize(14) .fontColor('#666666') .margin({ left: 20 }) .alignSelf(ItemAlign.Start) Text('· 可在设置中自定义工具') .fontSize(14) .fontColor('#666666') .margin({ left: 20 }) .alignSelf(ItemAlign.Start) } .width('100%') .margin({ bottom: 40 })}// 步骤5.13:工具开关事件处理private async onToolToggle(toolId: string, enabled: boolean) {try {await this.toolManager.updateToolStatus(toolId, enabled);// 更新本地状态const toolIndex = this.tools.findIndex(tool => tool.id === toolId);if (toolIndex !== -1) {this.tools[toolIndex].enabled = enabled;this.tools = […this.tools]; // 触发重新渲染}console.info(工具 ${toolId} 状态已更新: ${enabled});} catch (error) {console.error(‘更新工具状态失败:’, JSON.stringify(error));}}// 步骤5.14:透明度变更事件处理private async onOpacityChange(value: number) {this.windowOpacity = value;try { // 更新悬浮窗透明度 if (this.isFloatWindowVisible) { await this.floatWindowManager.updateOpacity(value); } console.info(`窗口透明度已更新: ${value}`); } catch (error) { console.error('更新透明度失败:', JSON.stringify(error)); }}// 步骤5.15:悬浮窗开关事件处理private async onFloatWindowToggle(enabled: boolean) {this.isFloatWindowVisible = enabled;try { const context = getContext(this) as common.BaseContext; if (enabled) { // 创建并显示悬浮窗 await this.floatWindowManager.createFloatWindow(context); await this.floatWindowManager.show(); await this.floatWindowManager.updateOpacity(this.windowOpacity); // 加载保存的窗口状态 const savedState = await this.toolManager.loadWindowState(); if (savedState && savedState.position) { await this.floatWindowManager.updatePosition( savedState.position.x, savedState.position.y ); } } else { // 隐藏悬浮窗 const currentState = this.floatWindowManager.getCurrentState(); await this.toolManager.saveWindowState(currentState); await this.floatWindowManager.hide(); } console.info(`悬浮窗状态已更新: ${enabled}`); } catch (error) { console.error('切换悬浮窗状态失败:', JSON.stringify(error)); this.isFloatWindowVisible = false; }}// 步骤5.16:添加工具点击事件private onAddToolClick() {// 跳转到添加工具页面或显示对话框console.info(‘添加工具按钮被点击’);// TODO: 实现添加工具逻辑}// 步骤5.17:重置配置点击事件private async onResetClick() {try {await this.toolManager.resetToDefault();this.tools = this.toolManager.getTools();this.windowOpacity = 0.9;this.progressInfo = { current: 0, total: 100, description: ‘配置已重置’ }; console.info('配置已重置为默认'); } catch (error) { console.error('重置配置失败:', JSON.stringify(error)); }}// 步骤5.18:关闭按钮点击事件private onCloseClick() {// 保存当前状态this.saveAppState();// 关闭悬浮窗 if (this.isFloatWindowVisible) { this.floatWindowManager.hide().catch(console.error); } // 返回或关闭应用 console.info('关闭按钮被点击'); // TODO: 根据实际需求实现关闭逻辑}// 步骤5.19:保存应用状态private async saveAppState() {try {// 保存进度信息await this.toolManager.saveProgressInfo(this.progressInfo); // 保存窗口状态 if (this.isFloatWindowVisible) { const currentState = this.floatWindowManager.getCurrentState(); await this.toolManager.saveWindowState(currentState); } console.info('应用状态保存成功'); } catch (error) { console.error('保存应用状态失败:', JSON.stringify(error)); }}// 步骤5.20:页面离开时清理资源aboutToDisappear() {this.saveAppState().catch(console.error);// 清理悬浮窗资源 if (this.isFloatWindowVisible) { this.floatWindowManager.destroy().catch(console.error); }}}此步骤实现完整的用户界面,严格按照图片中的设计布局。包括状态栏、标题区域、介绍区域、预览区域、工具配置区域、设置区域和操作按钮。使用ArkTS声明式UI构建,实现响应式布局和交互逻辑。3.6 创建悬浮窗UI组件// FloatWindowComponent.ets - 步骤6:悬浮窗UI组件@Componentexport struct FloatWindowComponent {@Link tools: ToolConfig[]; // 工具列表@Link onToolClick: (tool: ToolConfig) => void; // 工具点击回调@Link onDragStart: (x: number, y: number) => void; // 拖拽开始回调@Link onDragMove: (x: number, y: number) => void; // 拖拽移动回调@Link onDragEnd: (x: number, y: number) => void; // 拖拽结束回调@State private isExpanded: boolean = false; // 是否展开@State private dragOffset: { x: number, y: number } = { x: 0, y: 0 };// 步骤6.1:构建悬浮窗组件build() {// 使用Stack实现悬浮层级Stack() {// 悬浮窗主体Column() {// 标题栏(拖拽区域)Row().width(‘100%’).height(40).backgroundColor(‘#007DFF’).borderRadius({ topLeft: 12, topRight: 12 }).gesture(GestureGroup(GestureMode.Parallel,PanGesture({ distance: 1 }).onActionStart((event: GestureEvent) => {// 记录拖拽起点this.onDragStart(event.offsetX, event.offsetY);}).onActionUpdate((event: GestureEvent) => {// 更新拖拽位置this.onDragMove(event.offsetX, event.offsetY);}).onActionEnd(() => {// 结束拖拽this.onDragEnd(this.dragOffset.x, this.dragOffset.y);}))) // 工具区域 Column({ space: 16 }) { // 显示启用的工具 ForEach(this.tools.filter(tool => tool.enabled), (tool: ToolConfig, index: number) => { this.buildToolButton(tool, index) } ) // 展开/收起按钮 Row() .width(32) .height(32) .backgroundColor('#FFFFFF33') .borderRadius(16) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .onClick(() => { this.isExpanded = !this.isExpanded; }) .margin({ top: 8 }) } .width('100%') .padding(12) .alignItems(HorizontalAlign.Center) } .width(this.isExpanded ? 180 : 150) .height(this.isExpanded ? 260 : 220) .backgroundColor('#1A007DFF') .borderRadius(12) .shadow({ radius: 12, color: '#40000000', offsetX: 0, offsetY: 4 }) .opacity(0.9) }}// 步骤6.2:构建工具按钮@BuilderbuildToolButton(tool: ToolConfig, index: number) {Column({ space: 4 }) {Button(‘’, { type: ButtonType.Circle }).width(48).height(48).backgroundColor(‘#FFFFFF33’).onClick(() => {this.onToolClick(tool);}).overlay(Image(tool.icon).width(24).height(24).objectFit(ImageFit.Contain)) Text(tool.name) .fontSize(12) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width(60)}}此步骤实现悬浮窗的UI组件。包含可拖拽的标题栏和工具按钮区域,支持展开/收起功能。通过Gesture API实现拖拽交互,使用Stack确保正确的zIndex层级。项目结构和资源文件4.1 资源文件配置// resources/base/element/string.json{“string”: [{“name”: “app_name”,“value”: “悬浮工具箱”},{“name”: “entryability_label”,“value”: “悬浮工具箱”},{“name”: “entryability_desc”,“value”: “基于HarmonyOS的悬浮工具箱案例”},{“name”: “float_window_label”,“value”: “工具箱”}]}总结5.1 实现成果严格按照图片中的UI设计,完整实现了鸿蒙悬浮工具箱案例:精准的UI还原:严格按照图片布局实现,包括状态栏、标题、介绍、预览图、进度条等完整的悬浮窗功能:基于最新window API实现,支持拖拽和边缘吸附流畅的手势交互:使用gesture API实现自然的拖拽体验完善的数据管理:使用preferences API持久化配置模块化架构:清晰的代码结构和职责分离
-
鸿蒙扫码能力接入案例问题场景:在鸿蒙应用开发中,需要集成扫码功能实现商品识别、身份验证、信息录入等场景开发者不熟悉鸿蒙扫码能力的接入流程和最佳实践扫码功能需要适配不同设备(手机、平板、带摄像头的物联网设备) 解决方案方案一:基于系统能力的完整扫码组件1. 创建扫码模块结构qrcode-scanner/├── src/main/│ ├── ets/│ │ ├── qrcode/│ │ │ ├── QRCodeScanner.ets // 主组件│ │ │ ├── ScannerController.ets // 控制器│ │ │ ├── ScannerView.ets // 预览视图│ │ │ ├── types/ // 类型定义│ │ │ │ ├── ScannerConfig.ets│ │ │ │ └── ScanResult.ets│ │ │ └── utils/ // 工具类│ │ │ ├── PermissionUtil.ets│ │ │ └── DeviceUtil.ets│ │ └── resources/ // 资源文件│ │ ├── base/media/ // 图片音效│ │ └── rawfile/ // 配置文件│ └── module.json5 // 模块配置└── oh-package.json5 // 依赖配置 2. 核心代码实现QRCodeScanner.ets - 主组件import { ScannerConfig, ScanResult, ScanError } from './types/ScannerConfig';import { ScannerController } from './ScannerController';import { PermissionUtil } from './utils/PermissionUtil';@Componentexport struct QRCodeScanner { // 配置参数 private config: ScannerConfig = { scanTypes: ['QRCODE', 'BARCODE', 'DATAMATRIX'], vibrateOnSuccess: true, beepOnSuccess: true, autoZoom: true, torchEnabled: true, scanAreaRatio: 0.7, scanInterval: 300 }; // 控制器实例 private controller: ScannerController = new ScannerController(); // 扫码结果回调 private onScanResult: (result: ScanResult) => void = (result: ScanResult) => { console.info('Scan result:', result); // 震动反馈 if (this.config.vibrateOnSuccess) { this.vibrate(); } // 声音反馈 if (this.config.beepOnSuccess) { this.playBeep(); } }; // 错误处理回调 private onScanError: (error: ScanError) => void = (error: ScanError) => { console.error('Scan error:', error); this.showErrorMessage(error.message); }; aboutToAppear(): void { this.initScanner(); } // 初始化扫码器 private async initScanner(): Promise<void> { try { // 1. 检查权限 const hasPermission = await PermissionUtil.checkCameraPermission(); if (!hasPermission) { await PermissionUtil.requestCameraPermission(); } // 2. 初始化控制器 await this.controller.init(this.config); // 3. 设置回调 this.controller.setOnScanResult(this.onScanResult); this.controller.setOnError(this.onScanError); // 4. 开始扫码 await this.controller.startScan(); } catch (error) { this.onScanError({ code: -1, message: `初始化失败: ${error.message}` }); } } // 渲染UI build() { Column() { // 扫码预览区域 ScannerView({ controller: this.controller }) .width('100%') .height('100%') // 扫码框和提示 this.buildScanFrame() // 底部操作栏 this.buildToolbar() } .width('100%') .height('100%') .backgroundColor(Color.Black) } // 构建扫码框 @Builder private buildScanFrame() { Column() { // 半透明蒙层 Rect() .width('100%') .height('100%') .fill('#A6000000') // 扫码框(中间透明区域) Rect() .width('70%') .height('70%') .fill('#00000000') .strokeWidth(2) .stroke(Color.White) .overlay( // 扫描线动画 this.buildScanLine() ) } } // 构建扫描线动画 @Builder private buildScanLine() { Rect() .width('100%') .height(2) .fill(Color.Green) .animation({ duration: 2000, iterations: -1, curve: Curve.Linear }) .translate({ y: -$r('app.float.scan_line_position') }) } // 构建工具栏 @Builder private buildToolbar() { Row() { // 手电筒按钮 Button(this.controller.isTorchOn() ? '关闭闪光灯' : '打开闪光灯') .onClick(() => this.controller.toggleTorch()) // 相册选择 Button('从相册选择') .onClick(() => this.pickImageFromGallery()) // 设置按钮 Button('设置') .onClick(() => this.openSettings()) } .padding(20) .backgroundColor('#33000000') } aboutToDisappear(): void { this.controller.release(); }} ScannerController.ets - 控制器import { Camera, camera } from '@ohos.multimedia.camera';import { image } from '@ohos.multimedia.image';import { zbar } from '@ohos.zbar';export class ScannerController { private cameraManager: camera.CameraManager; private cameraInput: camera.CameraInput; private previewOutput: camera.PreviewOutput; private imageReceiver: image.ImageReceiver; private scanTimer: number = 0; private isScanning: boolean = false; private torchState: boolean = false; // 初始化相机 async init(config: ScannerConfig): Promise<void> { try { // 获取相机管理器 this.cameraManager = camera.getCameraManager(globalThis.context); // 获取后置摄像头 const cameras = this.cameraManager.getSupportedCameras(); const backCamera = cameras.find(cam => cam.position === camera.CameraPosition.CAMERA_POSITION_BACK ); if (!backCamera) { throw new Error('未找到后置摄像头'); } // 创建相机输入 this.cameraInput = this.cameraManager.createCameraInput(backCamera); await this.cameraInput.open(); // 创建预览输出 const surfaceId = await this.createPreviewSurface(); this.previewOutput = this.cameraManager.createPreviewOutput(surfaceId); // 创建图片接收器用于扫码分析 this.imageReceiver = image.createImageReceiver( 1920, // 宽度 1080, // 高度 image.ImageFormat.JPEG, // 格式 2 // 容量 ); // 配置相机 const cameraOutputCapability = this.cameraManager.getSupportedOutputCapability( backCamera ); // 创建会话 const session = this.cameraManager.createCaptureSession(); session.beginConfig(); session.addInput(this.cameraInput); session.addOutput(this.previewOutput); session.commitConfig(); await session.start(); this.isScanning = true; this.startScanLoop(); } catch (error) { throw new Error(`相机初始化失败: ${error.message}`); } } // 开始扫码循环 private startScanLoop(): void { this.scanTimer = setInterval(async () => { if (!this.isScanning) return; try { const image = await this.captureImage(); const result = await this.scanImage(image); if (result && this.onScanResult) { this.onScanResult(result); this.pauseScanning(); // 扫码成功暂停 } } catch (error) { if (this.onError) { this.onError({ code: -2, message: `扫码失败: ${error.message}` }); } } }, this.config.scanInterval); } // 扫码图片 private async scanImage(img: image.Image): Promise<ScanResult> { return new Promise((resolve, reject) => { zbar.scan({ image: img, scanTypes: this.config.scanTypes }, (err, data) => { if (err) { reject(err); return; } if (data && data.length > 0) { resolve({ type: data[0].type, content: data[0].content, points: data[0].points, timestamp: new Date().getTime() }); } else { resolve(null); } }); }); } // 切换手电筒 toggleTorch(): boolean { this.torchState = !this.torchState; this.cameraInput.enableTorch(this.torchState); return this.torchState; } // 释放资源 release(): void { this.isScanning = false; clearInterval(this.scanTimer); if (this.cameraInput) { this.cameraInput.close(); } }} 3. 权限配置module.json5{ "module": { "requestPermissions": [ { "name": "ohos.permission.CAMERA", "reason": "$string:camera_permission_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] }} PermissionUtil.etsimport { abilityAccessCtrl, Permissions } from '@ohos.abilityAccessCtrl';export class PermissionUtil { // 检查相机权限 static async checkCameraPermission(): Promise<boolean> { try { const atManager = abilityAccessCtrl.createAtManager(); const result = await atManager.checkAccessToken( globalThis.context.tokenId, Permissions.CAMERA ); return result === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; } catch (error) { console.error('检查权限失败:', error); return false; } } // 请求相机权限 static async requestCameraPermission(): Promise<boolean> { return new Promise((resolve) => { const atManager = abilityAccessCtrl.createAtManager(); atManager.requestPermissionsFromUser( globalThis.context, [Permissions.CAMERA], (err, data) => { if (err || data.authResults[0] !== 0) { resolve(false); } else { resolve(true); } } ); }); }} 4. 使用示例页面调用示例import { QRCodeScanner } from '../qrcode-scanner/QRCodeScanner';import { ScanResult } from '../qrcode-scanner/types/ScanResult';@Entry@Componentstruct ScanPage { @State scanResult: string = ''; @State isScanning: boolean = true; build() { Column() { if (this.isScanning) { // 使用扫码组件 QRCodeScanner({ onScanResult: (result: ScanResult) => { this.handleScanResult(result); }, onScanError: (error) => { this.handleScanError(error); } }) } else { // 显示扫码结果 Text(this.scanResult) .fontSize(20) .padding(20) Button('重新扫码') .onClick(() => { this.isScanning = true; this.scanResult = ''; }) } } } private handleScanResult(result: ScanResult): void { console.info('扫码结果:', result.content); this.scanResult = `类型: ${result.type}\n内容: ${result.content}`; this.isScanning = false; // 根据扫码类型处理不同业务 this.processByScanType(result); } private processByScanType(result: ScanResult): void { switch (result.type) { case 'QRCODE': // 处理二维码 this.processQRCode(result.content); break; case 'BARCODE': // 处理条形码 this.processBarcode(result.content); break; case 'DATAMATRIX': // 处理Data Matrix码 this.processDataMatrix(result.content); break; } }} 5 结果展示接入时间缩短:从原来的2-3天缩短到1小时内完成扫码功能集成代码复用率:扫码模块可在不同项目中复用,减少重复开发维护成本:统一维护扫码模块,问题修复一处更新,多处生效 后续优化建议高级功能扩展支持二维码生成功能增加扫码历史记录批量扫码支持离线扫码能力性能优化使用WebAssembly加速图像处理实现扫码缓存机制支持后台扫码服务兼容性增强适配更多扫码格式支持自定义扫码识别算法多设备自适应布局
-
1.1 问题说明:Scroll容器嵌套滚动手势冲突问题场景在HarmonyOS应用开发中,当需要在一个父Scroll容器中嵌套多个可滚动子组件(如Web组件、List组件)时,会出现滚动手势冲突问题。典型的应用场景包括新闻浏览页面,其中新闻内容由Web组件展示,评论区由List组件展示。具体表现// 常见的问题代码结构@Componentstruct NewsDetailPage {build() {Scroll() {// 新闻内容 - Web组件(可滚动)Web({ src: ‘news_content_url’ }).height(‘50%’) // 评论区 - List组件(可滚动) List() { ForEach(comments, (comment: CommentItem) => { ListItem() { CommentItemView({ comment: comment }) } }) } .height('50%') }}}问题复现条件:父容器Scroll包含多个可滚动的子组件用户滚动新闻内容时,期望能够平滑滚动到评论区用户滚动评论区时,期望能够平滑滚动回新闻内容实际表现:滚动手势在父子组件间冲突,导致滚动卡顿、不连续核心问题:● 父Scroll容器和子Web/List组件都监听滚动手势● 手势优先级不明确,导致滚动行为混乱● 无法实现从新闻内容到评论区的无缝滚动体验1.2 原因分析:滚动手势冲突机制技术根因// 手势冲突示意图interface GestureConflict {父组件: {类型: “Scroll容器”,手势: “垂直滚动”,事件传播: “向下传播”,优先级: “低”};子组件: {类型: “Web/List组件”,手势: “垂直滚动”,事件传播: “向上传播”,优先级: “高”};冲突结果: {现象: “滚动不连续、卡顿”,原因: “父子组件都消费滚动事件”,影响: “用户体验差”};}根本原因分析:手势事件传播机制:○ HarmonyOS默认采用冒泡机制传播手势事件○ 子组件优先消费滚动手势事件○ 父组件无法获取完整的手势控制权滚动边界处理:○ 每个组件都有自己的滚动边界○ 滚动到边界时无法自动切换到父容器或其他子组件○ 需要手动处理滚动传递逻辑1.3 解决思路:统一滚动控制方案优化方向禁用子组件滚动:通过.scrollable(false)禁用Web和List的滚动手势统一事件处理:父Scroll容器统一处理所有滚动事件智能偏移计算:根据滚动位置和方向计算各组件偏移量平滑过渡:实现组件间无缝滚动体验1.4 解决方案:完整实现代码步骤1:定义数据模型// NewsModels.ets - 新闻数据模型export interface NewsItem {id: string;title: string;content: string;source: string;publishTime: number;viewCount: number;likeCount: number;shareCount: number;isLiked: boolean;isFavorited: boolean;}export interface CommentItem {id: string;userId: string;userName: string;userAvatar: string;content: string;publishTime: number;likeCount: number;replyCount: number;isLiked: boolean;replies?: CommentItem[];}export interface ScrollPosition {webScrollY: number;listScrollY: number;totalScrollY: number;currentSection: ‘web’ | ‘list’;isScrolling: boolean;}首先定义新闻和评论的数据结构,以及滚动位置状态模型,为后续的滚动控制提供数据基础。步骤2:实现滚动控制器// ScrollController.ets - 滚动控制管理器export class NestScrollController {// 滚动状态private scrollState: ScrollPosition = {webScrollY: 0,listScrollY: 0,totalScrollY: 0,currentSection: ‘web’,isScrolling: false};// 组件尺寸信息private componentMetrics = {webHeight: 0,listHeight: 0,screenHeight: 0,headerHeight: 0};// 滚动监听器private scrollListeners: Array<(position: ScrollPosition) => void> = [];// 初始化组件尺寸initializeMetrics(metrics: {webHeight: number;listHeight: number;screenHeight: number;headerHeight: number;}) {this.componentMetrics = metrics;console.info(‘滚动控制器初始化完成:’, metrics);}// 处理滚动事件handleScroll(offsetY: number): ScrollPosition {this.scrollState.isScrolling = true;this.scrollState.totalScrollY += offsetY;// 计算当前滚动位置 const { webHeight, listHeight, headerHeight } = this.componentMetrics; const totalContentHeight = webHeight + listHeight; const maxScrollY = totalContentHeight - this.componentMetrics.screenHeight; // 限制滚动范围 this.scrollState.totalScrollY = Math.max(0, Math.min( this.scrollState.totalScrollY, maxScrollY )); // 计算各组件偏移量 this.calculateComponentOffsets(); // 确定当前活动区域 this.determineCurrentSection(); // 通知监听器 this.notifyScrollListeners(); return { ...this.scrollState };}// 计算各组件偏移量private calculateComponentOffsets() {const { webHeight, listHeight, headerHeight } = this.componentMetrics;const totalScrollY = this.scrollState.totalScrollY;// Web组件偏移量计算 if (totalScrollY <= webHeight) { // 仍在Web区域 this.scrollState.webScrollY = totalScrollY; this.scrollState.listScrollY = 0; } else { // 进入List区域 this.scrollState.webScrollY = webHeight; this.scrollState.listScrollY = totalScrollY - webHeight; } // 限制偏移量范围 this.scrollState.webScrollY = Math.min( this.scrollState.webScrollY, webHeight ); this.scrollState.listScrollY = Math.min( this.scrollState.listScrollY, listHeight );}// 确定当前活动区域private determineCurrentSection() {const { webHeight } = this.componentMetrics;const { totalScrollY } = this.scrollState;if (totalScrollY < webHeight) { this.scrollState.currentSection = 'web'; } else { this.scrollState.currentSection = 'list'; }}// 滚动到指定位置scrollTo(position: {section?: ‘web’ | ‘list’;offsetY?: number;animated?: boolean;}) {const { section, offsetY, animated = true } = position;if (section === 'web') { this.scrollState.totalScrollY = offsetY || 0; this.scrollState.currentSection = 'web'; } else if (section === 'list') { const { webHeight } = this.componentMetrics; this.scrollState.totalScrollY = webHeight + (offsetY || 0); this.scrollState.currentSection = 'list'; } else if (offsetY !== undefined) { this.scrollState.totalScrollY = offsetY; } this.calculateComponentOffsets(); this.notifyScrollListeners(); if (animated) { // 触发动画滚动 this.animateScroll(); }}// 动画滚动private animateScroll() {// 实现平滑滚动动画console.info(‘执行动画滚动到:’, this.scrollState.totalScrollY);}// 添加滚动监听addScrollListener(listener: (position: ScrollPosition) => void) {this.scrollListeners.push(listener);}// 移除滚动监听removeScrollListener(listener: (position: ScrollPosition) => void) {const index = this.scrollListeners.indexOf(listener);if (index > -1) {this.scrollListeners.splice(index, 1);}}// 通知所有监听器private notifyScrollListeners() {this.scrollListeners.forEach(listener => {listener({ …this.scrollState });});}// 获取当前滚动状态getScrollState(): ScrollPosition {return { …this.scrollState };}// 重置滚动状态reset() {this.scrollState = {webScrollY: 0,listScrollY: 0,totalScrollY: 0,currentSection: ‘web’,isScrolling: false};this.notifyScrollListeners();}}实现滚动控制器,负责统一管理所有滚动事件、计算各组件偏移量,并确保滚动行为的一致性。步骤3:实现新闻内容Web组件// NewsWebComponent.ets - 新闻内容Web组件@Componentexport struct NewsWebComponent {@Prop content: string = ‘’;@Prop scrollY: number = 0;@Prop onSizeChange?: (height: number) => void;@State webHeight: number = 0;private webController: WebController = new WebController();aboutToAppear() {// 加载HTML内容this.loadHtmlContent();}// 加载HTML内容loadHtmlContent() {const htmlContent = <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { margin: 0; padding: 16px; font-family: -apple-system, sans-serif; line-height: 1.6; color: #333; } .news-title { font-size: 24px; font-weight: bold; margin-bottom: 12px; color: #000; } .news-meta { font-size: 14px; color: #666; margin-bottom: 20px; } .news-content { font-size: 16px; line-height: 1.8; } .news-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; } .news-content p { margin-bottom: 16px; } </style> </head> <body> <div class="news-title">${this.content.title || '新闻标题'}</div> <div class="news-meta"> <span>${this.content.source || '未知来源'}</span> <span> · </span> <span>${this.formatTime(this.content.publishTime)}</span> </div> <div class="news-content"> ${this.content.content || '新闻内容加载中...'} </div> </body> </html> ;this.webController.loadData(htmlContent, 'text/html', 'UTF-8');}// 格式化时间private formatTime(timestamp: number): string {const date = new Date(timestamp);return ${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()};}// Web组件加载完成onWebLoadFinish(event: { url: string }) {// 获取Web内容高度this.webController.getWebContentHeight().then((height: number) => {this.webHeight = height;this.onSizeChange?.(height);console.info(‘Web内容高度:’, height);});}build() {Column() {// Web组件 - 禁用滚动Web({src: $rawfile(‘news_template.html’),controller: this.webController}).width(‘100%’).height(this.webHeight).scrollable(false) // 关键:禁用Web组件自身的滚动.onPageEnd(() => {this.onWebLoadFinish({ url: ‘’ });}).margin({ top: -this.scrollY }) // 通过负边距实现滚动效果}.width(‘100%’).clip(true) // 裁剪超出部分}}实现新闻内容Web组件,关键点是通过.scrollable(false)禁用Web组件自身的滚动,通过负边距实现滚动效果。步骤4:实现评论区List组件// CommentListComponent.ets - 评论区组件@Componentexport struct CommentListComponent {@Prop comments: CommentItem[] = [];@Prop scrollY: number = 0;@Prop onSizeChange?: (height: number) => void;@State listHeight: number = 0;private listController: ListController = new ListController();// 计算列表总高度calculateListHeight(): number {const itemHeight = 100; // 每个评论项预估高度const spacing = 8; // 间距return this.comments.length * (itemHeight + spacing);}aboutToAppear() {this.listHeight = this.calculateListHeight();this.onSizeChange?.(this.listHeight);}aboutToUpdate() {const newHeight = this.calculateListHeight();if (newHeight !== this.listHeight) {this.listHeight = newHeight;this.onSizeChange?.(newHeight);}}@BuilderbuildCommentItem(comment: CommentItem) {Column({ space: 8 }) {// 用户信息Row({ space: 12 }) {Image(comment.userAvatar).width(32).height(32).borderRadius(16).objectFit(ImageFit.Cover) Column({ space: 4 }) { Text(comment.userName) .fontSize(14) .fontColor('#000000') .fontWeight(FontWeight.Medium) Text(this.formatTime(comment.publishTime)) .fontSize(12) .fontColor('#999999') } .layoutWeight(1) // 点赞按钮 Button('点赞') .fontSize(12) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(comment.isLiked ? '#FF3B30' : '#F0F0F0') .fontColor(comment.isLiked ? '#FFFFFF' : '#666666') } .width('100%') // 评论内容 Text(comment.content) .fontSize(14) .fontColor('#333333') .lineHeight(20) .textAlign(TextAlign.Start) // 操作栏 Row({ space: 16 }) { Text(`${comment.likeCount} 点赞`) .fontSize(12) .fontColor('#666666') Text(`${comment.replyCount} 回复`) .fontSize(12) .fontColor('#666666') Text('回复') .fontSize(12) .fontColor('#0066FF') } .width('100%') .margin({ top: 8 }) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .border({ width: { bottom: 1 }, color: '#F0F0F0' })}private formatTime(timestamp: number): string {const now = Date.now();const diff = now - timestamp;if (diff < 60000) { return '刚刚'; } else if (diff < 3600000) { return `${Math.floor(diff / 60000)}分钟前`; } else if (diff < 86400000) { return `${Math.floor(diff / 3600000)}小时前`; } else { const date = new Date(timestamp); return `${date.getMonth() + 1}-${date.getDate()}`; }}build() {Column() {// 评论标题Row() {Text(‘评论’).fontSize(18).fontColor(‘#000000’).fontWeight(FontWeight.Bold) Blank() Text(`${this.comments.length}条`) .fontSize(14) .fontColor('#666666') } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') // 评论列表 - 禁用滚动 List({ space: 8, controller: this.listController }) { ForEach(this.comments, (comment: CommentItem) => { ListItem() { this.buildCommentItem(comment) } }, (comment: CommentItem) => comment.id) } .width('100%') .height(this.listHeight) .scrollable(false) // 关键:禁用List组件自身的滚动 .margin({ top: -this.scrollY }) // 通过负边距实现滚动效果 } .width('100%') .clip(true) // 裁剪超出部分}}实现评论区List组件,同样通过.scrollable(false)禁用自身滚动,通过负边距实现滚动效果。步骤5:实现主页面容器// ContainerNestedScrollPage.ets - 主页面@Entry@Componentstruct ContainerNestedScrollPage {@State newsData: NewsItem = {id: ‘1’,title: ‘国足对阵印尼67年不败金身若被破,主教练伊万科维奇很可能会就此下课’,content: ‘对于今晚国足主场对阵印尼的18强赛第4轮比赛,上观新闻发文进行了分析,认为国足对阵印尼67年不败金身若被破,主教练伊万科维奇很可能会就此下课。10月15日,青岛青春足球场,国足将迎来关键一战…’,source: ‘上观新闻’,publishTime: Date.now() - 3600000,viewCount: 15432,likeCount: 887,shareCount: 245,isLiked: false,isFavorited: false};@State comments: CommentItem[] = [];@State scrollPosition: ScrollPosition = {webScrollY: 0,listScrollY: 0,totalScrollY: 0,currentSection: ‘web’,isScrolling: false};@State webHeight: number = 800;@State listHeight: number = 1200;@State screenHeight: number = 800;private scrollController: NestScrollController = new NestScrollController();private mainScrollController: Scroller = new Scroller();aboutToAppear() {this.loadComments();this.initializeScrollController();}// 加载评论数据loadComments() {// 模拟评论数据this.comments = Array.from({ length: 20 }, (_, index) => ({id: comment_${index},userId: user_${index},userName: 用户${index + 1},userAvatar: https://example.com/avatar/${index}.jpg,content: 这是第${index + 1}条评论,对新闻内容发表了自己的看法。,publishTime: Date.now() - Math.random() * 86400000,likeCount: Math.floor(Math.random() * 100),replyCount: Math.floor(Math.random() * 10),isLiked: Math.random() > 0.5}));}// 初始化滚动控制器initializeScrollController() {this.scrollController.initializeMetrics({webHeight: this.webHeight,listHeight: this.listHeight,screenHeight: this.screenHeight,headerHeight: 60});// 监听滚动状态变化 this.scrollController.addScrollListener((position: ScrollPosition) => { this.scrollPosition = position; });}// 处理滚动事件onScroll(event: ScrollEvent) {const scrollY = event.offsetY;const newPosition = this.scrollController.handleScroll(scrollY);// 更新UI状态 this.scrollPosition = newPosition; // 滚动到边界处理 this.handleScrollBoundary(newPosition);}// 处理滚动边界handleScrollBoundary(position: ScrollPosition) {const { webHeight, listHeight, screenHeight } = this.scrollController.getMetrics();const { totalScrollY, currentSection } = position;// 滚动到Web组件底部,准备进入List组件 if (currentSection === 'web' && totalScrollY >= webHeight - screenHeight / 2) { console.info('即将进入评论区'); } // 滚动到List组件顶部,准备返回Web组件 if (currentSection === 'list' && totalScrollY <= webHeight + 50) { console.info('即将返回新闻内容'); }}build() {Column() {// 顶部标题栏this.buildHeader() // 主滚动容器 Scroll(this.mainScrollController) { Column() { // 新闻内容区域 NewsWebComponent({ content: this.newsData, scrollY: this.scrollPosition.webScrollY, onSizeChange: (height: number) => { this.webHeight = height; this.scrollController.updateWebHeight(height); } }) .width('100%') .height(this.webHeight) // 评论区区域 CommentListComponent({ comments: this.comments, scrollY: this.scrollPosition.listScrollY, onSizeChange: (height: number) => { this.listHeight = height; this.scrollController.updateListHeight(height); } }) .width('100%') .height(this.listHeight) } .width('100%') } .width('100%') .height('100%') .scrollBar(BarState.Off) // 隐藏滚动条 .onScroll((offset: ScrollEvent) => { this.onScroll(offset); }) .onScrollFrameBegin((offset: number, state: ScrollState) => { // 控制滚动行为 return this.handleScrollFrameBegin(offset, state); }) // 底部操作栏 this.buildBottomBar() } .width('100%') .height('100%') .backgroundColor('#F8F8F8') .onAreaChange((oldValue, newValue) => { // 更新屏幕高度 this.screenHeight = newValue.height; this.scrollController.updateScreenHeight(newValue.height); })}@BuilderbuildHeader() {Row({ space: 12 }) {Button().width(32).height(32).borderRadius(16).backgroundColor(‘#F0F0F0’).onClick(() => {// 返回}) Text('新闻详情') .fontSize(18) .fontColor('#000000') .fontWeight(FontWeight.Bold) .layoutWeight(1) Button() .width(32) .height(32) .borderRadius(16) .backgroundColor('#F0F0F0') .onClick(() => { // 分享 }) } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') .border({ width: { bottom: 1 }, color: '#EEEEEE' })}@BuilderbuildBottomBar() {Row({ space: 20 }) {// 关注按钮Column({ space: 4 }) {Image($r(‘app.media.ic_follow’)).width(20).height(20) Text('关注') .fontSize(10) .fontColor('#666666') } .onClick(() => { this.newsData.isFavorited = !this.newsData.isFavorited; }) // 搜索按钮 Column({ space: 4 }) { Image($r('app.media.ic_search')) .width(20) .height(20) Text('搜索') .fontSize(10) .fontColor('#666666') } .onClick(() => { // 搜索功能 }) // 直播按钮 Column({ space: 4 }) { Image($r('app.media.ic_live')) .width(20) .height(20) Text('直播') .fontSize(10) .fontColor('#666666') } .onClick(() => { // 直播功能 }) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .border({ width: { top: 1 }, color: '#EEEEEE' })}// 处理滚动帧开始事件handleScrollFrameBegin(offset: number, state: ScrollState): { offsetRemain: number } {// 可以根据需要调整滚动行为return { offsetRemain: offset };}}实现主页面容器,集成Web新闻组件和List评论组件,通过统一的Scroll容器管理所有滚动事件。步骤6:实现使用示例页面// ContainerNestedSlidePage.ets - 示例页面@Entry@Componentstruct ContainerNestedSlidePage {@State currentTab: ‘overview’ | ‘code’ | ‘issues’ | ‘pr’ = ‘code’;@State showPreview: boolean = true;build() {Column() {// 顶部状态栏this.buildStatusBar() // 页面标题 this.buildPageHeader() // 标签页 this.buildTabs() // 内容区域 Scroll() { Column() { if (this.currentTab === 'overview') { this.buildOverviewContent() } else if (this.currentTab === 'code') { this.buildCodeContent() } if (this.showPreview) { this.buildPreviewSection() } } .width('100%') } .scrollBar(BarState.Off) } .width('100%') .height('100%') .backgroundColor('#FFFFFF')}@BuilderbuildStatusBar() {Row() {Text(‘04:15’).fontSize(16).fontColor(‘#000000’).fontWeight(FontWeight.Medium) Blank() Row({ space: 4 }) { Image($r('app.media.ic_signal')) .width(16) .height(16) Image($r('app.media.ic_wifi')) .width(16) .height(16) Text('77%') .fontSize(14) .fontColor('#000000') } } .width('100%') .padding({ left: 20, right: 20, top: 12, bottom: 12 }) .backgroundColor('#F8F8F8')}@BuilderbuildPageHeader() {Column({ space: 4 }) {Text(‘Scroll容器嵌套多种组件事件处理案例’).fontSize(18).fontColor(‘#000000’).fontWeight(FontWeight.Bold) Text('HarmonyOS-Cases/Cases') .fontSize(12) .fontColor('#666666') } .width('100%') .padding(20) .backgroundColor('#FFFFFF') .border({ width: { bottom: 1 }, color: '#EEEEEE' })}@BuilderbuildTabs() {const tabs = [{ key: ‘overview’, label: ‘概览’, count: null },{ key: ‘code’, label: ‘代码’, count: 50 },{ key: ‘issues’, label: ‘Issues’, count: 15 },{ key: ‘pr’, label: ‘Pull Requests’, count: 3 }];Row() { ForEach(tabs, (tab) => { Column() { Text(tab.label) .fontSize(this.currentTab === tab.key ? 16 : 14) .fontColor(this.currentTab === tab.key ? '#0066FF' : '#666666') .fontWeight(this.currentTab === tab.key ? FontWeight.Medium : FontWeight.Normal) if (tab.count !== null) { Text(tab.count.toString()) .fontSize(10) .fontColor('#FFFFFF') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .backgroundColor('#0066FF') .borderRadius(10) .margin({ top: 4 }) } } .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .onClick(() => { this.currentTab = tab.key; }) }) } .width('100%') .border({ width: { bottom: 1 }, color: '#EEEEEE' })}@BuilderbuildOverviewContent() {Column({ space: 16 }) {Text(‘# Scroll容器嵌套多种组件事件处理案例’).fontSize(20).fontColor(‘#000000’).fontWeight(FontWeight.Bold) Text('## 介绍') .fontSize(18) .fontColor('#000000') .fontWeight(FontWeight.Medium) Text('本示例适用于Scroll容器嵌套多组件事件处理场景:当需要一个父容器Scroll内嵌套web、List,当父子的滚动手势冲突时,此时希望父容器的滚动优先级最高,即实现子组件的偏移量都由父容器统一派发,实现滚动任一子组件流畅滚动到父容器顶/底的效果。例如本案例的新闻浏览界面,父组件Scroll嵌套了新闻内容与评论区(Web实现新闻内容,List实现评论区),通过禁用web和list组件滚动手势,再由父组件Scroll统一计算派发偏移量,达到一种web的滚动和list组件滚动能无缝衔接,像同一个滚动组件滚动效果。') .fontSize(14) .fontColor('#333333') .lineHeight(20) } .width('100%') .padding(20)}@BuilderbuildCodeContent() {Column({ space: 12 }) {Row() {Text(‘cases / CommonAppDevelopment / feature / containernestedslide / README.md’).fontSize(12).fontColor(‘#666666’).layoutWeight(1) Image($r('app.media.ic_copy')) .width(16) .height(16) } .width('100%') .padding(12) .backgroundColor('#F8F8F8') .borderRadius(8) // 代码内容 Column({ space: 8 }) { Text('# Scroll容器嵌套多种组件事件处理案例') .fontSize(16) .fontColor('#000000') .fontWeight(FontWeight.Bold) Text('## 介绍') .fontSize(14) .fontColor('#000000') .fontWeight(FontWeight.Medium) Text('本示例适用于Scroll容器嵌套多组件事件处理场景...') .fontSize(12) .fontColor('#333333') .lineHeight(18) Text('## 效果图预览') .fontSize(14) .fontColor('#000000') .fontWeight(FontWeight.Medium) .margin({ top: 16 }) } .width('100%') .padding(20) }}@BuilderbuildPreviewSection() {Column({ space: 16 }) {Row() {Text(‘效果图预览’).fontSize(16).fontColor(‘#000000’).fontWeight(FontWeight.Medium) Blank() Button(this.showPreview ? '收起' : '展开') .fontSize(12) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor('#F0F0F0') .onClick(() => { this.showPreview = !this.showPreview; }) } .width('100%') if (this.showPreview) { // 预览容器 Column() { // 模拟新闻预览界面 this.buildNewsPreview() } .width('100%') .border({ width: 1, color: '#EEEEEE' }) .borderRadius(8) } } .width('100%') .padding(20)}@BuilderbuildNewsPreview() {Column() {// 顶部操作栏Row() {Text(‘+关注’).fontSize(14).fontColor(‘#0066FF’) Blank() Row({ space: 8 }) { Image($r('app.media.ic_search_small')) .width(16) .height(16) Text('搜你想看的') .fontSize(12) .fontColor('#999999') } .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor('#F8F8F8') .borderRadius(16) .layoutWeight(1) Text('听') .fontSize(14) .fontColor('#0066FF') .margin({ left: 8 }) } .width('100%') .padding(12) .border({ width: { bottom: 1 }, color: '#EEEEEE' }) // 新闻内容 Column({ space: 12 }) { Text('直播吧10月15日讯 对于今晚国足主场对阵印尼的18强赛第4轮比赛,上观新闻发文进行了分析,认为国足对阵印尼67年不败金身若被破,主教练伊万科维奇很可能会就此下课。') .fontSize(14) .fontColor('#000000') .lineHeight(20) Text('10月15日,青岛青春足球场,国足将迎来关键一战。这场比赛对于国足来说至关重要,不仅关系到出线形势,也关系到主教练伊万科维奇的帅位。') .fontSize(14) .fontColor('#000000') .lineHeight(20) } .width('100%') .padding(12) }}}总结实现成果通过以上解决方案,我们完整实现了Scroll容器嵌套多种组件的事件处理方案:统一滚动控制:父Scroll容器统一管理所有滚动事件手势冲突解决:通过禁用子组件滚动,解决手势冲突问题无缝滚动体验:实现从Web组件到List组件的平滑过渡性能优化:避免不必要的重绘和布局计算
-
鸿蒙深色模式开发指导在鸿蒙应用开发中,用户希望在不同光照环境下获得舒适的视觉体验。系统提供了深色模式(Dark Mode)方案一:标准化资源管理步骤1:创建分层颜色资源文件<!-- resources/base/element/color.json -->{ "color": { "primary_blue": "#007DFF", "primary_blue_dark": "#5C9EFF", "text_primary": "#182431", "text_primary_dark": "#E6FFFFFF", "background_primary": "#FFFFFF", "background_primary_dark": "#0C0C0C" }}步骤2:定义语义颜色映射<!-- resources/base/element/semantic_colors.json -->{ "semantic": { "color_bg_primary": { "light": "$color:background_primary", "dark": "$color:background_primary_dark" }, "color_text_primary": { "light": "$color:text_primary", "dark": "$color:text_primary_dark" } }}步骤3:组件级颜色配置<!-- resources/base/element/component_colors.json -->{ "component": { "button_primary": { "background": "$semantic:color_primary", "text": "$semantic:color_text_on_primary" }, "card_background": { "light": "$color:background_card_light", "dark": "$color:background_card_dark" } }}方案二:动态主题切换实现步骤1:创建主题管理器// utils/ThemeManager.etsimport configuration from '@ohos.application.Configuration';import common from '@ohos.app.ability.common';export class ThemeManager { private static instance: ThemeManager; private currentTheme: AppTheme = AppTheme.AUTO; // 主题枚举 export enum AppTheme { LIGHT = 'light', DARK = 'dark', AUTO = 'auto' } // 单例模式 static getInstance(): ThemeManager { if (!ThemeManager.instance) { ThemeManager.instance = new ThemeManager(); } return ThemeManager.instance; } // 初始化监听系统主题变化 init(context: common.UIAbilityContext): void { const config = configuration.getConfiguration(); this.handleConfigurationUpdate(config); // 监听系统配置变化 configuration.on('configurationUpdate', (config: configuration.Configuration) => { this.handleConfigurationUpdate(config); }); } // 处理配置更新 private handleConfigurationUpdate(config: configuration.Configuration): void { const colorMode = config.colorMode; if (this.currentTheme === AppTheme.AUTO) { const isDarkMode = colorMode === configuration.ColorMode.COLOR_MODE_DARK; this.applyTheme(isDarkMode ? AppTheme.DARK : AppTheme.LIGHT); } } // 应用主题 applyTheme(theme: AppTheme): void { this.currentTheme = theme; // 更新所有已注册的监听器 this.notifyThemeChange(theme); // 持久化存储用户选择 this.saveThemePreference(theme); } // 获取当前主题 getCurrentTheme(): AppTheme { return this.currentTheme; } // 判断是否为深色模式 isDarkMode(): boolean { if (this.currentTheme === AppTheme.AUTO) { const config = configuration.getConfiguration(); return config.colorMode === configuration.ColorMode.COLOR_MODE_DARK; } return this.currentTheme === AppTheme.DARK; }}步骤2:创建主题适配组件// components/ThemeWrapper.ets@Componentexport struct ThemeWrapper { @State currentTheme: ThemeManager.AppTheme = ThemeManager.AppTheme.AUTO; private themeManager = ThemeManager.getInstance(); aboutToAppear(): void { this.currentTheme = this.themeManager.getCurrentTheme(); this.themeManager.addThemeChangeListener((theme) => { this.currentTheme = theme; }); } build() { Column() { // 子组件 this.ContentSlot() } .width('100%') .height('100%') .backgroundColor(this.getBackgroundColor()) } @Builder ContentSlot() { // 插槽内容 } // 根据主题获取颜色 private getBackgroundColor(): ResourceColor { return this.currentTheme === ThemeManager.AppTheme.DARK ? $r('app.color.background_dark') : $r('app.color.background_light'); }}步骤3:在EntryAbility中初始化// entryability/EntryAbility.etsimport ThemeManager from '../utils/ThemeManager';export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { // 初始化主题管理器 ThemeManager.getInstance().init(this.context); }}方案三:组件级适配规范通用组件适配示例:// components/AdaptiveCard.ets@Componentexport struct AdaptiveCard { @Prop title: string = ''; @Prop content: string = ''; private themeManager = ThemeManager.getInstance(); build() { Column() { // 标题 Text(this.title) .fontColor(this.getTextColor('primary')) .fontSize(18) .fontWeight(FontWeight.Medium) .margin({ top: 12, bottom: 8 }) // 内容 Text(this.content) .fontColor(this.getTextColor('secondary')) .fontSize(14) .margin({ bottom: 12 }) } .width('100%') .padding(16) .backgroundColor(this.getCardBackground()) .borderRadius(8) .shadow(this.getShadowStyle()) } // 根据主题获取文本颜色 private getTextColor(type: 'primary' | 'secondary'): ResourceColor { const isDark = this.themeManager.isDarkMode(); if (type === 'primary') { return isDark ? $r('app.color.text_primary_dark') : $r('app.color.text_primary_light'); } else { return isDark ? $r('app.color.text_secondary_dark') : $r('app.color.text_secondary_light'); } } // 获取卡片背景 private getCardBackground(): ResourceColor { return this.themeManager.isDarkMode() ? $r('app.color.background_card_dark') : $r('app.color.background_card_light'); } // 获取阴影样式 private getShadowStyle(): ShadowStyle { return this.themeManager.isDarkMode() ? { radius: 8, color: Color.Black, offsetX: 0, offsetY: 2 } : { radius: 12, color: '#1A000000', offsetX: 0, offsetY: 4 }; }}方案四:深色模式测试方案步骤1:创建测试工具类// test/ThemeTestUtils.etsexport class ThemeTestUtils { // 切换主题并等待渲染完成 static async switchThemeAndWait( theme: ThemeManager.AppTheme, timeout: number = 500 ): Promise<void> { ThemeManager.getInstance().applyTheme(theme); await new Promise(resolve => setTimeout(resolve, timeout)); } // 验证元素颜色 static verifyElementColor( element: any, expectedColorKey: string, theme: ThemeManager.AppTheme ): boolean { const expectedColor = this.getExpectedColor(expectedColorKey, theme); const actualColor = this.getElementColor(element); return this.colorsMatch(expectedColor, actualColor); } // 生成主题测试用例 static generateThemeTestCases() { return [ { name: '浅色模式基础测试', theme: ThemeManager.AppTheme.LIGHT, assertions: [ { element: 'background', colorKey: 'background_primary' }, { element: 'text_primary', colorKey: 'text_primary' } ] }, { name: '深色模式基础测试', theme: ThemeManager.AppTheme.DARK, assertions: [ { element: 'background', colorKey: 'background_primary_dark' }, { element: 'text_primary', colorKey: 'text_primary_dark' } ] } ]; }} 实施效果1. 工具库theme-utils/├── ThemeManager.ets # 主题管理核心类├── ThemeWrapper.ets # 主题包装组件├── ColorParser.ets # 颜色解析工具└── ThemeTestUtils.ets # 测试工具类2. 资源模板resources/├── base/│ ├── element/│ │ ├── colors.json # 基础色值│ │ ├── semantic_colors.json # 语义颜色│ │ └── component_colors.json # 组件颜色│ └── media/ # 图片资源(深色/浅色版本)└── dark/ # 深色模式覆盖资源3. 最佳实践文档《鸿蒙深色模式设计规范》《组件适配checklist》《主题切换性能优化指南》《深色模式测试用例模板》
上滑加载中
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签