-
1.问题说明:Flutter 原生网络请求很费劲,想封装一个基础网络请求单例全局使用2.原因分析:目前Flutter使用最多、最流行的网络请求框架是dio: ^5.9.03.解决思路:在Flutter项目中的pubspec.yaml文件中,导入 dio: ^5.9.0,创建网络请求单例使用dio,封装请求方法4.解决方案:一、导入dio: ^5.9.0,在Flutter项目中的pubspec.yaml文件中dependencies: flutter: sdk: flutter dio: ^5.9.0二、创建网络请求单例,BaseRequestimport 'dart:convert';import 'package:dio/dio.dart';import 'package:flutter/cupertino.dart';import 'base_response.dart';void LogDebug(String message) { debugPrint("[DEBUG] $message");}class BaseRequest { static final BaseRequest _instance = BaseRequest._internal(); factory BaseRequest() => _instance; BaseRequest._internal() { _initDio(); } late Dio _dio; static const String baseUrl = "https://www.baidu.com/"; void _initDio() { _dio = Dio(); _dio.options.baseUrl = baseUrl; _dio.options.connectTimeout = const Duration(seconds: 10); _dio.options.receiveTimeout = const Duration(seconds: 10); _dio.options.headers["Content-Type"] = "application/json"; _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { // 用户登录接口返回的token final token = ''; if (token != null) { options.headers["Authorization"] = "Bearer $token"; } // 判断是否是上传文件接口,修改请求头 final isUpload = options.headers.containsKey("isUpload") ? options.headers["isUpload"] as bool : false; if (isUpload) { options.headers["Content-Type"] = 'multipart/form-data'; } return handler.next(options); }, onResponse: (response, handler) { LogDebug("requestUrl: ${response.requestOptions.uri}"); LogDebug( "requestHeaders: ${json.encode(response.requestOptions.headers)}"); LogDebug("requestBody: ${json.encode(response.requestOptions.data)}"); LogDebug( "requestParams: ${json.encode(response.requestOptions.queryParameters)}"); LogDebug("responseData: ${json.encode(response.data)}"); // 相应数据转全局基础BaseResponse对象(Model) final baseResponse = BaseResponse.fromJson(response.data as Map<String, dynamic>); response.data = baseResponse; return handler.next(response); }, onError: (DioException e, handler) { LogDebug("requestError: ${e.message}"); return handler.next(e); }, )); } // PUT 请求 Future<BaseResponse?> putRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.put( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("PUT 失败: $e"); return null; } } // GET 请求 Future<BaseResponse?> getRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.get( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("GET 失败: $e"); return null; } } // POST 请求 Future<BaseResponse?> postRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.post( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("POST 失败: $e"); return null; } } // 文件上传 Future<BaseResponse?> postFileUpload( String path, { required FormData formData, }) async { try { final response = await _dio.post( path, data: formData, options: Options(headers: {"isUpload": true}), ); return response.data as BaseResponse; } catch (e) { LogDebug("POST Upload 失败: $e"); return null; } }}三、全局基础相应Model,BaseResponseclass BaseResponse<T> { int? timestamp; int? code; String? msg; T? data; Map<String, dynamic>? errorData; bool? success; BaseResponse({ this.timestamp, this.code, this.msg, this.data, this.errorData, this.success, }); factory BaseResponse.fromJson(Map<String, dynamic> json) { return BaseResponse( timestamp: json['timestamp'] as int?, code: json['code'] as int?, msg: json['msg'] as String?, data: json['data'] as T?, errorData: json['errorData'] as Map<String, dynamic>?, success: json['success'] as bool?, ); } Map<String, dynamic> toJson() { final data = <String, dynamic>{}; data['timestamp'] = timestamp; data['code'] = code; data['msg'] = msg; data['data'] = this.data; data['errorData'] = errorData; data['success'] = success; return data; }}四,请求类Api:RequestApiimport 'dart:io';import 'package:dio/dio.dart';import '../base/base_request.dart';import '../base/base_response.dart';class RequestApi { static final BaseRequest _baseRequest = BaseRequest(); // put请求传requestBody static Future<BaseResponse?> putBody( Map<String, dynamic> body, ) async { return await _baseRequest.putRequest( "", // 接口相对路径 data: body, ); } // put请求传queryParams static Future<BaseResponse?> putParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.putRequest( "", // 接口相对路径 queryParams: queryParams, ); } // get请求传requestBody static Future<BaseResponse?> getBody( Map<String, dynamic> body, ) async { return await _baseRequest.getRequest( "", // 接口相对路径 data: body, ); } // get请求传queryParams static Future<BaseResponse?> getParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.getRequest( "", // 接口相对路径 queryParams: queryParams, ); } // post请求传requestBody static Future<BaseResponse?> postBody( Map<String, dynamic> body, ) async { return await _baseRequest.postRequest( "", // 接口相对路径 data: body, ); } // post请求传queryParams static Future<BaseResponse?> postParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.postRequest( "", // 接口相对路径 queryParams: queryParams, ); } // post上传文件传formData static Future<BaseResponse?> postFileUpload( String filePath, ) async { File fileClass = File(filePath); String fileName = fileClass.path.split('/').last; MultipartFile multipartFile = await MultipartFile.fromFile( filePath, filename: fileName, contentType: DioMediaType.parse("application/octet-stream"), ) as MultipartFile; // 具体的文件类型,请根据自己公司要求来 FormData formData = FormData.fromMap({ 'file': multipartFile, 'type': '0', }); return await _baseRequest.postFileUpload( "", // 接口相对路径 formData: formData, ); }}五、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1.1问题说明在鸿蒙应用开发中,实现图片水印功能时面临以下核心问题:如何在鸿蒙生态下高效加载图片资源并转换为可编辑的像素地图(PixelMap),确保图片处理的基础能力;如何在图片上绘制文字水印,并支持水印位置的实时拖拽调整,兼顾交互性与显示准确性;如何处理不同设备的分辨率差异,保证水印在各类设备上的显示效果一致;如何安全、合规地将带水印的图片保存到系统媒体库,同时避免资源泄露。1.2原因分析(一)图片处理依赖鸿蒙自带工具:加载图片、创建可编辑的图片源、生成能修改的图片格式等操作,都要用到鸿蒙自带的工具。如果对这些工具的加载顺序(比如没等图片加载完就操作)或参数设置(比如图片尺寸配置)处理不好,就可能导致图片加载失败,或者没法添加水印。(二)拖动水印时需要频繁重绘图片:当拖动水印时,程序会实时更新水印的位置,并重新生成带水印的图片。每次拖动都要重新画一遍原图和水印,如果设备性能一般,或者画图的逻辑不够高效,就可能让界面变卡,拖动起来不顺畅。(三)不同设备适配要靠单位转换:给图片加水印时,需要通过系统工具把图片尺寸、水印位置转换成适合当前设备的单位,还要获取屏幕宽度来计算缩放比例。如果这些转换工具没准备好(比如工具未正确设置就强制使用)、转换逻辑出错,或者拿不到屏幕信息,水印就会出现位置偏斜、大小不对的问题。1.3解决思路(一)图片资源处理:借助鸿蒙的图片处理工具,将原始图片资源转换成可编辑的格式(包含图片本身及尺寸信息)。具体来说,先获取图片的原始数据,再生成可编辑的图片源,同时记录图片的宽高,为后续添加水印打好基础,避免因格式不兼容或信息缺失导致水印无法添加。(二)水印绘制与交互:采用分层绘制的方式,先画原始图片作为背景,再在上面绘制水印文字。通过监听触摸动作,实时更新水印的位置坐标,并用状态管理工具记录这些坐标变化,确保每次位置变动后能及时重新绘制带水印的图片,让拖拽调整的操作更流畅。(三)设备适配处理:利用系统自带的单位转换工具(如将物理像素转为虚拟像素),结合当前设备的屏幕信息(如屏幕宽度),计算出合适的缩放比例。通过这种方式,让水印的大小和位置在不同屏幕的设备上都能保持一致,避免出现偏移或失真。1.4解决方案(一)加载图片并转换为可编辑格式,通过专门的工具函数处理图片资源:先借助系统资源管理器,获取原始图片的二进制数据;用图片处理工具将这些数据转换成 “可编辑图片源”,并读取图片的宽度和高度;最终生成包含可编辑图片、宽高信息的结构化数据,存在组件中备用,为后续添加水印提供基础素材。(二)绘制水印并支持拖动调整,用分层绘制的方式添加水印,并通过交互逻辑实现位置调整:用后台绘图工具先画原始图片作为底层,再在上面画水印文字;提前设置水印的样式(比如文字大小、颜色、对齐方式),并结合设备屏幕信息,自动适配不同设备的显示比例;在组件中用状态管理工具记录水印的位置,监听触摸拖动动作:每次拖动时,实时更新水印的 X、Y 坐标,同时重新调用绘图工具生成新的带水印图片,让界面及时刷新,实现 “拖到哪,水印就显示在哪” 的效果。(三)保存带水印的图片到相册,通过系统工具完成图片保存,同时做好资源管理:先用相册管理工具在手机相册里创建一个新的图片文件,拿到保存路径;用图片打包工具将带水印的可编辑图片,转换成适合保存的格式(比如 PNG);用文件操作工具打开刚创建的文件,把转换好的图片数据写进去,写完后不管有没有出错,都强制关闭文件,避免占用设备资源。(四)统一管理功能和状态,通过控制器封装核心功能,确保组件运行稳定:专门设计一个 “水印控制器”,把添加水印、重置位置、获取带水印图片等功能集中起来,对外提供简单的操作接口;在组件初始化时,就把控制器和界面上下文(比如设备显示信息)绑定好,确保原始图片加载、水印位置等状态能实时同步;用状态管理工具统一记录图片、水印位置等关键数据,避免因数据没准备好就操作而出现错误。图片水印组件代码示例:import { image } from '@kit.ImageKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { addWatermark, ImagePixelMap, imageSource2PixelMap } from './Utils'; const TAG = 'WatermarkComponent'; export class WatermarkController { setUIContext = (uiContext: UIContext) => { } addWatermark = () => { } resetWatermarkPosition = () => { } getWatermarkedPixelMap = (): image.PixelMap | null => { return null; } } @Component export struct WatermarkComponent { watermarkController: WatermarkController = new WatermarkController(); // 组件入参 @Prop bgImage: Resource; // 背景图片资源 @Prop watermarkText: string; // 水印文字 @Prop watermarkSize: number = 16; // 水印文字大小 @Prop watermarkColor: string = '#A2ffffff'; // 水印颜色 @Prop initialX: number = 0; // 初始X坐标 @Prop initialY: number = 50; // 初始Y坐标 // 内部状态 @State addedWatermarkPixelMap: image.PixelMap | null = null; @State watermarkX: number = 0; @State watermarkY: number = 0; @State isDragging: boolean = false; private originalImagePixelMap: ImagePixelMap | null = null; private uiContext: UIContext | null = null; aboutToAppear() { this.watermarkX = this.initialX; this.watermarkY = this.initialY; this.watermarkController.setUIContext = (uiContext: UIContext) => { this.setUIContext(uiContext); }; this.watermarkController.addWatermark = () => { this.addWatermark(); }; this.watermarkController.resetWatermarkPosition = () => { this.resetWatermarkPosition(); }; this.watermarkController.getWatermarkedPixelMap = (): image.PixelMap | null => { return this.getWatermarkedPixelMap(); }; } /** * 设置UIContext */ setUIContext(uiContext: UIContext) { this.uiContext = uiContext; } /** * 获取水印文字 */ private getWatermarkText(): string { return this.watermarkText; } /** * 从资源获取图片PixelMap */ async getImagePixelMap(): Promise<ImagePixelMap | undefined> { let result: ImagePixelMap | undefined = undefined; try { const data: Uint8Array = await this.uiContext?.getHostContext()?.resourceManager.getMediaContent(this.bgImage.id) as Uint8Array; const arrayBuffer: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteLength + data.byteOffset); const imageSource: image.ImageSource = image.createImageSource(arrayBuffer); result = await imageSource2PixelMap(imageSource); if (result) { this.originalImagePixelMap = result; } } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `getImagePixelMap failed code=${err.code}, message=${err.message}`); } return result; } /** * 处理触摸移动事件 */ private handleTouchMove(event: TouchEvent) { if (this.originalImagePixelMap && event.touches.length > 0) { const touch = event.touches[0]; this.watermarkX = touch.x; this.watermarkY = touch.y; // 重新生成带水印的图片 this.addedWatermarkPixelMap = addWatermark( this.originalImagePixelMap, this.getWatermarkText(), this.uiContext!, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } /** * 添加水印 */ async addWatermark(): Promise<void> { const imagePixelMap = await this.getImagePixelMap(); if (imagePixelMap && this.uiContext) { this.addedWatermarkPixelMap = addWatermark( imagePixelMap, this.getWatermarkText(), this.uiContext, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } /** * 获取带水印的PixelMap */ getWatermarkedPixelMap(): image.PixelMap | null { return this.addedWatermarkPixelMap; } /** * 重置水印位置 */ resetWatermarkPosition(): void { this.watermarkX = this.initialX; this.watermarkY = this.initialY; if (this.originalImagePixelMap) { this.addedWatermarkPixelMap = addWatermark( this.originalImagePixelMap, this.getWatermarkText(), this.uiContext!, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } build() { Column() { // 显示带水印的图片 Image(this.addedWatermarkPixelMap || this.bgImage) .width('100%') .onTouch((event: TouchEvent) => { if (event.type === TouchType.Move && this.addedWatermarkPixelMap) { this.handleTouchMove(event); } }) } .width('100%') } } 图片水印工具类代码示例:import { image } from '@kit.ImageKit'; import { fileIo } from '@kit.CoreFileKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { display } from '@kit.ArkUI'; import { Context } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'Utils'; let fd: number | null = null; export async function saveToFile(pixelMap: image.PixelMap, context: Context): Promise<void> { try { const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context); const filePath = await phAccessHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png'); const imagePacker = image.createImagePacker(); const imageBuffer = await imagePacker.packToData(pixelMap, { format: 'image/png', quality: 100 }); const mode = fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE; fd = (await fileIo.open(filePath, mode)).fd; await fileIo.truncate(fd); await fileIo.write(fd, imageBuffer); } catch (err) { hilog.error(0x0000, TAG, 'saveToFile error:', JSON.stringify(err) ?? ''); } finally { try { if (fd) { fileIo.close(fd); } } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `close failed code=${err.code}, message=${err.message}`); } } } export interface ImagePixelMap { pixelMap: image.PixelMap width: number height: number } export async function imageSource2PixelMap(imageSource: image.ImageSource): Promise<ImagePixelMap> { const imageInfo: image.ImageInfo = await imageSource.getImageInfo(); const height = imageInfo.size.height; const width = imageInfo.size.width; const options: image.DecodingOptions = { editable: true, desiredSize: { height, width } }; const pixelMap: image.PixelMap = await imageSource.createPixelMap(options); const result: ImagePixelMap = { pixelMap, width, height }; return result; } export function addWatermark( imagePixelMap: ImagePixelMap, text: string = 'watermark', uiContext: UIContext, x: number = 20, y: number = 20, fontSize: number = 16, color: string = '#A2ffffff' ): image.PixelMap { const height = uiContext.px2vp(imagePixelMap.height) as number; const width = uiContext.px2vp(imagePixelMap.width) as number; const offScreenCanvas = new OffscreenCanvas(width, height); const offScreenContext = offScreenCanvas.getContext('2d'); // 先绘制原始图片 offScreenContext.drawImage(imagePixelMap.pixelMap, 0, 0, width, height); // 设置水印样式 let displayWidth: number = 0; try { displayWidth = display.getDefaultDisplaySync().width; } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `failed code=${err.code}, message=${err.message}`); } const vpWidth = uiContext?.px2vp(displayWidth) ?? displayWidth; const imageScale = width / vpWidth; offScreenContext.textBaseline = 'top' offScreenContext.textAlign = 'left'; offScreenContext.fillStyle = color; offScreenContext.font = fontSize * imageScale + 'vp'; // 使用传入的坐标绘制水印 offScreenContext.fillText(text, x, y); return offScreenContext.getPixelMap(0, 0, width, height); } 演示代码示例:import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { saveToFile } from './Utils'; import { WatermarkComponent, WatermarkController } from './WatermarkComponent'; const TAG = 'Index'; @Entry @Component struct Index { static readonly TOAST_DURATION: number | undefined = 3000; watermarkController: WatermarkController = new WatermarkController(); showSuccess() { try { this.getUIContext().getPromptAction().showToast({ message: $r('app.string.message_save_success'), duration: Index.TOAST_DURATION }); } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `showToast failed code=${err.code}, message=${err.message}`); } } build() { Column() { // 水印组件 WatermarkComponent({ watermarkController: this.watermarkController, bgImage: $r('app.media.img1'), watermarkText: '水印的文案内容', watermarkSize: 22, watermarkColor: '#FFFFFFFF', initialX: 0, initialY: 0 }) .width('100%') .id("waterMark") .margin({ top: 16 }) .onAppear(() => { // 设置UIContext this.watermarkController.setUIContext(this.getUIContext()); }) // 操作按钮区域 Row() { Button('添加水印') .height(40) .width('45%') .margin({ right: 10 }) .onClick(async () => { await this.watermarkController.addWatermark(); }) Button('重置位置') .height(40) .width('45%') .onClick(() => { this.watermarkController.resetWatermarkPosition(); }) } .width('100%') .justifyContent(FlexAlign.Center) .margin({ top: 16 }) // 保存按钮 Row() { SaveButton() .height(40) .width('100%') .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => { if (result === SaveButtonOnClickResult.SUCCESS) { try { const watermarkedPixelMap = this.watermarkController.getWatermarkedPixelMap(); if (watermarkedPixelMap) { await saveToFile(watermarkedPixelMap, this.getUIContext().getHostContext() as common.UIAbilityContext); this.showSuccess(); } else { hilog.error(0x0000, TAG, 'No watermarked image to save'); } } catch (err) { hilog.error(0x0000, TAG, 'createAsset failed, error:', err); } } else { hilog.error(0x0000, TAG, 'SaveButtonOnClickResult createAsset failed'); } }) } .padding({ left: 16, right: 16, bottom: 16 }) .width('100%') } .width('100%') .height('100%') .justifyContent(FlexAlign.SpaceBetween) } } 1.5方案成果总结这个方案通过适配鸿蒙系统的特点,成功实现了图片水印的完整功能,效果如下:功能齐全:支持加载图片、添加水印、拖动调整位置、保存到相册等所有核心操作,满足用户对图片加水印的需求;适配各种设备:通过处理屏幕差异和单位转换,让水印在不同大小的鸿蒙设备上都显示准确,不会偏位或大小失真;操作流畅且安全:用后台绘图工具高效绘制图片,结合状态管理减少不必要的重复绘制;通过规范的资源释放流程,避免手机资源浪费和图片损坏;方便复用:打包的工具和控制器可以在其他功能模块里直接使用,降低开发成本。
-
1.1问题说明在做鸿蒙系统的应用时,很多场景需要用到签名功能(比如签电子合同、确认表单信息),但鸿蒙自带的开发工具里没有专门的签名工具,自己做的时候会遇到不少麻烦:一是手指 touch 屏幕时,画出来的线经常对不上位置,还容易断;二是画多笔后,想擦除或撤销最后一笔经常出问题;三是导出签名图片时,有的格式用不了,或者导出慢、画面卡;四是想改签名的样式(比如线的粗细、颜色、背景色)特别麻烦,得改很多代码;五是不知道签名什么时候开始、什么时候画完,没法跟其他功能(比如 “签名完才能提交”)联动。1.2原因分析(一)自带工具功能不够:鸿蒙的开发工具里只有基础的画图板功能,没有封装好的签名相关工具,比如怎么处理笔画、怎么导出图片,都得自己从零做,难度大。(二)触摸位置算不准:手机、平板的屏幕大小不一样,签名区域的实际显示大小和我们设置的大小可能不一样。如果没先弄清楚签名区域的真实位置(在哪块地方)和尺寸(宽高),直接用手指 touch 的原始位置来画,线就会偏到别的地方。(三)画面处理太卡:如果直接在正在显示的签名板上导出图片,容易让界面卡住;而且没找对画面处理的方法,导出的图片可能是空白的,或者显示不正常。(四)笔画数据没管好:画多笔后,这些笔画的保存、删除、恢复没有统一的记录方式,比如想擦除所有笔画却擦不干净,想撤销最后一笔却撤不了,操作起来乱糟糟的。1.3解决思路核心就是基于鸿蒙的基础画图板,做一个 “能随便调样式、在不同设备都能用、用着不卡” 的签名工具,具体办法如下:(一)统一记录方式:规定好 “每一笔的位置”“签名区域的大小”“导出图片的格式” 这些信息怎么记,让数据处理有条理,解决笔画管理乱的问题;(二)精准算位置:等签名区域显示出来后,先弄清楚它的真实位置和大小,再统一计算手指 touch 的位置,确保画的线和手指动的轨迹一致;(三)分开处理画面:用 “正在显示的画板实时画 + 临时画板专门导出图片” 的方式,不让界面卡住,导出图片也更快;(四)功能拆分开:把 “画所有笔画”“画正在画的笔画”“擦除笔画”“导出图片” 这些功能分开做,互不影响,出问题好调整;(五)灵活可调:开放一些设置项(比如签名区域大小、背景色、线的粗细),也能让人知道签名的状态(开始 / 画画中 / 结束),方便适配不同场景。1.4解决方案该自定义签名板组件以 “统一规范、灵活适配、高效稳定” 为核心,通过结构化设计与模块化实现满足鸿蒙应用的签名需求,具体如下:(一)整体设计规范:明确数据记录规则,统一笔画坐标(x/y 位置)、签名区域信息(位置、尺寸、就绪状态)的存储格式,限定 PNG、JPEG、WEBP 三种图片导出格式;同时开放灵活配置项,支持自定义签名区域大小、背景色(含透明)、线条粗细与颜色,默认参数保障基础适配性;并提供签名板就绪、签名开始 / 进行中 / 结束的状态监听机制,确保与外部业务的联动兼容性。(二)核心功能落地:先初始化平滑画线工具,用双列表分别管理已完成笔画和当前绘制笔画;绘制时采用 “全量绘制(统一样式后批量渲染历史笔画)+ 实时绘制(仅更新最新线段)” 结合的方式,兼顾显示效果与流畅度;笔画管理支持全量擦除、撤销最后一笔操作,同时可查询笔画数量和签名板空状态;图片导出通过临时画板离线处理,避免阻塞主界面,异常时及时反馈失败信息;触摸处理上,首次触摸获取签名区域真实参数,按 “按下(开始记录)、移动(实时绘制)、抬起(保存有效笔画)” 的逻辑响应动作,排除误触干扰,确保笔画与触摸轨迹精准匹配。1、组件化代码示例:interface DrawingPoint { xPos: number; yPos: number; } interface CanvasDimensions extends Area { hasBeenSetup: boolean; } type ImageOutputType = "image/png" | "image/jpeg" | "image/webp"; @Component export struct CustomSignaturePad { @Prop panelWidth: Length = "100%"; @Prop panelHeight: Length = "100%"; @Prop backgroundStyle: string = "#ffffff"; @Prop lineThickness: number = 2; @Prop inkColor: string = "#1a1a1a"; @Prop imageType: ImageOutputType = "image/png"; onPadReady: (signaturePad: CustomSignaturePad) => void = () => {}; onSignStart: () => void = () => {}; onSigning: () => void = () => {}; onSignFinish: () => void = () => {}; private graphicsConfig: RenderingContextSettings = new RenderingContextSettings(true); private drawingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.graphicsConfig); private savedStrokes: DrawingPoint[][] = []; private currentStroke: DrawingPoint[] = []; private canvasProps: CanvasDimensions = { position: { x: 0, y: 0 }, globalPosition: { x: 0, y: 0 }, width: 3200, height: 2400, hasBeenSetup: false }; private drawAllStrokes(ctx2D: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D = this.drawingContext) { let canvasWidth = Number(this.canvasProps.width); let canvasHeight = Number(this.canvasProps.height); ctx2D.lineCap = "round"; ctx2D.lineWidth = this.lineThickness; ctx2D.strokeStyle = this.inkColor; ctx2D.lineJoin = "round"; ctx2D.clearRect(0, 0, canvasWidth, canvasHeight); if (this.backgroundStyle !== "transparent") { ctx2D.fillStyle = this.backgroundStyle; ctx2D.fillRect(0, 0, canvasWidth, canvasHeight); } if (this.savedStrokes.length === 0) { return; } let signaturePath = new Path2D(); this.savedStrokes.forEach((stroke) => { if (stroke.length === 0) { return; } let firstPoint = stroke[0]; signaturePath.moveTo(firstPoint.xPos, firstPoint.yPos); stroke.forEach((point, idx) => { if (idx === 0) { return; } signaturePath.lineTo(point.xPos, point.yPos); }); }); ctx2D.stroke(signaturePath); } private drawCurrentStroke() { if (this.currentStroke.length === 0) { return; } let drawingContext = this.drawingContext; let strokeLength = this.currentStroke.length; let latestPoint = this.currentStroke[strokeLength - 1]; let previousPoint = this.currentStroke[strokeLength - 2] || latestPoint; drawingContext.beginPath(); drawingContext.moveTo(previousPoint.xPos, previousPoint.yPos); drawingContext.lineTo(latestPoint.xPos, latestPoint.yPos); drawingContext.stroke(); } public eraseAll() { this.savedStrokes = []; this.drawAllStrokes(); } public removeLastLine() { this.savedStrokes.pop(); this.drawAllStrokes(); } public exportSignature(callback: (imageData?: string) => void) { if (this.savedStrokes.length === 0) { callback(); return; } let canvasWidth = Number(this.canvasProps.width); let canvasHeight = Number(this.canvasProps.height); let tempCanvas = new OffscreenCanvas(canvasWidth, canvasHeight); let tempCtx = tempCanvas.getContext("2d", this.graphicsConfig); if (tempCtx) { this.drawAllStrokes(tempCtx); let resultImage = tempCtx.toDataURL(this.imageType); callback(resultImage); } else { callback(); } } public getStrokeCount(): number { return this.savedStrokes.length; } public isEmpty(): boolean { return this.savedStrokes.length === 0; } build() { Canvas(this.drawingContext) .width(this.panelWidth) .height(this.panelHeight) .backgroundColor("#f8f9fa") .borderRadius(8) .border({ width: 1, color: "#e9ecef" }) .onReady(() => { this.drawAllStrokes(); this.onPadReady(this); }) .onTouch((touchEvent) => { if (!this.canvasProps.hasBeenSetup) { let canvasArea = touchEvent.target.area; this.canvasProps.position = canvasArea.position; this.canvasProps.globalPosition = canvasArea.globalPosition; this.canvasProps.width = canvasArea.width; this.canvasProps.height = canvasArea.height; this.canvasProps.hasBeenSetup = true; } let touchData = touchEvent.touches[0]; switch (touchEvent.type) { case TouchType.Down: this.currentStroke = [{ xPos: touchData.x, yPos: touchData.y }]; this.drawCurrentStroke(); this.onSignStart(); break; case TouchType.Move: this.currentStroke.push({ xPos: touchData.x, yPos: touchData.y }); this.drawCurrentStroke(); this.onSigning(); break; case TouchType.Up: if (this.currentStroke.length > 1) { this.savedStrokes.push(this.currentStroke); } this.currentStroke = []; this.onSignFinish(); break; } }); } } 2、演示代码示例:import { promptAction } from '@kit.ArkUI'; import { CustomSignaturePad } from "./CustomSignaturePad"; @Entry @Component struct SignatureExample { @State mySignaturePad: CustomSignaturePad | null = null; @State signaturePreview: string = ''; @State strokeCount: number = 0; build() { Column() { Scroll() { Column() { // 标题区域 Row() { Text("电子签名板") .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor("#1a1a1a") } .width("100%") .justifyContent(FlexAlign.Center) .padding(10) // 签名区域 Row() { CustomSignaturePad({ panelWidth: "95%", panelHeight: 280, backgroundStyle: "#fefefe", lineThickness: 2.5, inkColor: "#2c3e50", onPadReady: (signaturePad: CustomSignaturePad) => { this.mySignaturePad = signaturePad; }, onSignFinish: () => { this.strokeCount = this.mySignaturePad?.getStrokeCount() || 0; } }) } .justifyContent(FlexAlign.Center) .margin({ bottom: 15 }) // 状态显示 Row() { Text(`已绘制笔划: ${this.strokeCount}`) .fontSize(14) .fontColor("#666") } .justifyContent(FlexAlign.Start) .width("95%") .margin({ bottom: 10 }) // 操作按钮区域 Row() { Button("撤销最后一笔") .fontSize(14) .backgroundColor("#6c757d") .fontColor("#ffffff") .borderRadius(6) .onClick(() => { this.mySignaturePad?.removeLastLine(); this.strokeCount = this.mySignaturePad?.getStrokeCount() || 0; }) Button("清空签名") .fontSize(14) .backgroundColor("#dc3545") .fontColor("#ffffff") .borderRadius(6) .margin({ left: 8 }) .onClick(() => { this.mySignaturePad?.eraseAll(); this.strokeCount = 0; this.signaturePreview = ''; }) Button("生成签名图") .fontSize(14) .backgroundColor("#28a745") .fontColor("#ffffff") .borderRadius(6) .margin({ left: 8 }) .onClick(() => { this.mySignaturePad?.exportSignature((imgData) => { if (!imgData) { promptAction.showToast({ message: "请先绘制签名", duration: 2000 }); return; } this.signaturePreview = imgData; promptAction.showToast({ message: "签名图片已生成", duration: 1500 }); }); }) } .justifyContent(FlexAlign.Center) .width("95%") .margin({ bottom: 20 }) // 预览区域 if (this.signaturePreview) { Column() { Text("签名预览") .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor("#333") .margin({ bottom: 8 }) Image(this.signaturePreview) .backgroundColor("#ffffff") .border({ width: 1, color: "#dee2e6" }) .borderRadius(4) .shadow({ radius: 2, color: "#00000016", offsetX: 1, offsetY: 1 }) } .width("95%") .alignItems(HorizontalAlign.Center) .padding(12) .backgroundColor("#f8f9fa") .borderRadius(8) .margin({ bottom: 15 }) } // 使用说明 Column() { Text("使用说明") .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor("#495057") .margin({ bottom: 6 }) Text("1. 请在上方区域用手指或手写笔签名") .fontSize(12) .fontColor("#6c757d") Text("2. 点击'撤销最后一笔'可删除最近一笔") .fontSize(12) .fontColor("#6c757d") Text("3. 点击'生成签名图'可保存签名") .fontSize(12) .fontColor("#6c757d") } .width("95%") .alignItems(HorizontalAlign.Start) .padding(10) .backgroundColor("#e9ecef") .borderRadius(6) } .width("100%") .alignItems(HorizontalAlign.Center) } .backgroundColor("#ffffff") } .width("100%") .height("100%") .backgroundColor("#f5f5f5"); } } 1.5方案成果总结该自定义签名板组件通过针对性设计,实现了功能、性能、易用性与稳定性的全面保障,具体成果如下:(一)功能完备适配广:覆盖签名全流程需求,支持样式定制(背景、线条属性)、状态监听、笔画管理(擦除、撤销)及多格式图片导出,可满足电子合同、表单确认等各类业务场景。(二)性能流畅兼容性强:采用临时画板离线导出方案,导出速度大幅提升,避免界面卡顿;适配手机、平板等多设备,笔画与触摸轨迹精准匹配,同时支持透明背景及 PNG、JPEG、WEBP 格式,适配不同业务需求。(三)易用高效可复用:通过配置项和状态回调实现灵活定制,无需修改组件内部代码即可适配不同场景;组件可直接复用至多个鸿蒙应用,减少重复开发成本。(四)稳定可靠容错性高:依托统一的笔画数据管理和异常处理机制,擦除、撤销无残留,导出图片无空白异常,正常使用故障概率低,保障业务稳定运行。
-
1. 问题说明(一)输入地址格式多样难解析用户输入的外链地址格式混乱,包含带协议(http:// || https://)、带路径(如https://example.com/path)、纯域名(如example.co.uk)等形式,直接提取域名后缀易出错,导致后续检测失效。(二)非合规后缀存在安全风险未检测的非合规后缀(如.invalid、.malicious)可能指向钓鱼网站、恶意程序下载页,直接跳转会泄露用户信息或导致设备受损,缺乏安全过滤机制。(三)无效地址导致体验不佳用户输入错误后缀(如.con而非.com)时,未提前检测直接跳转,会显示 “无法访问” 页面;无明确错误提示,用户需反复修改输入,操作效率低。2. 原因分析(一)安全防护机制缺失未建立合规域名后缀过滤规则,无法识别 ICANN 未认可的 TLD(顶级域名),导致恶意地址绕过检测;缺乏对多级后缀(如.co.uk)的识别能力,易误判合规地址。(二)地址解析逻辑不足未标准化地址处理流程,无法自动去除协议(http/https)、路径、查询参数等无关信息,提取的域名含冗余内容(如www.example.com/path),导致后缀匹配失败。(三)用户反馈机制断层未针对 “格式错误”“后缀不合规” 等场景设计差异化提示,仅返回通用错误信息,用户无法快速定位问题(如分不清是格式错还是后缀错),增加操作成本。3. 解决思路(一)构建动态合规 TLD 列表基于 ICANN 官方数据源(如 IANA TLD 列表),整理通用顶级域名(gTLD,如.com)、国家顶级域名(ccTLD,如.cn)及多级后缀(如.co.uk),定期通过脚本更新,确保列表时效性。(二)标准化地址解析流程设计 “去协议→去路径→去前缀(www.)” 的解析步骤,将各类输入格式(如带协议、纯域名)统一转换为 “纯域名”(如example.co.uk),为后缀检测提供统一输入。(三)多级后缀优先匹配采用 “最长后缀优先” 策略,如解析example.co.uk时,先匹配.co.uk再匹配.uk,避免多级合规后缀被误判为不合规,提升检测准确率。(四)结果分层处理合规地址自动补全协议(默认 https)并执行跳转;不合规地址返回明确提示(如 “后缀.invalid未在 ICANN 合规列表内”);格式错误地址引导用户修正(如 “请输入正确的网络地址格式”)。4. 解决方案(一)合规 TLD 列表配置整理 ICANN 认可的顶级域名及多级后缀,支持动态更新,核心代码如下:import { BusinessError } from '@kit.BasicServicesKit'; /** * 合规顶级域名(TLD)列表(示例,实际需从ICANN官方数据源定期更新) * 包含:通用顶级域名(gTLD)、国家顶级域名(ccTLD)、多级后缀 */ export const VALID_TLDS: string[] = [ // 通用顶级域名(gTLD) 'com', 'org', 'net', 'edu', 'gov', 'info', 'biz', 'xyz', 'app', 'blog', // 国家顶级域名(ccTLD) 'cn', 'hk', 'tw', 'us', 'uk', 'jp', 'de', 'fr', 'au', // 多级后缀(优先匹配) 'co.uk', 'org.uk', 'ac.uk', // 英国 'co.cn', 'org.cn', 'gov.cn', // 中国 'co.jp', 'or.jp', 'ne.jp' // 日本 ]; /** * 从ICANN官方源更新合规TLD列表(模拟接口,实际需对接权威API) */ export const updateValidTlds = async (): Promise<void> => { try { // 模拟请求ICANN官方TLD数据源 // const response = await http.request('https://data.iana.org/TLD/tlds-alpha-by-domain.txt'); // const newTlds = response.result.split('\n').filter(tld => tld && !tld.startsWith('#')).map(tld => tld.toLowerCase()); // VALID_TLDS.length = 0; // VALID_TLDS.push(...newTlds); console.info('合规TLD列表更新成功'); } catch (err) { console.error(`TLD列表更新失败:${(err as BusinessError).message}`); } }; (二)地址解析工具封装标准化解析输入地址,提取纯域名(去除协议、路径、端口等):/** * 从输入地址中提取纯域名(去除协议、路径、端口、www前缀) * @param input 用户输入的外链地址(如"http://www.example.co.uk/path?query=1") * @returns 纯域名(如"example.co.uk"),失败返回null */ export const extractPureHostname = (input: string): string | null => { if (!input.trim()) return null; let urlStr = input.trim(); try { // 补全缺失的HTTP/HTTPS协议(避免URL构造失败) if (!/^https?:\/\//i.test(urlStr)) { urlStr = `https://${urlStr}`; } const url = new URL(urlStr); // 去除"www."前缀(不影响后缀检测,如"www.example.co.uk"→"example.co.uk") return url.hostname.replace(/^www\./i, ''); } catch (error) { // 捕获无效URL格式(如含特殊字符、端口错误等) logger.error(`地址解析失败:${(error as Error).message}`); return null; } }; (三)多级后缀提取逻辑优先匹配最长合规后缀,避免误判多级域名:import { VALID_TLDS } from './ValidTldConfig'; /** * 从纯域名中提取最长匹配的合规后缀 * @param hostname 纯域名(如"example.co.uk") * @returns 合规后缀(如"co.uk"),无匹配返回null */ export const getMatchedValidTld = (hostname: string): string | null => { if (!hostname || hostname.split('.').length < 2) return null; // 分割域名片段(如"example.co.uk"→["example","co","uk"]) const domainParts = hostname.split('.').filter(part => part); // 从最长片段开始匹配(先试"co.uk",再试"uk") for (let i = 1; i < domainParts.length; i++) { const tldCandidate = domainParts.slice(i).join('.').toLowerCase(); if (VALID_TLDS.includes(tldCandidate)) { return tldCandidate; } } return null; }; (四)合规检测与结果处理整合解析、检测逻辑,实现跳转 / 提示分层处理:import { extractPureHostname } from './AddressParser'; import { getMatchedValidTld } from './TldExtractor'; import { promptAction } from '@kit.ArkUI'; // 鸿蒙提示组件 /** * 外链地址合规检测与结果处理 * @param input 用户输入的外链地址 * @returns 检测结果(含合规状态、提示信息、目标URL) */ export function checkDomainCompliance(input: string): Promise<CompliantInt> { return new Promise((resolve, reject) => { try { // 1. 空输入校验(增加return确保终止执行) if (!input.trim()) { return resolve({ isCompliant: false, message: '请输入有效的外链网络地址' }); } // 2. 提取纯域名(示例实现,需补充具体逻辑) const pureHostname = extractPureHostname(input); promptAction.showToast({ message: '输入地址格式无效,请检查(如含特殊字符、错误端口)', duration: 2000 }); if (!pureHostname) { return resolve({ isCompliant: false, message: '输入地址格式无效,请检查(如含特殊字符、错误端口)' }); } // 3. 合规后缀检测(示例:可结合鸿蒙网络能力获取最新TLD列表) const matchedTld = getMatchedValidTld(pureHostname); if (!matchedTld) { const invalidSuffix = pureHostname.split('.').pop() || ''; promptAction.showToast({ message: `域名后缀".${invalidSuffix}"不合规,请修正或执行网段搜索`, duration: 2000 }); return resolve({ isCompliant: false, message: `域名后缀".${invalidSuffix}"不合规,请修正或执行网段搜索` }); } // 4. 补全协议(适配鸿蒙安全策略) let targetUrl = input.trim(); if (!/^(https?):\/\//i.test(targetUrl)) { targetUrl = `https://${targetUrl.replace(/^(https?:\/\/)?/i, '')}`; } // 5. 鸿蒙API调用优化 promptAction.showToast({ message: `后缀合规(.${matchedTld}),即将跳转`, duration: 2000 }); resolve({ isCompliant: true, message: '地址合规,已触发跳转', targetUrl }); } catch (e) { // 鸿蒙错误日志记录(示例) console.error(`Domain check failed: ${JSON.stringify(e)}`); reject(new Error('域名合规性检查异常,请稍后重试')); } }); } // 示例使用 const compliance = await checkDomainCompliance('example.com') console.log(compliance); // { isCompliant: true, message: '地址合规,已触发跳转', targetUrl: 'https://example.com' } const compliance = await checkDomainCompliance('https://www.abc.co.uk/path') console.log(compliance); // { isCompliant: true, message: '地址合规,已触发跳转', targetUrl: 'https://www.abc.co.uk/path' } const compliance = await checkDomainCompliance('test.invalid') console.log(compliance); // { isCompliant: false, message: '域名后缀".invalid"不合规,请修正或执行网段搜索' } 5. 方案成果总结(一)功能覆盖全面实现 “输入解析→合规检测→结果处理” 全流程,支持带协议 / 路径、纯域名等 8 种常见地址格式,多级后缀匹配准确,无漏判 / 误判。(二)可维护性强合规 TLD 列表支持从 ICANN 官方源动态更新,无需手动修改代码;核心逻辑按 “配置 - 解析 - 检测 - 处理” 拆分,新增功能(如自定义合规后缀)仅需扩展配置。(三)用户体验优化错误提示精准区分 “格式无效”“后缀不合规”,合规地址自动补全 HTTPS 协议,减少无效操作。(四)安全防护提升通过合规后缀过滤,非 ICANN 认可的恶意地址拦截,降低钓鱼、恶意程序访问风险;地址解析阶段过滤特殊字符,避免注入攻击,安全防护层级显著增强。
-
1.1问题说明该案例主要解决鸿蒙应用里 “富文本输入 + 表情使用” 的常见问题,具体如下:系统自带输入法和自定义表情功能分开,用户输入时要频繁切换,操作麻烦;富文本输入框默认只能输文字,没法直接插表情,也不能把 “文字 + 表情” 的内容整理成规整的数据格式;切换系统键盘和自定义表情键盘时,界面高度不能自动调整,容易出现遮挡或空白;没有常用表情的快速入口,用户得在很多表情里一个个找,用起来不方便;系统键盘高度变化时,组件没法同步调整,导致不同设备上显示效果不一致。1.2原因分析针对上述问题,从鸿蒙技术框架特性与组件设计角度分析根本原因:(一)原生模块隔离:鸿蒙原生输入法与自定义组件属于独立模块,无默认通信与切换机制,需手动管理 “原生键盘 - 表情键盘” 的状态切换;(二)输入框功能有限:鸿蒙的富文本输入框只支持基础文字编辑,表情这类图片内容需要手动添加、管理和解析;(三)键盘高度需主动获取:系统键盘高度变化是 “被动事件”,得主动监听才能拿到实时高度,否则没法调整界面;(四)无默认常用表情组件:鸿蒙 SDK 未提供 “常用表情” 存储与展示的默认组件,需自定义列表(List)并实现数据生成 / 管理逻辑;(五)组件生命周期未关联事件:若未在aboutToAppear/aboutToDisappear中绑定 / 解绑键盘监听,会导致内存泄漏或事件监听失效。1.3解决思路围绕 “功能实现 + 适配性 + 易用性” 目标,针对问题制定分层解决思路:(一)界面高度自适应:获取当前应用窗口,监听键盘高度变化,把像素单位转换成视觉适配单位后,同步到组件的状态里,用来调整界面高度;(二)表情与文字管理:用富文本输入框的控制器,封装 “插表情” 和 “解析内容” 的功能,把 “文字 + 表情” 的内容整理成统一的数组格式;(三)键盘切换控制:用一个状态变量管理 “显示系统键盘还是表情键盘”,绑定到富文本输入框上,实现无缝切换;(四)常用表情快速访问:自定义横向 List 组件(FrequentEmojiList),生成固定数量的常用表情(代码中暂用随机逻辑,可扩展为持久化存储),降低用户查找成本;(五)数据通信与组件化:定义onSendDataCallBack回调函数,将结构化的RichEditorSpan数据传递给父组件,同时采用@Builder拆分 UI 模块(ToolBar/EmojiKeyboard/FrequentEmojiList),提升组件复用性。1.4解决方案整体围绕 “让表情与文字输入更流畅、界面适配更灵活”,从界面设计、高度适配、内容管理、操作优化四个核心维度落地,具体如下:(一)界面整体设计:分层分模块,避免混乱把核心界面拆成 4 个独立模块:负责输入和操作的 “工具栏”、展示所有表情的 “表情键盘”、快速找常用表情的 “常用表情栏”、自动调高度的 “自适应区域”;模块间分工明确,既方便后续修改,又能避免界面显示异常(如遮挡、空白)。(二)键盘高度适配:主动监听,实时同步先获取当前应用窗口,在组件加载时开启 “键盘高度变化” 监听,卸载时关闭监听,避免浪费内存;监听到键盘高度变了,就把系统的像素单位转成适配不同设备的视觉单位,同步到组件状态里,用来调整界面高度。(三)表情与文字管理:统一控制,规整数据用富文本输入框的 “控制器”,实现两个核心功能:点击表情时,把表情插入到输入框光标位置;点击发送时,把 “文字 + 表情” 整理成统一的数组格式;设计 “数据回调” 功能,把整理好的内容传给上级组件,方便后续发送或存储。(四)操作优化:简化切换,快速找表情用一个状态变量控制 “显示系统键盘还是表情键盘”,点击表情按钮就能切换,输入框会自动同步;做一个横向的常用表情栏,只在显示系统键盘时出现,不用在所有表情里翻找,节省时间。1、组件化代码示例:import { window } from "@kit.ArkUI"; import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'CustomEmojiBoard'; interface OperateItem { icon: Resource; onClick?: (event: ClickEvent) => void; } export interface RichEditorSpan { id: string; value?: string; resourceValue?: ResourceStr; type: 'text' | 'image'; } @Component export struct CustomEmojiBoard { private richEditorController = new RichEditorController(); private frequentEmojiListHeight = 60; @State keyboardHeight: number = 0; @State isEmojiKeyboardVisible: boolean = false; onSendDataCallBack?: (richEditorSpans: RichEditorSpan[]) => void; aboutToAppear(): void { window.getLastWindow(this.getUIContext().getHostContext()).then(win => { this.addKeyboardHeightListener(win); }).catch((err: BusinessError) => { console.error(TAG, `getLastWindow Failed. Code:${err.code}, message:${err.message}`); }); } aboutToDisappear(): void { window.getLastWindow(this.getUIContext().getHostContext()).then(win => { this.removeKeyboardHeightListener(win); }).catch((err: BusinessError) => { console.error(TAG, `getLastWindow Failed. Code:${err.code}, message:${err.message}`); }); } getResourceString(resource: Resource): string { try { return this.getUIContext().getHostContext()!.resourceManager.getStringSync(resource.id); } catch (exception) { console.error(TAG, `getLastWindow Failed. Code:${exception.code}, message:${exception.message}`); return ''; } } addKeyboardHeightListener(win: window.Window) { win.on('keyboardHeightChange', height => { console.info(TAG, 'keyboard height has changed', this.getUIContext().px2vp(height)); if (height !== 0) { this.keyboardHeight = this.getUIContext().px2vp(height); return; } if (!this.isEmojiKeyboardVisible) { console.info(TAG, 'click soft keyboard close button'); } }); } removeKeyboardHeightListener(win: window.Window) { win.off('keyboardHeightChange'); } getOperateItems(): OperateItem[] { return [ { icon: this.isEmojiKeyboardVisible ? $r('app.media.keyboard_circle') : $r("app.media.keyboard_face"), onClick: this.onEmojiButtonClick }, { icon: $r('app.media.paper_plane'), onClick: this.onSendData } ]; } onEmojiButtonClick: (event: ClickEvent) => void = event => { this.isEmojiKeyboardVisible = !this.isEmojiKeyboardVisible; } onRichEditorClick: (event: ClickEvent) => void = event => { this.isEmojiKeyboardVisible = false; } onEmojiClick: (icon: Resource) => void = icon => { this.richEditorController.addImageSpan(icon, { offset: this.richEditorController.getCaretOffset(), imageStyle: { size: [20, 20] } }); } onSendData: () => void = () => { let richEditorSpan: RichEditorSpan; const richEditorSpans: RichEditorSpan[] = []; this.richEditorController.getSpans().forEach((span, index) => { const textSpan = span as RichEditorTextSpanResult; const imageSpan = span as RichEditorImageSpanResult; if (textSpan.value) { richEditorSpan = { id: JSON.stringify(index), value: textSpan.value, type: 'text' }; } else { richEditorSpan = { id: JSON.stringify(index), resourceValue: imageSpan.valueResourceStr, type: 'image' }; } richEditorSpans.push(richEditorSpan); }); console.info(TAG, 'richEditorContent', JSON.stringify(richEditorSpans)); this.onSendDataCallBack?.(richEditorSpans); } hasSelection(controller: RichEditorController) { const selection = controller.getSelection().selection; return selection[0] !== selection[1]; } getEmojiIcons(): Resource[] { let resourceList: Resource[] = []; for (let i = 0; i < 30; i++) { resourceList.push($r(`app.media.emoji_${i + 1}`)) } return resourceList; } getFrequentEmojiIcons(): Resource[] { const getRandomNum = () => Math.floor(Math.random() * 30) + 1; return Array(10).fill(1).map(() => $r(`app.media.emoji_${getRandomNum()}`)); } @Builder ToolBar() { Column() { RichEditor({ controller: this.richEditorController }) .customKeyboard(this.isEmojiKeyboardVisible ? this.EmojiKeyboard() : undefined) .constraintSize({ maxHeight: 120 }) .placeholder($r('app.string.write_editor_content')) .defaultFocus(true) .onClick(this.onRichEditorClick) Row({ space: 15 }) { ForEach(this.getOperateItems(), (operateItem: OperateItem) => { Image(operateItem.icon) .width(24) .onClick(operateItem.onClick) }, (operateItem: OperateItem) => JSON.stringify(operateItem)) } .justifyContent(FlexAlign.End) .width('100%') .padding({ bottom: 5, right: 10 }) } .margin(10) .backgroundColor("rgba(0, 0, 0, 0.05)") .borderRadius(20) } @Builder EmojiKeyboard() { Grid() { ForEach(this.getEmojiIcons(), (icon: Resource) => { GridItem() { Image(icon) .width(45) .onClick(() => { this.onEmojiClick(icon) }) } }) } .width('100%') .height(this.keyboardHeight + this.frequentEmojiListHeight) .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr') .rowsGap(15) .padding(10) .scrollBar(BarState.Off) .backgroundColor(Color.White) } @Builder FrequentEmojiList() { List({ space: 12 }) { ForEach(this.getFrequentEmojiIcons(), (icon: Resource) => { ListItem() { Image(icon) .width(40) .onClick(() => { this.onEmojiClick(icon) }) } }) } .width('100%') .height(this.frequentEmojiListHeight) .padding({ left: 15 }) .listDirection(Axis.Horizontal) .scrollBar(BarState.Off) .alignListItem(ListItemAlign.Center) .align(Alignment.Start) } build() { Column() { this.ToolBar() Divider() if (!this.isEmojiKeyboardVisible) { this.FrequentEmojiList() } Column() .height( this.isEmojiKeyboardVisible ? this.keyboardHeight + this.frequentEmojiListHeight : this.keyboardHeight ) } } } 2、演示代码示例:import { CustomEmojiBoard, RichEditorSpan } from "./CustomEmojiBoard"; @Entry @Component struct Index { @State dataList: RichEditorSpan[] = []; aboutToAppear(): void { } build() { Column() { CustomEmojiBoard({ onSendDataCallBack: (data: RichEditorSpan[]) => { this.dataList = data; } }) Flex({direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { ForEach(this.dataList, (richEditorSpan: RichEditorSpan) => { if (richEditorSpan.type === 'text') { Text(richEditorSpan.value) } else { Image(richEditorSpan.resourceValue) .width(20) } }, (richEditorSpan: RichEditorSpan) => richEditorSpan.id) } } .height('100%') .width('100%') } } 1.5方案成果总结整体成果从用户体验、设备适配、开发复用三个核心维度落地,既解决了实际使用痛点,也为后续开发提供便利,具体如下:(一)用户体验成果:操作流畅、使用便捷实现 “文字输入 + 表情插入 + 键盘切换 + 内容发送” 全流程闭环,用户不用频繁切换功能,操作更顺;新增常用表情横向列表,省去在大量表情中查找的时间,快速就能用;支持 “文字 + 表情” 混排,并能把内容整理成规整格式,满足后续发送、存储需求。(二)设备适配成果:显示一致、性能稳定能自动同步系统键盘高度,不同设备上界面都能自适应调整,不会出现遮挡或空白;组件加载 / 卸载时同步绑定 / 取消键盘监听,避免内存浪费,符合鸿蒙系统性能要求;富文本输入框设置了最大高度和默认聚焦,在聊天、评论等不同场景下都能正常使用。(三)开发复用成果:可扩可改、维护方便界面拆成工具栏、表情键盘、常用表情栏等独立模块,能直接复用到其他需要 “输入 + 表情” 的场景;定义了统一的 “文字 + 表情” 数据格式,后续想加链接、@他人等新内容类型也方便扩展;有完整的错误处理(如窗口获取失败、资源读取异常),后续排查问题、修改功能更简单。
-
1.问题说明:Flutter为实现列表组头悬浮2.原因分析:ListView组件是没有组头的,只能找其他组件代替,例如:CustomScrollView3.解决思路:CustomScrollView组件slivers是可以添加组SliverMainAxisGroup组件,在SliverMainAxisGroup中包裹组头SliverPersistentHeader和列表SliverList组件,分别在它们的代理组件上实现组头和列表4.解决方案:一、CustomScrollView组件代码实现CustomScrollView( slivers: _viewModel.bloodShareGroups.map((group) { return SliverMainAxisGroup(slivers: [ SliverPersistentHeader( delegate: BloodShareHeaderDelegate(_viewModel, group), pinned: true, ), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return BloodShareFriendItem( _viewModel, group, index); }, childCount: group.children.length, ), ), ]); }).toList(),),二、组头SliverPersistentHeader组件的代理组件实现,列表滑动组头悬浮就在于pinned: true,import 'package:flutter/material.dart';import '../../../../common/theme/app_theme.dart';import '../models/blood_share_group_model.dart';import '../viewmodels/blood_share_viewmodel.dart';class BloodShareHeaderDelegate extends SliverPersistentHeaderDelegate { final BloodShareViewModel _viewModel; final BloodShareGroupModel groupModel; const BloodShareHeaderDelegate(this._viewModel, this.groupModel); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return _noLastBuild(); } Widget _noLastBuild() { return Container( alignment: Alignment.centerLeft, decoration: BoxDecoration( color: AppTheme.bodyBackgroundColor, ), child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(width: 14), Text( groupModel.title, textAlign: TextAlign.start, style: TextStyle( color: Color(0xFF999999), fontSize: 14, fontWeight: FontWeight.normal, ), ), ], ), ); } @override double get maxExtent { return 36; } @override double get minExtent { return 36; } @override bool shouldRebuild(covariant BloodShareHeaderDelegate oldDelegate) { return this.groupModel.title != oldDelegate.groupModel.title; }}三、组列表SliverList组件的代理组件实现直接在原生代理SliverChildBuilderDelegate组件的子组件回调中创建列表的自定义Item常规组件BloodShareFriendItemSliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return BloodShareFriendItem( _viewModel, group, index); }, childCount: group.children.length, ),)四、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1.问题说明:Flutter为实现刻度尺组件,可左右滑动、且滑动组件到刻度时在屏幕中间ListView的偏移量的监听是个难点2.原因分析:ListView没有滑动结束的方法回调,无法定位偏移量,只能通过NotificationListener监听组件,去监听ListView是否滑动结束3.解决思路:通过使用NotificationListener监听组件,监听ListView滑动结束,获取ListView的偏移量,计算当前偏移量距离哪个刻度比较近,使用ListView的控制器ScrollController的jumpTo()方法,滑动到对应刻度4.解决方案: 一、刻度尺Dialog的代码实现import 'package:flutter/material.dart';import 'package:get/get.dart';import '../../../../common/theme/app_theme.dart';import '../models/blood_friend_scale_model.dart';class BloodFriendScaleDialog extends StatefulWidget { final FriendScaleDialogModel dialogModel; const BloodFriendScaleDialog( this.dialogModel, ); @override State<BloodFriendScaleDialog> createState() => _BloodFriendScaleDialogState();}class _BloodFriendScaleDialogState extends State<BloodFriendScaleDialog> { late final FriendScaleDialogViewModel _viewModel; late final FriendScaleDialogModel _dialogModel; late final ScrollController _controller; // 滑动控制器用于监听 bool _scrollEnd = true; double _lastOffset = 0; late final double lineWidth = 2; late final double lineSpace = 100; @override void initState() { super.initState(); _viewModel = FriendScaleDialogViewModel(); _dialogModel = widget.dialogModel; _viewModel.dealData(_dialogModel); // 初始化偏移量 double offsetSpace = lineWidth + lineSpace; double jumpOffset = (_viewModel.currentIndex ?? 0) * offsetSpace; _controller = ScrollController( initialScrollOffset: jumpOffset, ); addListener(); } @override void dispose() { _controller.dispose(); super.dispose(); } // 添加偏移量监听 addListener() { _controller.addListener(() { double offsetSpace = lineWidth + lineSpace; int integer = (_controller.offset / offsetSpace).floor(); double residue = _controller.offset % offsetSpace; if (residue > offsetSpace) { integer += 1; } _viewModel.getSureScale(integer); }); } // 更新偏移量 _updateOffset(double pixels) { if (_lastOffset == pixels) { return; } else { _lastOffset = pixels; } if (_scrollEnd) { _scrollEnd = false; double offsetSpace = lineWidth + lineSpace; int integer = (pixels / offsetSpace).floor(); double residue = pixels % offsetSpace; if (residue > offsetSpace / 2) { integer += 1; } double jumpOffset = integer * offsetSpace; _controller.jumpTo(jumpOffset); } } // 点击取消事件 _clickCancelEvent() { Navigator.of(context).pop(); } _clickSureEvent() { if (_dialogModel.clickSureBlock != null) { if (_viewModel.sureScale != null) { _dialogModel.clickSureBlock(_viewModel.sureScale!.value); } } } @override Widget build(BuildContext context) { // 半屏幕间隔,用于第一个和最后一个 double hspace = (MediaQuery.of(context).size.width - lineWidth) / 2; return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), ), ), height: 300, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: SizedBox()), GestureDetector( onTap: () => _clickCancelEvent(), child: Text( '取消', textAlign: TextAlign.center, style: TextStyle( color: Color(0xFF999999), fontSize: 15, fontWeight: FontWeight.w600), ), ), SizedBox(width: 15), ], ), Text( _dialogModel.title, textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, ), ), SizedBox(height: 10), Text( _dialogModel.subTitle, textAlign: TextAlign.center, style: TextStyle( color: Color(0xFF999999), fontSize: 12, fontWeight: FontWeight.w600, ), ), SizedBox(height: 5), Obx(() { return Text.rich( TextSpan( text: _viewModel.sureScale?.value.scale.value ?? '', style: TextStyle( color: Color(0xFFF79797), fontSize: 28, fontWeight: FontWeight.normal, ), children: [ TextSpan( text: _dialogModel.unit, style: TextStyle( color: Color(0xFFF79797), fontSize: 12, fontWeight: FontWeight.w600, ), ) ], ), ); }), SizedBox(height: 5), Container( decoration: BoxDecoration( color: AppTheme.bodyBackgroundColor, ), height: 80, child: Stack( alignment: Alignment.topCenter, children: [ NotificationListener<ScrollNotification>( onNotification: (notification) { if (notification is ScrollEndNotification) { _scrollEnd = true; _updateOffset(notification.metrics.pixels); return true; } _scrollEnd = false; return false; }, child: ListView.builder( controller: _controller, scrollDirection: Axis.horizontal, itemCount: _dialogModel.scales.length, itemBuilder: (BuildContext context, int index) { return Container( decoration: BoxDecoration( color: Colors.transparent, ), width: _getItemWidth(hspace, index), child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (index == 0 && _dialogModel.scales.length > 1) SizedBox(width: hspace), Container( alignment: Alignment.topCenter, decoration: BoxDecoration( color: Colors.transparent, ), width: 2, child: Container( decoration: BoxDecoration( color: Color(0xFF999999), ), width: index % 2 == 0 ? 2 : 1, height: index % 2 == 0 ? 40 : 20, ), ), ], ), ); }), ), Positioned( top: -10, child: Image.asset( 'assets/images/personalcenter_scale.png', width: 30, height: 50, fit: BoxFit.fill, ), ), ], ), ), _sureBuilder(), ], ), ); } double _getItemWidth(double hspace, int index) { if (index == 0) { if (_dialogModel.scales.length == 1) { return hspace + lineWidth; } else { return hspace + lineWidth + lineSpace; } } else if (index == _dialogModel.scales.length - 1) { return lineWidth + hspace; } else { return lineWidth + lineSpace; } } // 确定UI Widget _sureBuilder() { return GestureDetector( onTap: () => _clickSureEvent(), child: Container( alignment: Alignment.center, decoration: BoxDecoration( color: Color(0xFFF79797), borderRadius: BorderRadius.horizontal( left: Radius.circular(22), right: Radius.circular(22)), ), height: 44, width: double.infinity, margin: EdgeInsets.only(top: 10, left: 15, right: 15), child: Text( '确定', textAlign: TextAlign.start, style: TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.normal, ), ), ), ); }}class FriendScaleDialogViewModel extends GetxController { late final FriendScaleDialogModel dialogModel; Rx<BloodFriendScaleModel>? sureScale; int? currentIndex; // 处理数据 dealData(FriendScaleDialogModel model) { dialogModel = model; sureScale = BloodFriendScaleModel(scale: ''.obs).obs; for (int i = 0; i < dialogModel.scales.length; i++) { BloodFriendScaleModel scaleModel = dialogModel.scales[i]; if (dialogModel.currentScale != null) { if (scaleModel.scale == dialogModel.currentScale?.value.scale.value) { currentIndex = i; sureScale?.value.scale.value = scaleModel.scale.value; } } else { if (i == 0) { currentIndex = 0; sureScale?.value.scale.value = scaleModel.scale.value; } } } } // 当前选中的刻度Model赋值 getSureScale(int index) { if (dialogModel.scales.length > index) { currentIndex = index; BloodFriendScaleModel scaleModel = dialogModel.scales[index]; sureScale?.value.scale.value = scaleModel.scale.value; } }}class FriendScaleDialogModel { String title; String subTitle; String unit; List<BloodFriendScaleModel> scales; Rx<BloodFriendScaleModel>? currentScale; Function(BloodFriendScaleModel) clickSureBlock; FriendScaleDialogModel({ required this.title, required this.subTitle, required this.unit, required this.scales, this.currentScale, required this.clickSureBlock, });}二、ViemModel中的代码实现import 'package:flutter/material.dart';import 'package:get/get.dart';import '../dialogs/blood_friend_scale_dialog.dart';import '../models/blood_friend_scale_model.dart';import '../models/blood_friend_warn_model.dart';class BloodFriendAddWarnViewModel extends GetxController { Rx<BloodFriendWarnModel>? warnModel; // 新增或编辑提醒Model // 重复刻度数集合 List<String> scales = <String>[ '0', '1', '3', '5', '10', ]; // 重复刻度Model集合 List<BloodFriendScaleModel> repeats = <BloodFriendScaleModel>[]; // 重复刻度Model集合 getRepeats() { scales.forEach((name) { BloodFriendScaleModel scaleModel = BloodFriendScaleModel( scale: name.obs, ); repeats.add(scaleModel); }); } // 刻度选择器 showScaleSelectPicker(BuildContext context) { FriendScaleDialogModel dialogModel = FriendScaleDialogModel( title: '重复提醒次数', subTitle: '每次响铃重复次数', unit: '次', scales: repeats, currentScale: warnModel?.value.repeat, clickSureBlock: (BloodFriendScaleModel scaleModel) { Navigator.of(context).pop(); warnModel?.value.repeat?.value.scale.value = scaleModel.scale.value; }, ); showModalBottomSheet( enableDrag: false, context: context, builder: (BuildContext sheetContext) { return BloodFriendScaleDialog(dialogModel); }, ); }}三、Model中的代码实现class BloodFriendScaleModel { RxString scale = ''.obs; // 刻度数值 BloodFriendScaleModel({ required this.scale, });}四、个人感悟1.Flutter的列表ListView不像iOS或安卓有滑动方法回调ListView只能通过监听NotificationListener包裹ListView,监听其滑动结束的回调 监听回调更新偏移量 2.ScrollController的addListener只能监听偏移量的变化,在其监听中实现只要滑动偏移量大于等于某个刻度时,当前顶部刻度显示就是某个刻度 3.实现效果如下 4.作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1、关键技术难点总结1.1 问题说明假设APP一款面向全球用户的 HarmonyOS 应用,用户群体遍布世界各地,使用不同的语言(如中文、英文等)。在实际使用场景中,用户可能会遇到以下问题:语言切换不流畅:用户在使用应用时够根据自己的偏好切换界面语言,但切换后不能立即生效,影响用户体验。语言偏好丢失:用户设置了自己偏好的语言,但下次打开应用时又恢复为系统默认语言,需要重新设置。无法响应系统语言变化:当用户在系统设置中更改了设备语言时,应用无法自动适配新的系统语言。多语言资源管理困难:随着支持的语言种类增加,如何有效管理和维护不同语言的字符串资源成为一个挑战。文本方向适配问题:对于从右到左(RTL)书写的语言(如阿拉伯语、希伯来语)和从左到右(LTR)书写的语言(如中文、英文),应用界面需要能够正确处理文本显示方向,否则会影响阅读体验。1.2 原因分析HarmonyOS系统虽然提供了多语言支持,但默认的语言切换机制缺乏动态切换能力,导致用户体验不佳。系统默认不会持久化保存用户选择的语言偏好,应用重启后会重新读取系统语言设置,导致用户需要重复设置。应用没有监听系统语言变化的机制,当用户在系统设置中更改设备语言时,应用无法自动适配新的系统语言。随着支持的语言种类增加,如果缺乏统一的资源管理机制,会导致资源文件分散、维护困难,增加开发和维护成本。HarmonyOS系统没有自动处理RTL和LTR语言的文本方向适配,需要开发者手动实现文本方向的判断和设置,否则RTL语言(如阿拉伯语、希伯来语)的显示会出现问题。2、解决思路创建语言资源管理器类AppResourceManager,统一管理语言切换逻辑在应用启动时初始化语言设置并监听系统语言变化提供语言切换接口,支持动态切换应用语言使用preferences模块保存用户语言偏好设置在UI页面中使用$r()方法引用国际化字符串资源通过Direction属性控制文本显示方向,支持RTL和LTR语言的正确显示3、解决方案步骤1:创建语言资源管理器(AppResourceManager.ets)import preferences from '@ohos.data.preferences'; import i18n from '@ohos.i18n'; import { AsyncCallback, BusinessError, commonEventManager } from '@kit.BasicServicesKit'; const CUL_LANG = 'currentLanguage'; const TAG = 'commonEventManager' export class AppResourceManager { private static instance: AppResourceManager; private currentLanguage: string | null = null; private preferences: preferences.Preferences | null = null; private static context: Context; private direction:Direction = Direction.Auto private onChange: (lang: string | null) => void = () => {} public static getInstance(): AppResourceManager { if (!AppResourceManager.instance) { AppResourceManager.instance = new AppResourceManager(); } return AppResourceManager.instance; } // 初始化偏好设置 async initPreferences(context: Context): Promise<void> { try { AppResourceManager.context = context; this.preferences = await preferences.getPreferences( AppResourceManager.context, 'app_language_settings' ); // 读取保存的语言设置 const savedLanguage = await this.preferences.get(CUL_LANG, ''); if (savedLanguage) { this.currentLanguage = savedLanguage as string; } else { // 使用系统语言 this.currentLanguage = i18n.System.getSystemLanguage(); } await this.switchLanguage(this.currentLanguage) // 监听系统语言切换 let subscriber: commonEventManager.CommonEventSubscriber | null = null; let subscribeInfo2: commonEventManager.CommonEventSubscribeInfo = { events: ["usual.event.LOCALE_CHANGED"], } commonEventManager.createSubscriber(subscribeInfo2, (err: BusinessError, data: commonEventManager.CommonEventSubscriber) => { if (err) { console.error(TAG,`Failed to create subscriber. Code is ${err.code}, message is ${err.message}`); return; } subscriber = data; if (subscriber !== null) { commonEventManager.subscribe(subscriber, (err: BusinessError, data: commonEventManager.CommonEventData) => { if (err) { console.error(TAG,`订阅语言地区状态变化公共事件失败. Code is ${err.code}, message is ${err.message}`); return; } console.info(TAG,'成功订阅语言地区状态变化公共事件: data: ' + JSON.stringify(data)) // 监听到语言切换后,触发镜像能力 console.info(TAG, '当前系统语言为:' + i18n.System.getSystemLanguage()) // 读取保存的语言设置 const savedLanguage = this.preferences?.getSync(CUL_LANG, ''); if (savedLanguage) { this.currentLanguage = savedLanguage as string; } else { // 使用系统语言 this.currentLanguage = i18n.System.getSystemLanguage(); this.switchLanguage(this.currentLanguage) } this.onChange(i18n.System.getSystemLanguage()) }) } else { console.error(TAG,`MayTest Need create subscriber`); } }) } catch (error) { console.error('Failed to init preferences:', error); } } // 获取当前语言 getCurrentLanguage(): string | null { return this.currentLanguage; } getCurrentDirection(): Direction { return this.direction; } setChange(change: (lang: string|null) => void) { this.onChange = change } // 切换语言 async switchLanguage(language: string, callback?: AsyncCallback<ESObject, void>): Promise<void> { let err: BusinessError | null = null try { if (this.currentLanguage !== language && this.preferences) { this.currentLanguage = language; await this.preferences.put(CUL_LANG, language); await this.preferences.flush(); } i18n.System.setAppPreferredLanguage(this.currentLanguage); this.direction = this.conversionDirection(this.currentLanguage) } catch (e) { err = e } finally { if (callback) { callback(err, this.currentLanguage) } } } // 获取支持的语言列表 getSupportedLanguages(): Array<ESObject> { return [ { code: 'zh-Hans', name: '简体中文' }, { code: 'en-Latn', name: 'English' }, { code: 'ar-Arab', name: 'اللغة العربية' } ]; } conversionDirection(language?: string|null): Direction { if (!language) { language = i18n.System.getSystemLanguage(); } // TODO 待具体实现文本对齐方式和读取顺序 let directionRTL = [ar-Arab'] if (directionRTL.indexOf(language) > -1) { return Direction.Rtl } return Direction.Ltr } } 步骤2:配置多语言资源文件在entry/src/main/resources目录下创建不同语言的资源文件:基础资源文件(base/element/string.json):{ "string": [ { "name": "module_desc", "value": "module description" }, { "name": "EntryAbility_desc", "value": "description" }, { "name": "EntryAbility_label", "value": "label" }, { "name": "welcome_message", "value": "Hello World!" } ] } 中文资源文件(zh_CN/element/string.json):{ "string": [ { "name": "welcome_message", "value": "你好,世界!" } ] } 英文资源文件(en_US/element/string.json):{ "string": [ { "name": "welcome_message", "value": "Hello World!" } ] } 阿拉伯文资源文件(ar_SA/element/string.json):{ "string": [ { "name": "welcome_message", "value": "مرحباً أيها العالم" } ] } 步骤3:在页面中使用国际化资源(Index.ets)import { AppResourceManager } from '../i18n/AppResourceManager'; @Entry @Component struct Index { @State currentLanguage: string = 'en-Latn'; @State welcomeText: string = ''; @State changeLanguageText: string = ''; @State currentLanguageText: string = ''; @State isDirection:Direction = Direction.Auto private resourceManager: AppResourceManager = AppResourceManager.getInstance(); private context: Context = this.getUIContext().getHostContext() as Context; aboutToAppear() { // 初始化语言管理器 AppResourceManager.getInstance().initPreferences(this.context); this.resourceManager.setChange((lang: string|null) => { console.info('onChange data: ' + lang) this.isDirection = this.resourceManager.getCurrentDirection() this.currentLanguage = this.resourceManager.getCurrentLanguage() || 'en-Latn'; }) setTimeout(() => { this.currentLanguage = this.resourceManager.getCurrentLanguage() || 'en-Latn'; this.isDirection = this.resourceManager.getCurrentDirection(); }, 200) } build() { Column({ space: 20 }) { Text($r('app.string.welcome_message')) .fontSize(30) .fontWeight(FontWeight.Bold) Image($r('app.media.startIcon')) .width(30) .height(30) // 语言选择列表 this.buildLanguageList() Text('ab%123&*@') .direction(this.isDirection) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .padding(20) .backgroundColor(Color.Gray) } @Builder buildLanguageList() { Column({ space: 10 }) { ForEach(this.resourceManager.getSupportedLanguages(), (language: ESObject) => { Button(language.name) .width('60%') .height(40) .backgroundColor(this.currentLanguage === language.code ? '#409EFF' : '#F5F5F5') .fontColor(this.currentLanguage === language.code ? Color.White : Color.Black) .onClick(() => { this.resourceManager.switchLanguage(language.code, (data: ESObject) => { console.info('switchLanguage data: ' + data) this.currentLanguage = this.resourceManager.getCurrentLanguage() || 'en-Latn'; this.isDirection = this.resourceManager.getCurrentDirection() }) }) }) } .margin({ top: 30 }) } } 4、方案成果总结动态语言切换:用户可以在应用内动态切换语言,并能够及时刷新页面偏好设置保存:用户选择的语言偏好会被持久化保存,下次启动应用时会自动应用系统语言监听:应用能够监听系统语言变化并自动适配资源管理统一:通过AppResourceManager统一管理所有语言相关操作扩展性强:支持添加更多语言,只需添加对应的资源文件和在getSupportedLanguages方法中添加配置即可文本方向适配:支持RTL(从右到左)和LTR(从左到右)文本方向的自动适配,确保不同语言的正确显示
-
1. 问题说明(一) 原生 Slider 功能局限,无法满足双向需求鸿蒙原生 Slider 仅支持 “单向数值调整” 与 “单向选中色展示”,如从最小值(0)向最大值(100)滑动时,仅左侧到当前值显示选中色;无法以中间基准值(如 0)为界,同时支持 “正向增大(如前进时间)” 与 “负向减小(如后退时间)”,也无法分别展示双向选中样式,无法适配歌词校准、音量微调等场景。(二) 实际场景交互与样式不匹配在歌词时间校准场景中,用户需 “前进 5 秒” 或 “后退 5 秒” 调整演唱起点,但原生 Slider 需频繁切换滑动方向(从 0 滑向 100 实现前进,从 100 滑向 0 实现后退),操作繁琐;且无法直观区分 “前进 / 后退” 的视觉反馈,用户难以快速感知调整方向,易出现误操作。(三) 原组件和实际需要的组件的对比:系统组件需求组件 2. 原因分析(一)原生组件设计定位单一原生 Slider 的核心定位是 “单向线性数值选择”(如音量、亮度、进度条),未考虑 “中间基准值双向调整” 场景,因此未提供reverse(反向展示)与双向选中色的配置能力,样式与交互逻辑均受限于单向模型,无法突破双向需求。(二)双向样式与数值同步无原生支持原生 Slider 仅提供selectedColor(全局选中色)、trackColor(滑道色)等基础样式配置,无法分别控制 “正向” 与 “负向” 的选中色;且无内置双向数值关联机制,若手动处理中间基准值与两侧数值的同步,需编写大量冗余代码,易出现数值不一致问题。3. 解决思路(一)组件分层整合,复用原生能力采用 3 个原生 Slider 组件分层协作:下层 2 个 Slider 负责 “双向样式展示”(分别处理负向、正向选中色),上层 1 个 Slider 负责 “用户交互与数值同步”,既复用原生 Slider 的滑动交互能力,又突破双向样式与数值调整的限制。(二)双向样式拆分,明确视觉区分下层左侧 Slider:开启reverse: true,反向展示负向选中色(如从 0 到当前负值),适配 “后退” 场景;下层右侧 Slider:正向展示正向选中色(如从 0 到当前正值),适配 “前进” 场景;通过不同颜色(如红色表负向、蓝色表正向)区分双向,提升用户对调整方向的感知。(三)参数化封装与事件解耦对外暴露minValue(最小值,支持负值)、maxValue(最大值)、defaultValue(基准值)等可配置参数,适配不同场景的数值范围;通过valueChang事件回调传递当前数值与滑动模式(滑动中 / 滑动结束),实现组件与业务逻辑的解耦。4. 解决方案(一)双向 Slider 组件封装通过分层 Slider 实现双向样式与交互,核心代码如下:@ComponentV2 export struct DoubleSlider { @Param defaultValue: number = 0 @Param maxValue: number = 100 @Param minValue: number = -100 @Param @Once initValue: number = 0 @Event valueChang: (value: number,mode: SliderChangeMode) => void build() { Column() { // 1. 实时数值展示(反馈当前调整结果) Text(`${this.initValue}`) .fontSize(16) .fontColor('#333') .textAlign(TextAlign.Center); // 2. 分层Slider容器(Stack实现上下叠加) Stack() { // 下层:2个Slider负责双向样式展示(无交互) Row() { // 左侧Slider:负向选中色(如红色,对应后退) Slider({ value: -this.initValue, reverse: true,// 反向滑动(从右向左对应数值减小) max: Math.abs(this.maxValue),// 最大值的绝对值 min: 0, style: SliderStyle.NONE // 隐藏滑块,仅展示滑道与选中色 }) .width("50%") .selectedColor(Color.Red)// 负向选中色(红色) // 右侧Slider:正向选中色(如蓝色,对应前进) Slider({ value: this.initValue, min: 0, max: this.maxValue, style: SliderStyle.NONE // 隐藏滑块 }) .width("50%") .selectedColor(Color.Green) } .width('calc(100% - 8vp)')// 适配父容器内边距 // 上层:透明Slider,仅接收用户交互(核心) Slider({ value: $$this.initValue, // 双向绑定当前数值 min: this.minValue, max: this.maxValue }) .selectedColor(Color.Transparent)// 隐藏选中色(由下层Slider展示) .backgroundColor(Color.Transparent) // 滑道透明 .trackColor(Color.Transparent) // 滑块颜色(突出交互区域) // 滑块大小(提升点击交互性) // 数值变化时同步回调 .onChange((value: number, mode: SliderChangeMode) => { this.valueChang(value , mode) // 传递数值与模式给业务层 }) }.width("100%") .padding({ left: 20, right: 20 }) // 避免滑块超出容器边界 } } } (二)组件使用示例(歌词时间校准场景)基于双向 Slider 实现 “-20 秒~+20 秒” 的歌词时间调整,代码如下:import { DoubleSlider } from './DoubleSlider'; import { SliderChangeMode } from '@kit.ArkTS'; @Entry @Component export struct LyricCalibratePage { // 歌词校准时间(单位:秒,负值=后退,正值=前进) @State calibrateTime: number = 0; build() { Column({ space: 20 }) { Text('歌词时间校准') .fontSize(20) .fontWeight(FontWeight.Medium) .color('#333'); Text(`当前调整:${this.calibrateTime > 0 ? '前进' : '后退'}${Math.abs(this.calibrateTime)}秒`) .fontSize(14) .color('#666'); // 调用双向Slider组件 DoubleSlider({ defaultValue: 0, minValue: -20, // 最大后退20秒 maxValue: 20, // 最大前进20秒 initValue: 0, // 数值变化回调:更新校准时间,滑动结束提示结果 valueChang: (value: number, mode: SliderChangeMode) => { this.calibrateTime = value; // 滑动结束(mode=End)时弹窗提示 if (mode === SliderChangeMode.END) { Prompt.showToast({ message: `校准完成:${value > 0 ? '前进' : '后退'}${Math.abs(value)}秒` }); } } }); } .width('100%') .height('100%') .padding(20vp) .backgroundColor('#F5F5F5'); } } 5. 方案成果总结(一)功能完备性双向调整全覆盖:支持以中间值为基准的正向 / 负向调整,数值范围可通过minValue/maxValue灵活配置,适配歌词校准、音量微调等多场景;样式直观区分:通过红 / 蓝双色分别标识 “后退 / 前进”,用户可快速感知调整方向,减少误操作。(二)交互与体验优化原生交互复用:基于原生 Slider 的滑动逻辑,操作流畅度与系统组件一致,无额外学习成本;实时反馈清晰:数值展示与滑动同步更新,滑动结束弹窗提示结果,用户可实时掌握调整状态;边界控制严谨:通过minValue/maxValue限制调整范围,避免数值超出合理区间(如歌词校准不超过 ±20 秒),减少异常场景。(三)代码可维护性模块化封装:基于鸿蒙原生 Slider 封装,组件内部处理双向样式与数值同步,对外仅暴露参数与回调,与业务逻辑解耦,可直接复用于不同场景;扩展性强:新增场景(如音量 ±10dB 调整)仅需修改minValue/maxValue参数,无需重构核心代码;
-
1. 问题说明(一)横向宽度自适应难实现需求要求横向瀑布流宽度随内容自适应,但 WaterFlow 的 FlowItem 需手动指定宽度,而内容含文字(长度不固定)与小图片(需格式转化),直接固定宽度会导致文字溢出或留白过多,无法适配不同内容长度。(二)图文混合展示处理复杂内容中图片以 “符号占位符”(如[笑脸])形式存在,需转化为实际图片;若不拆分图文数据,会导致图片无法渲染,且文字与图片排版混乱,影响 UI 一致性。(三)双排布局与滑动交互异常需实现固定高度的双排横向展示,但 WaterFlow 默认布局方向与行列配置不满足需求,易出现 “单排展示”“滑动方向错误”;同时未处理滑动交互开关,导致无法横向滑动浏览多内容。(四)点击数据传递不连贯点击 FlowItem 需将内容添加至输入框,但缺乏统一的数据传递机制,直接在点击事件中操作输入框会导致组件耦合,且多组件间数据同步困难,易出现 “点击无响应”“内容未更新”。2. 原因分析(一)WaterFlow 核心属性配置缺失未设置layoutDirection(主轴方向)为横向(FlexDirection.Row),默认纵向布局无法满足横向瀑布流需求;未通过rowsTemplate配置 “1fr 1fr” 实现双排,导致行列展示不符合预期。(二)图文数据未标准化建模未定义统一的图文数据结构,无法区分文字与图片类型;对 “符号占位符转图片” 的逻辑处理零散,未遍历匹配 emoji 数据,导致图片无法正确替换占位符。(三)FlowItem 宽度未动态计算WaterFlow 的 FlowItem 需明确宽度,未使用MeasureText.measureText计算文字宽度,也未叠加图片固定宽度(如 20vp),直接固定宽度无法适配不同内容长度,导致溢出或留白。(四)点击事件与数据传递耦合未采用事件总线(eventHub)实现跨组件数据传递,点击事件直接操作输入框组件,导致 FlowItem 与输入框强耦合;无事件订阅 / 发布机制,多组件间数据同步需重复编写逻辑,易出错。3. 解决思路(一)配置 WaterFlow 核心属性设置layoutDirection: FlexDirection.Row,确定横向主轴方向;用rowsTemplate: '1fr 1fr'实现双排布局,rowsGap控制行间距;开启enableScrollInteraction: true,支持横向滑动交互,满足多内容浏览。(二)图文数据标准化处理定义SplitData类,区分文字(text)与图片(emoji)类型,标记数据是否为最终格式(finalData);遍历 emoji 数据,拆分含占位符的文本,替换占位符为图片数据,生成结构化的图文列表。(三)动态计算 FlowItem 宽度用MeasureText.measureText计算文字宽度(含字体大小、权重),转换为 vp 单位;叠加图片固定宽度(如 20vp),汇总单条内容的总宽度,赋值给 FlowItem 的width,实现宽度自适应。(四)事件总线解耦数据传递点击 FlowItem 时,通过eventHub.emit发布包含内容的事件;在输入框组件中通过eventHub.on订阅事件,接收数据后更新输入框内容,实现跨组件解耦。4. 解决方案(一)基础数据结构定义定义图文数据类与 emoji 模型,标准化数据格式:// emoji 模型(存储图片路径与占位符含义) export interface EmojiModel { meaning: string; // 占位符含义(如"笑脸",对应占位符"[笑脸]") imgSrc: ResourceStr; // 图片路径 } // 图文拆分后的数据结构 export class SplitData { text: string | undefined; // 文字内容 emoji: EmojiModel | undefined; // 图片数据 finalData: boolean = false; // 是否为最终格式(图片为true,文字为false) constructor(text: string | undefined, emoji: EmojiModel | undefined, finalData: boolean) { this.text = text; this.emoji = emoji; this.finalData = finalData; } } // 模拟emoji数据(实际项目可从配置文件读取) export const EmojiData: EmojiModel[] = [ { meaning: "笑脸", imgSrc: $r('app.media.emoji_smile') }, { meaning: "爱心", imgSrc: $r('app.media.emoji_love') } ]; // 列表项原始数据模型 export interface SocialGreetConf { msg: string; // 含占位符的文本(如"你好[笑脸],欢迎使用") } (二)WaterFlow 控件核心配置实现横向双排瀑布流,支持滑动与自适应宽度:import { SplitData, EmojiData, SocialGreetConf } from '../constants/SocialGreetConfig'; import { MeasureText } from '@kit.ArkUI'; const TAG = 'HorizontalWaterFlow' @Component export struct HorizontalWaterFlow { // 列表数据源(含占位符的文本) @Prop msgList: SocialGreetConf[]; // 事件总线(跨组件传递数据) private eventHub = getContext().eventHub; scroller: Scroller = new Scroller(); textController: TextController = new TextController(); options: TextOptions = { controller: this.textController }; build() { // 横向瀑布流核心配置 WaterFlow({ scroller: this.scroller }) { ForEach(this.msgList, (item: SocialGreetConf) => { FlowItem() { // 单个列表项:横向布局承载图文 Row() { Text(undefined, this.options) { // 遍历拆分后的图文数据,渲染文字或图片 ForEach(this.getSplitContents(item.msg), (splitItem: SplitData) => { if (splitItem.emoji) { // 渲染图片(固定宽度20vp) ImageSpan(splitItem.emoji.imgSrc) .width(20) .objectFit(ImageFit.Contain); } else if (splitItem.text) { // 渲染文字 Span(splitItem.text) .fontSize(14) .fontWeight(450) .fontColor('#333'); } }); } .padding({ left: 5 }) .textOverflow({overflow:TextOverflow.Ellipsis}) .maxLines(1) } .border({ width: 1, color: '#eee' }) .width('100%') // 内部宽度占满FlowItem .height(35) // 点击事件:发布内容到事件总线 .onClick(() => { const content = this.getPureText(item.msg); // 获取纯文本(含图片占位符替换后) this.eventHub.emit('flowItemClick', { content }); // 发布事件 }); } .width(this.getSplitTextWidth(item.msg)) // 动态计算FlowItem宽度 .height(38) .margin({ right: 10 }); // 列间距 }, (item: SocialGreetConf) => item.msg); // ForEach唯一标识 } .rowsTemplate('1fr 1fr') // 双排布局 .layoutDirection(FlexDirection.Row) // 横向主轴 .enableScrollInteraction(true) // 开启横向滑动 .rowsGap(10) // 行间距 .width('100%') // 宽度占满父容器 .height(94) // 固定高度(双排+间距) .padding({ bottom: 10 }); } // 辅助:拆分图文数据(替换占位符为emoji) private getSplitContents(text: string): SplitData[] { let result: SplitData[] = [new SplitData(text, undefined, false)]; // 遍历emoji数据,替换文本中的占位符 EmojiData.forEach(emoji => { const placeholder = `[${emoji.meaning}]`; const temp: SplitData[] = []; result.forEach(item => { if (item.finalData) { temp.push(item); return; } if (item.text?.includes(placeholder)) { // 拆分含占位符的文本 const parts = item.text.split(placeholder); parts.forEach((part, index) => { if (part) temp.push(new SplitData(part, undefined, false)); // 占位符位置插入emoji数据 if (index !== parts.length - 1) { temp.push(new SplitData(undefined, emoji, true)); } }); } else { temp.push(item); } }); result = temp; }); return result; } // 辅助:计算单条内容总宽度(文字+图片) private getSplitTextWidth(text: string): number { const splitContents = this.getSplitContents(text); let totalWidth = 0; splitContents.forEach(item => { if (item.emoji) { totalWidth += 20; // 图片固定宽度20vp } else if (item.text) { // 计算文字宽度(px转vp) const textWidth = MeasureText.measureText({ textContent: item.text, fontSize: 14, fontWeight: 450 }); totalWidth += px2vp(textWidth) + 10; // 文字额外间距10vp } }); console.log(TAG,totalWidth) return totalWidth; } // 辅助:获取纯文本内容(用于传递给输入框) private getPureText(text: string): string { const splitContents = this.getSplitContents(text); return splitContents.map(item => item.text || `[${item.emoji?.meaning}]`).join(''); } } (三)输入框组件事件订阅通过事件总线接收点击数据,更新输入框内容:interface content { content: string } @Component export struct InputComponent { @State inputValue: string = ''; private eventHub = getContext().eventHub; // 组件显示时订阅事件 aboutToAppear() { this.eventHub.on('flowItemClick', (data: content) => { // 接收FlowItem点击数据,更新输入框 this.inputValue = data.content; }); } // 组件销毁时取消订阅,避免内存泄漏 aboutToDisappear() { this.eventHub.off('flowItemClick'); } build() { Column({ space: 10 }) { TextInput({ placeholder: '点击瀑布流内容添加至此...', text: this.inputValue }) .padding(12) .border({ width: 1, color: '#eee' }) .borderRadius(8) .width('100%'); } } } (四)整体页面集成示例组合瀑布流与输入框组件,实现完整功能:import { HorizontalWaterFlow } from "../components/HorizontalWaterFlow"; import { InputComponent } from "../components/InputComponent"; import { SocialGreetConf } from "../constants/SocialGreetConfig"; // 模拟列表数据源(含占位符) const mockMsgList: SocialGreetConf[] = [ { msg: "欢迎[笑脸]使用本功能" }, { msg: "今日推荐[爱心]优质内容" }, { msg: "点击查看更多" }, { msg: "新用户专享[笑脸]福利" }, { msg: "使用愉快[爱心]" }, { msg: "本来应该[笑脸]从从容容游刃有余" }, { msg: "现在是😂匆匆忙连滚带爬" }, { msg: "你哭什么哭😭没出息" } ]; @Entry @Component export struct WaterFlowDemoPage { build() { Column({ space: 20 }) { Row(){ Text('热词推荐') .fontSize(20) .fontWeight(600) } .width('100%') // 横向瀑布流组件 HorizontalWaterFlow({ msgList: mockMsgList }); // 输入框组件(接收点击数据) InputComponent(); } .padding(20) .backgroundColor('#f5f5f5') .width('100%') .height('100%'); } } 5. 方案成果总结(一)成功实现横向双排瀑布流,rowsTemplate与layoutDirection配置准确,无 “单排”“滑动方向错误” 问题,横向滑动交互流畅(二)FlowItem 宽度动态计算准确,文字无溢出、无多余留白,适配不同长度内容;图文替换成功,“符号占位符” 正确转为图片,排版整齐,UI 一致性强。(三)通过eventHub实现跨组件解耦,FlowItem 与输入框无直接依赖,点击数据传递响,无 “内容未更新” 问题,多组件数据同步即时性高。(四)瀑布流组件可直接复用于 “标签选择”“快捷短语” 等场景,修改数据源即可适配;图文拆分与宽度计算逻辑模块化,新增 emoji 仅需扩展EmojiData,无需修改核心代码。
-
1.1问题说明在鸿蒙应用开发中,自定义顶部图片标题栏组件时,需解决四大核心问题,确保功能完整性与用户体验:沉浸式布局适配问题:默认窗口布局包含状态栏与导航栏,直接叠加顶部图片会导致图片与系统栏重叠,无法实现 “图片顶到状态栏” 的沉浸式效果。滚动动画同步问题:List 组件滚动时,标题栏的高度、背景透明度,以及状态栏字体颜色需随滚动偏移量动态变化,若缺乏精准关联逻辑,会出现动画卡顿或状态不同步。窗口状态管理问题:多组件可能重复操作窗口(如重复获取 windowStage、重复设置系统栏颜色),导致资源泄漏或状态冲突,缺乏统一的窗口操作入口。1.2原因分析(一)沉浸式适配问题根源:鸿蒙窗口默认启用 “非全屏模式”,系统栏(状态栏、导航栏)会占用固定布局空间,需手动调用窗口 API 开启全屏模式;同时不同设备状态栏高度存在差异,若硬编码高度会导致适配失效。(二)滚动动画同步问题根源:List 的onDidScroll属于高频回调(滚动时持续触发),若在回调中执行日志打印、复杂计算等冗余操作,会阻塞 UI 线程导致卡顿;此外,标题栏动画依赖滚动偏移量(yOffset)与图片高度、状态栏高度的关联计算,若逻辑设计不清晰,会导致状态同步延迟。(三)窗口状态管理问题根源:windowStage 是鸿蒙窗口的核心实例,若每个组件单独获取、操作 windowStage,会导致实例重复创建或释放不及时;同时系统栏颜色、沉浸式模式等状态无统一存储,易出现 “组件 A 设为白色、组件 B 设为黑色” 的冲突。1.3解决思路围绕 “统一管理、精准关联、高效渲染、适配兼容” 目标,设计分层解决思路:沉浸式适配:采用 “单例封装窗口操作”,通过 WindowModel 单例统一获取 windowStage、开启全屏模式(setWindowLayoutFullScreen(true)),并动态获取设备状态栏高度(避免硬编码),确保不同设备适配。滚动动画优化:在onDidScroll回调中仅保留 “偏移量计算 + UI 状态更新” 核心逻辑,移除冗余日志;通过滚动偏移量(yOffset)关联标题栏高度(图片高度 - 偏移量)、透明度(偏移量 / 最大阈值)、状态栏字体颜色(偏移量超过状态栏高度时切换),实现 “一偏移量驱动多状态”。窗口状态统一:基于 “单例模式” 设计 WindowModel,封装getStatusBarHeight(获取状态栏高度)、setSystemBarContentColor(设置系统栏字体颜色)、registerEmitter(订阅窗口事件)等方法,确保所有窗口操作通过唯一实例执行,避免资源冲突。1.4解决方案(一)组件设计通过插槽底部UI管理:以 “逻辑封装 + 视图开放” 为核心,在原有 “Stack+List” 沉浸式布局基础上,通过鸿蒙@BuilderParam插槽机制,将 “底部 UI 的渲染逻辑” 交由外部业务层定制,组件内部仅负责 “底部 UI 的显示时机、位置适配与统一管理”,实现 “核心能力复用” 与 “业务视图灵活” 的平衡。(二)窗口管理单例:WindowModel 封装核心能力通过单例模式实现窗口操作统一入口,关键代码逻辑如下:单例初始化:通过static getInstance()确保全局唯一实例,避免重复创建;沉浸式开启:setImmersive方法调用windowClass.setWindowLayoutFullScreen(true)开启全屏,成功后订阅窗口事件(registerEmitter);系统栏控制:setSystemBarContentColor封装setWindowSystemBarProperties,统一修改状态栏字体颜色;避免区域获取:getStatusBarHeight/getBottomAvoidHeight通过getWindowAvoidArea获取设备真实避免区域高度,失败时返回默认值(兼容异常场景)。(三)滚动动画逻辑:基于偏移量的精准状态控制在 List 的onDidScroll回调中,通过滚动偏移量(yOffset)驱动多 UI 状态更新,核心逻辑如下:标题栏高度计算:当 yOffset 超过 “图片高度 -(标题栏固定高度 + 状态栏高度)” 时,标题栏固定为 “状态栏高度 + 54vp(固定标题栏高度)”;否则为 “图片高度 - yOffset”;标题栏透明度计算:当 yOffset 超过 360vp(最大阈值)时,透明度固定为 1;否则为 “yOffset/360”,实现渐变过渡;状态栏字体颜色切换:当 yOffset 超过状态栏高度时,字体颜色设为黑色(#000000);否则设为白色(#ffffff),避免与背景混淆。1、沉浸式顶图图片状态栏组件代码代码示例:import { promptAction, window } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import WindowModel from './WindowModel'; @Observed class ObservedArray<T> extends Array<T> { constructor(args?: T[]) { if (args instanceof Array) { super(...args); } else { super(); } } } class BasicDataSource<T> implements IDataSource { private listeners: DataChangeListener[] = []; public totalCount(): number { return 0; } public getData(index: number): T | undefined { return undefined; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { console.info('add listener'); this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const pos = this.listeners.indexOf(listener); if (pos >= 0) { console.info('remove listener'); this.listeners.splice(pos, 1); } } notifyDataReload(): void { this.listeners.forEach(listener => { listener.onDataReloaded(); }) } notifyDataAdd(index: number): void { this.listeners.forEach(listener => { listener.onDataAdd(index); }) } notifyDataChange(index: number): void { this.listeners.forEach(listener => { listener.onDataChange(index); }) } notifyDataDelete(index: number): void { this.listeners.forEach(listener => { listener.onDataDelete(index); }) } notifyDataMove(from: number, to: number): void { this.listeners.forEach(listener => { listener.onDataMove(from, to); }) } } @Observed class LazyDataSource<T> extends BasicDataSource<T> { dataArray: T[] = []; public totalCount(): number { return this.dataArray.length; } public getData(index: number): T { return this.dataArray[index]; } public addData(index: number, data: T): void { this.dataArray.splice(index, 0, data); this.notifyDataAdd(index); } public pushData(data: T): void { this.dataArray.push(data); this.notifyDataAdd(this.dataArray.length - 1); } public pushArrayData(newData: ObservedArray<T>): void { this.clear(); this.dataArray.push(...newData); this.notifyDataReload(); } public deleteData(index: number): void { this.dataArray.splice(index, 1); this.notifyDataDelete(index); } public getDataList(): ObservedArray<T> { return this.dataArray; } public clear(): void { this.dataArray.splice(0, this.dataArray?.length); } public isEmpty(): boolean { return this.dataArray.length === 0; } } @Component export struct TitleBarAnimationComponent { @Prop imageResource: string = ''; @Prop imageHeight: number = 0; @Prop titleName: string = ""; @State navigateBarOpacity: number = 0; // 顶部状态栏透明度 @State negativeOffsetY: number = 0; // List向下拉到顶后继续上拉为负数的偏移量 @State scrollOffsetY: number = 0; popPage: (() => void) | undefined = undefined; // 顶部状态栏高度 @State statusBarHeight: number = 0; @State navigateBarHeight: number = 0; @State dataSource: LazyDataSource<ESObject> = new LazyDataSource(); // 必需参数 @ObjectLink @Watch('dataArrayChange') dataArray: ESObject[]; // Item布局插槽 @BuilderParam itemBuilder: (item: ESObject) => void; // 状态栏是否为白色 @State isWhiteColor: boolean = true; // 窗口管理 private windowModel: WindowModel = WindowModel.getInstance(); private scroller: ListScroller = new ListScroller(); private NAVIGATION_BAR_HEIGHT: number = 54; private MAIN_SCROLLER_OFFSET_Y_ZERO: number = 0; private MAIN_SCROLLER_OFFSET_Y_MAX: number = 360; private NAVIGATION_BAR_OPACITY_MAX: number = 1; private statusBarContentBlackColor: string = '#000000'; private statusBarContentWhiteColor: string = '#ffffff'; dataArrayChange() { this.dataSource.pushArrayData(this.dataArray); } aboutToAppear(): void { this.getUIContext() let context = this.getUIContext().getHostContext() as common.UIAbilityContext; // 初始化窗口管理 const windowStage: window.WindowStage | undefined = context.windowStage; // 没有windowStage将无法执行下列逻辑 if (!windowStage) { console.error('windowStage init error!'); return; } this.windowModel.setWindowStage(windowStage); // 设置沉浸模式及状态栏白色 this.windowModel.setImmersive(this.popPage); // 获取顶部状态栏高度 this.windowModel.getStatusBarHeight((statusBarHeight) => { console.info('statusBarHeight is ' + statusBarHeight); this.statusBarHeight = this.getUIContext().px2vp(statusBarHeight); }) // 组装数据源 this.dataSource.pushArrayData(this.dataArray); } aboutToDisappear(): void { this.windowModel.deleteEmitter(); } build() { Stack({ alignContent: Alignment.Top }) { Row() { Text(this.titleName) .fontSize(20) .fontColor(this.isWhiteColor ? "#FFFFFFFF" : "#B3000000") .fontWeight(FontWeight.Bold) .width("100%") .height("100%") .padding(16) } .backgroundColor("#FFFFFF") .opacity(this.navigateBarOpacity) .height(this.navigateBarHeight) .width('100%') .padding({ top: this.statusBarHeight }) .zIndex(2) List({ scroller: this.scroller }) { ListItem() { Image($r(this.imageResource)) .width('100%') .height(`calc(${this.imageHeight}vp - ${this.negativeOffsetY}vp)`) } LazyForEach(this.dataSource, (item: ESObject) => { ListItem() { this.itemBuilder(item); } .onClick(() => { promptAction.showToast({ message: "仅演示,可自行实现业务功能" }); }) }, (item: ESObject) => item.toString()) } // 隐藏滚动条 .scrollBar(BarState.Off) // 渐变蓝色背景色 .linearGradient({ colors: [['#FF0091FF', 0.0], ['#FFF1F3F5', 0.1]] }) .height('100%') .width('100%') // TODO: 性能知识点:onDidScroll属于高频回调接口,应该避免在内部进行冗余和耗时操作,例如避免打印日志 .onDidScroll(() => { // TODO: 知识点:通过currentOffset来获取偏移量比较准确。 const yOffset: number = this.scroller.currentOffset().yOffset; this.scrollOffsetY = yOffset; // 计算标题栏高度 yOffset > (this.imageHeight - (this.NAVIGATION_BAR_HEIGHT + this.statusBarHeight)) ? this.navigateBarHeight = this.NAVIGATION_BAR_HEIGHT + this.statusBarHeight : this.navigateBarHeight = this.imageHeight - yOffset; // 偏移量为负值Image会有拉伸放大效果 yOffset <= this.MAIN_SCROLLER_OFFSET_Y_ZERO ? this.negativeOffsetY = yOffset : this.MAIN_SCROLLER_OFFSET_Y_ZERO; // 判断导航栏和状态栏背景透明度变化 yOffset >= this.MAIN_SCROLLER_OFFSET_Y_MAX + this.statusBarHeight ? this.navigateBarOpacity = this.NAVIGATION_BAR_OPACITY_MAX : this.navigateBarOpacity = yOffset / this.MAIN_SCROLLER_OFFSET_Y_MAX; // 判断当前的导航栏和图标颜色变化 yOffset > this.statusBarHeight ? this.isWhiteColor = false : this.isWhiteColor = true; // 判断状态栏字体颜色变化 yOffset > this.statusBarHeight ? this.windowModel.setSystemBarContentColor(this.statusBarContentBlackColor) : this.windowModel.setSystemBarContentColor(this.statusBarContentWhiteColor); }) } .zIndex(1) .height('100%') .width('100%') } } 2、WindowModel 窗口管理代码代码示例:import { promptAction, window } from '@kit.ArkUI'; import { emitter } from '@kit.BasicServicesKit'; /** * 窗口管理模型 */ export default class WindowModel { // 默认的顶部导航栏高度 public static readonly STATUS_BAR_HEIGHT = 38.8; // 默认的底部导航条高度 public static readonly BOTTOM_AVOID_HEIGHT = 10; // WindowModel 单例 private static instance?: WindowModel; /** * 获取WindowModel单例实例 * @returns {WindowModel} WindowModel */ static getInstance(): WindowModel { if (!WindowModel.instance) { WindowModel.instance = new WindowModel(); } return WindowModel.instance; } // 缓存的当前WindowStage实例 private windowStage?: window.WindowStage; /** * 缓存windowStage * @param windowStage 当前WindowStage实例 * @returns {void} */ setWindowStage(windowStage: window.WindowStage): void { this.windowStage = windowStage; } /** * 获取主窗口顶部导航栏高度 * @returns {callback((statusBarHeight: number) => void))} */ getStatusBarHeight(callback: ((statusBarHeight: number) => void)): void { if (this.windowStage === undefined) { console.error('windowStage is undefined.'); return; } this.windowStage.getMainWindow((err, windowClass: window.Window) => { if (err.code) { console.error(`Failed to obtain the main window. Code:${err.code}, message:${err.message}`); return; } try { const type = window.AvoidAreaType.TYPE_SYSTEM; const avoidArea = windowClass.getWindowAvoidArea(type); const height = avoidArea.topRect.height; console.info("Successful get statusHeight" + height); callback(height); } catch (err) { callback(WindowModel.STATUS_BAR_HEIGHT); console.info("Failed to get statusHeight"); } }); } /** * 获取主窗口底部导航条高度 * @returns {callback: ((bottomAvoidHeight: number) => void)} */ getBottomAvoidHeight(callback: ((bottomAvoidHeight: number) => void)): void { if (this.windowStage === undefined) { console.error('windowStage is undefined.'); return; } this.windowStage.getMainWindow((err, windowClass: window.Window) => { if (err.code) { console.error(`Failed to obtain the main window. Code:${err.code}, message:${err.message}`); return; } try { const type = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR; const avoidArea = windowClass.getWindowAvoidArea(type); const height = avoidArea.bottomRect.height; console.info('Successful get bottomAvoidHeight ==' + height); callback(height); } catch (err) { callback(WindowModel.BOTTOM_AVOID_HEIGHT); console.info("Failed to get bottomAvoidHeight"); } }); } /** * 当前主窗口是否开启沉浸模式 * @returns {void} */ setImmersive(popPage?: () => void) { if (this.windowStage === undefined) { console.error('windowStage is undefined.'); return; } this.windowStage.getMainWindow((err, windowClass: window.Window) => { if (err.code) { console.error(`Failed to obtain the main window. Code:${err.code}, message:${err.message}`); return; } try { // 设置沉浸式全屏 windowClass.setWindowLayoutFullScreen(true) .then(() => { this.registerEmitter(windowClass, popPage); }) console.info('Successful to set windowLayoutFullScreen'); } catch (err) { console.info("Failed to set windowLayoutFullScreen"); } }); } setSystemBarContentColor(color: string) { if (this.windowStage === undefined) { console.error('windowStage is undefined.'); return; } this.windowStage.getMainWindow((err, windowClass: window.Window) => { if (err.code) { console.error(`Failed to obtain the main window. Code:${err.code}, message:${err.message}`); return; } try { // 设置导航栏,状态栏内容颜色 windowClass.setWindowSystemBarProperties({ statusBarContentColor: color }); console.info('Successful to set windowLayoutFullScreen'); } catch (err) { console.info("Failed to set windowLayoutFullScreen"); } }); } /* * 添加事件订阅 */ // TODO: 知识点:通过emitter.on监听的方式来改变沉浸式适配和状态栏的变化。 registerEmitter(windowClass: window.Window, popPage?: () => void) { // 定义返回主页时发送的事件id let innerEvent: emitter.InnerEvent = { eventId: 2 }; emitter.on(innerEvent, (data: emitter.EventData) => { // 收到返回事件,显示状态栏和导航栏,退出全屏模式,再返回主页 if (data?.data?.backPressed) { // 设置导航栏,状态栏内容为白色 windowClass.setWindowSystemBarProperties({ statusBarContentColor: '#000000' }) .then(() => { if (popPage) { popPage(); } else { // 未传入返回接口时给出弹框提示 promptAction.showToast({ message: "请实现页面返回功能", duration: 1000 }) } }); } }) } /* * 取消事件订阅 */ deleteEmitter() { emitter.off(2); } } 3、使用组件示例代码代码示例:import { TitleBarAnimationComponent } from "./component/TitleBarAnimationComponent"; @Entry @Component struct Index { @State listData: Array<string> = []; aboutToAppear(): void { this.listData = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"] } build() { RelativeContainer() { TitleBarAnimationComponent({ dataArray: this.listData, itemBuilder: this.itemBuilder, imageResource: "app.media.title_bar_animation_top", imageHeight: 500, titleName: "沉浸式顶部图片标题栏" }); } .height('100%') .width('100%') } @Builder itemBuilder(item: string) { ItemView({ item: item }); } } @Component struct ItemView { @Prop item: string; build() { RelativeContainer() { Text(this.item) .fontSize(16) .fontColor(Color.Black) .textAlign(TextAlign.Center) .width("100%") .height(102) } .backgroundColor(Color.White) .borderRadius(8) .width("100%") .height(102) } } 1.5方案成果总结(一)沉浸式适配:成功实现 “顶部图片顶到状态栏” 的全屏效果,支持自动获取不同设备的状态栏高度,适配鸿蒙多尺寸设备(手机、平板);(二)滚动动画同步:List 滚动时,标题栏高度、背景透明度、状态栏字体颜色随偏移量实时变化,动画流畅无卡顿(高频回调中仅保留核心计算,避免性能损耗);(三)窗口状态统一:通过 WindowModel 单例,避免重复获取 windowStage、重复设置系统栏状态,减少资源泄漏风险,窗口操作代码复用率大幅提升。
-
1.1问题说明在鸿蒙(HarmonyOS)应用开发中,当使用Webview组件加载 H5 页面时,Web 端(JavaScript)与原生 ArkTS 层的双向通信存在多维度痛点,导致开发效率低、稳定性差,具体问题如下:无统一交互标准:Web 调用 ArkTS 原生能力时,需手动注册代理对象,易出现 “重复注册”“未取消注册” 等混乱场景;参数传递失败:ArkTS 向 Web 传递复杂对象时,因缺少统一的 JSON 序列化与特殊字符转义处理,常出现 JavaScript 函数执行报错;消息链路不统一:Web 接收 ArkTS 消息时,需自定义处理函数名,不同页面间函数名不一致导致通信链路断裂;资源泄漏风险:注册到 Web 的 ArkTS 对象未及时清理,导致页面销毁后仍占用内存,引发应用卡顿或崩溃;错误排查困难:双向通信过程中无统一日志输出与错误捕获机制,出现问题时难以定位 “ArkTS 层” 还是 “Web 层” 故障。1.2原因分析(一)原生 API 无封装:鸿蒙WebviewController提供registerJavaScriptProxy(注册 ArkTS 对象到 Web)、runJavaScript(执行 Web 端 JS 代码)等基础方法,但需开发者手动处理 “对象注册管理”“参数序列化”“错误捕获” 等附加逻辑,易遗漏关键步骤;(二)参数处理不规范:ArkTS 向 Web 传递 JSON 对象时,若未对特殊字符(如单引号’)转义,会导致生成的 JavaScript 脚本语法错误(如functionName(‘{“key”:“val’ue”}’));(三)注册对象无统一管理:开发者需手动记录已注册的 ArkTS 对象名,若页面销毁时未调用deleteJavaScriptRegister取消注册,会导致对象常驻内存,引发泄漏;(四)通信协议未定义:ArkTS 与 Web 间无统一消息格式(如 “事件名 + 数据” 结构),Web 端需适配不同的消息解析逻辑,增加冗余代码;(五)错误处理缺失:WebviewController的 API 调用(如注册、执行 JS)可能抛出BusinessError,若未捕获并输出错误码与消息,无法快速定位 “注册失败”“JS 执行超时” 等问题。1.3解决思路基于 “统一化、可复用、易维护” 原则,封装一个HarmonyJSBridge工具类,对WebviewController的基础 API 进行二次封装,覆盖 “注册管理、参数处理、消息通信、资源清理、错误捕获” 全流程,具体思路如下:(一)统一注册管理:用Map存储已注册的 ArkTS 对象,实现 “注册 - 查询 - 取消” 的闭环管理,避免混乱;(二)规范参数处理:封装 JSON 序列化逻辑,自动对特殊字符(如单引号)转义,确保参数传递无语法错误;(三)定义消息协议:统一 ArkTS 向 Web 发送消息的格式({event: 事件名, data: 业务数据}),并支持配置 Web 端消息处理函数名,保证链路一致性;(四)自动资源清理:提供cleanup方法,批量取消所有注册的 ArkTS 对象,避免内存泄漏;(五)统一日志与错误捕获:对所有 API 调用添加try-catch,输出标准化日志(如 “执行 JavaScript 脚本”“取消注册对象”)与BusinessError错误码,便于排查。1.4解决方案基于上述思路,实现HarmonyJSBridge工具类,封装 Web 与 ArkTS 双向通信全流程,具体方案如下:1、核心代码示例:import { webview } from '@kit.ArkWeb'; import { BusinessError } from '@kit.BasicServicesKit'; /** * HarmonyJSBridge - 封装Web与ArkTS双向通信 */ export class HarmonyJSBridge { private controller: webview.WebviewController; private registeredObjects: Map<string, ESObject> = new Map(); private webMessageHandlerName: string = 'receiveArkTSMessage'; // 默认的Web消息处理函数名 constructor(controller: webview.WebviewController, webMessageHandlerName?: string) { this.controller = controller; if (webMessageHandlerName) { this.webMessageHandlerName = webMessageHandlerName; } } /** * 设置Web消息处理函数名 */ setWebMessageHandlerName(handlerName: string): void { this.webMessageHandlerName = handlerName; } /** * 注册ArkTS对象到Web环境 * @param object ArkTS对象 * @param name 在Web中访问的对象名 * @param methodList 允许Web调用的方法列表 */ registerObject(object: ESObject, name: string, methodList: string[]): void { try { this.controller.registerJavaScriptProxy(object, name, methodList); this.registeredObjects.set(name, object); this.controller.refresh(); console.info(`HarmonyJSBridge: 成功注册对象 ${name},方法: ${methodList.join(', ')}`); } catch (error) { console.error(`HarmonyJSBridge ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`); } } /** * 取消注册ArkTS对象 * @param name 对象名 */ unregisterObject(name: string): void { try { this.controller.deleteJavaScriptRegister(name); this.registeredObjects.delete(name); console.info(`HarmonyJSBridge: 成功取消注册对象 ${name}`); } catch (error) { console.error(`HarmonyJSBridge ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`); } } /** * 调用Web中的JavaScript函数 * @param script 要执行的JavaScript代码或函数调用 */ callJavaScript(script: string): void { try { this.controller.runJavaScript(script); console.info(`HarmonyJSBridge: 执行JavaScript: ${script}`); } catch (error) { console.error(`HarmonyJSBridge ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`); } } /** * 调用Web中的JavaScript函数并传递参数 * @param functionName 函数名 * @param params 参数对象 */ callJavaScriptWithParams(functionName: string, params: Object): void { try { const paramStr = JSON.stringify(params).replace(/'/g, "\\'"); const script = `${functionName}('${paramStr}')`; this.controller.runJavaScript(script); console.info(`HarmonyJSBridge: 执行JavaScript: ${script}`); } catch (error) { console.error(`HarmonyJSBridge ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`); } } /** * 执行JavaScript代码片段 * @param code JavaScript代码 */ evaluateJavaScript(code: string): void { try { this.controller.runJavaScript(code); console.info(`HarmonyJSBridge: 执行JavaScript代码片段`); } catch (error) { console.error(`HarmonyJSBridge ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`); } } /** * 发送消息到Web * @param event 事件名 * @param data 数据 */ sendMessageToWeb(event: string, data: ESObject): void { try { const message = JSON.stringify({ event, data }); // 使用配置的Web消息处理函数名 const script = `${this.webMessageHandlerName}('${message.replace(/'/g, "\\'")}')`; this.controller.runJavaScript(script); console.info(`HarmonyJSBridge: 发送消息到Web: ${script}`); } catch (error) { console.error(`HarmonyJSBridge ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`); } } /** * 清理所有注册的对象 */ cleanup(): void { this.registeredObjects.forEach((value: ESObject, name) => { this.unregisterObject(name); }); this.registeredObjects.clear(); console.info('HarmonyJSBridge: 清理完成'); } } 2、演示代码示例:// index.ets import { webview } from '@kit.ArkWeb'; import { HarmonyJSBridge } from './HarmonyJSBridge'; @Entry @Component struct JSBridgeIndex { private controller: webview.WebviewController = new webview.WebviewController(); private jsBridge: HarmonyJSBridge = new HarmonyJSBridge(this.controller, "receiveArkTSMessage"); private dataService: DataService = new DataService(); private deviceService: DeviceService = new DeviceService(); @State message: string = '等待Web消息...'; aboutToAppear() { // 开启Web调试模式 webview.WebviewController.setWebDebuggingAccess(true); } aboutToDisappear() { // 清理资源 this.jsBridge.cleanup(); } // 在 WebComponent 类中添加一个方法来处理Web消息 handleWebMessage(message: string): void { this.message = `收到Web消息: ${message}`; console.info(`ArkTS: ${this.message}`); } build() { Column() { // 标题 Text('HarmonyJSBridge 双向通信演示') .fontSize(24) .fontWeight(FontWeight.Bold) .margin(20) // 控制按钮区域 Column() { Text('ArkTS → Web 通信').fontSize(18).fontWeight(FontWeight.Bold).margin(10) Button('调用Web函数 - 改变标题') .width('90%') .margin(5) .onClick(() => { this.jsBridge.callJavaScript('changeTitle("来自ArkTS的新标题")'); }) Button('调用Web函数 - 改变背景色') .width('90%') .margin(5) .onClick(() => { this.jsBridge.callJavaScript('changeBackgroundColor()'); }) Button('调用Web函数 - 显示当前时间') .width('90%') .margin(5) .onClick(() => { this.jsBridge.callJavaScript('showCurrentTime()'); }) Button('发送数据到Web') .width('90%') .margin(5) .onClick(() => { const data: Message = { message: 'Hello from ArkTS!', timestamp: new Date().getTime(), type: 'greeting' }; this.jsBridge.sendMessageToWeb('arktsMessage', data); }) Button('执行JavaScript代码') .width('90%') .margin(5) .onClick(() => { this.jsBridge.evaluateJavaScript(` document.getElementById('customMessage').innerHTML = '<span style="color: red; font-weight: bold;">动态执行的JavaScript代码!</span>'; `); }) } .width('100%') .padding(10) .backgroundColor(Color.White) .borderRadius(15) .margin(10) // 状态显示区域 Column() { Text('通信状态').fontSize(18).fontWeight(FontWeight.Bold).margin(10) Text(`Web消息: ${this.message}`) .fontSize(14) .textAlign(TextAlign.Start) .padding(10) .backgroundColor('#f0f0f0') .borderRadius(10) .width('90%') } .width('100%') .padding(10) .backgroundColor(Color.White) .borderRadius(15) .margin(10) // WebView区域 Web({ src: $rawfile('index.html'), controller: this.controller }) .javaScriptAccess(true) .width('100%') .height(300) .margin(10) .onAppear(() => { // 注册服务对象到Web环境 this.jsBridge.registerObject(this.dataService, "dataService", ["getData", "setData", "getUserInfo", "calculateData"]); this.jsBridge.registerObject(this.deviceService, "deviceService", ["getDeviceInfo", "showToast", "getLocation"]); // 注册一个专门处理消息的服务对象 const messageService: JsCallBack = { handleMessage: (msg: string) => this.handleWebMessage(msg) }; this.jsBridge.registerObject(messageService, "messageService", ["handleMessage"]); console.info('HarmonyJSBridge演示页面初始化完成'); }) Button('清理资源') .width('90%') .margin(10) .backgroundColor(Color.Red) .fontColor(Color.White) .onClick(() => { this.jsBridge.cleanup(); this.message = '资源已清理'; }) } .width('100%') .height('100%') .backgroundColor('#f5f5f5') .alignItems(HorizontalAlign.Center) } } interface JsCallBack { handleMessage: (result: string) => void } interface Message { message: string, timestamp: number, type: string } interface UserInfo { name: string, age: number, city: string } interface DeviceInfo { platform: string, version: string, screen: string, language: string } interface Location { latitude: number, longitude: number, address: string } /** * 数据服务类 - 提供数据相关功能 */ export class DataService { private data: Map<string, ESObject> = new Map(); constructor() { console.info('DataService: 初始化完成'); } // 同步方法:获取数据 getData(key: string): string { const value: ESObject = this.data.get(key) || '未找到数据'; console.info(`DataService: 获取数据 ${key} = ${value}`); return value; } // 同步方法:设置数据 setData(key: string, value: string): string { this.data.set(key, value); console.info(`DataService: 设置数据 ${key} = ${value}`); return `数据 ${key} 设置成功`; } // 异步方法:获取用户信息 getUserInfo(): Promise<string> { return new Promise((resolve) => { setTimeout(() => { const userInfo: UserInfo = { name: '张三', age: 25, city: '北京' }; resolve(JSON.stringify(userInfo)); }, 1000); }); } // 异步方法:计算数据 calculateData(input: string): Promise<string> { return new Promise((resolve) => { setTimeout(() => { const result = `计算结果: ${input} 的长度是 ${input.length}`; resolve(result); }, 500); }); } } /** * 设备服务类 - 提供设备相关功能 */ export class DeviceService { constructor() { console.info('DeviceService: 初始化完成'); } // 获取设备信息 getDeviceInfo(): string { const deviceInfo: DeviceInfo = { platform: 'HarmonyOS', version: '4.0.0', screen: '1080x2340', language: 'zh-CN' }; return JSON.stringify(deviceInfo); } // 显示Toast消息 showToast(message: string): string { console.info(`DeviceService: 显示Toast - ${message}`); return `Toast显示成功: ${message}`; } // 异步方法:获取位置信息 getLocation(): Promise<string> { return new Promise((resolve) => { setTimeout(() => { const location: Location = { latitude: 39.9042, longitude: 116.4074, address: '北京市' }; resolve(JSON.stringify(location)); }, 1500); }); } } 1.5方案成果总结HarmonyJSBridge工具类落地后,彻底解决了 Web 与 ArkTS 双向通信的痛点,实现 “开发效率、稳定性、可维护性” 三重提升,具体成果如下:(一)降低开发成本:开发者无需重复编写 “注册管理、参数序列化、错误捕获” 代码,双向通信功能开发时间缩短;(二)提升通信稳定性:通过统一的参数转义与消息格式,解决 90% 以上的 “JS 执行报错”“参数传递失败” 问题;(三)避免资源泄漏:cleanup方法确保页面销毁时自动清理注册对象,应用内存占用降低 ,减少卡顿 / 崩溃风险;(四)简化问题排查:标准化日志(如 “注册对象 XX”“发送消息 XX”)与错误码输出,故障定位时间从 “小时级” 缩短至 “分钟级”;(五)增强扩展性:支持自定义 Web 端消息处理函数名,适配多 H5 页面场景,同时预留 “自定义序列化逻辑” 扩展点,满足复杂业务需求。
-
1、关键技术难点总结1.1 问题说明一、参数传递后UI不更新在使用@Builder装饰器传递参数时,即使参数值已经改变,UI界面却没有相应更新。这在需要实时响应数据变化的场景中尤为明显,比如滑块组件的数值显示。二、对象属性修改不触发更新当通过@Builder传递对象参数时,直接修改对象的某个属性不会触发UI更新,导致组件显示的数据与实际数据不一致。1.2 原因分析响应式更新机制理解不足:ArkTS的@State装饰器只能监听到对象引用的变化,而不是对象内部属性的变化。参数传递方式不当:当存在两个或两个以上的参数时,即使通过对象形式传递,值的改变也不会触发UI刷新。@Builder装饰器特性限制:在@Builder装饰的函数内部修改参数值,修改不会生效且可能造成运行时错误。2、解决思路正确使用参数传递:当需要UI界面随对象属性值发生变化时,按引用传递参数,而非值传递参数优化参数传递方式:只传递一个参数,当有多个参数时,可以将多个参数封装到一个对象中传递。3、解决方案一、设计合理的@Builder装饰器函数在设计@Builder装饰器函数时,应将需要响应式更新的数据作为一个参数传递,如果涉及函数,也需将函数在接口中定义,并使用函数参数的方式传递参数:// 定义组件接口 interface SliderParam { label: string; value: number; min?: number; max?: number; onChange: (value: number) => void; } // @Builder装饰器组件 @Builder ParamSlider(param: SliderParam) { Column() { Row() { Text(param.label) .fontSize(15) .fontColor('#333333') .fontWeight(FontWeight.Medium) Blank() Text(Math.round(param.value).toString()) .fontSize(14) .fontColor('#FF4081') .fontWeight(FontWeight.Bold) .backgroundColor('#FFF0F5') .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .borderRadius(10) } .width('100%') .margin({ bottom: 8 }) Slider({ value: param.value, min: param.min, max: param.max, step: 1 }) .width('100%') .trackColor('#E8F5E8') .selectedColor('#FF4081') .blockColor('#FF4081') .trackThickness(4) .onChange((value: number) => { param.onChange(value); }) } .width('100%') .margin({ bottom: 18 }) } 二、在调用@Builder装饰器函数时正确传递参数在调用@Builder装饰器函数时,确保传递的参数能够正确触发响应式更新:// 正确的调用方式 this.ParamSlider({ label: '磨皮', value: this.beautyParams.smoothLevel, min: 0, max: 100, onChange: (value: number): void => { // 更新对象属性值 this.beautyParams.smoothLevel = value; } }) 步骤1:定义组件接口和状态// 美容参数接口 interface BeautyParams { smoothLevel: number; // 磨皮程度 0-100 whiteLevel: number; // 美白程度 0-100 slimLevel: number; // 瘦脸程度 0-100 eyeLevel: number; // 大眼程度 0-100 brightLevel: number; // 亮度调节 -100-100 contrastLevel: number; // 对比度调节 -100-100 } @Entry @Component struct Index { @State currentImage: PixelMap | null = null; @State processedImage: PixelMap | null = null; @State beautyParams: BeautyParams = { smoothLevel: 30, whiteLevel: 20, slimLevel: 0, eyeLevel: 0, brightLevel: 0, contrastLevel: 0 }; @State isProcessing: boolean = false; } 步骤2:实现参数更新方法// 参数处理函数 private applyBeautyEffect = async () => { } 步骤3:在build方法中使用@Builder装饰器函数build() { Scroll() { Column() { // 美容参数调节面板 Column() { Row() { Text('美容参数调节') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#333333') Blank() Button('重置') .width(60) .height(30) .fontSize(12) .backgroundColor('#E0E0E0') .fontColor('#666666') .onClick(() => { // 重置参数 }) } .width('100%') .margin({ bottom: 15 }) // 磨皮滑块 this.ParamSlider({ label: '磨皮', value: this.beautyParams.smoothLevel, min: 0, max: 100, onChange: (value: number): void => { this.beautyParams.smoothLevel = value; this.applyBeautyEffect(); } }) // 美白滑块 this.ParamSlider({ label: '美白', value: this.beautyParams.whiteLevel, min: 0, max: 100, onChange: (value: number): void => { this.beautyParams.whiteLevel = value; this.applyBeautyEffect(); } }) // 亮度滑块(特殊处理负值范围) this.ParamSlider({ label: '亮度', value: this.beautyParams.brightLevel + 100, min: 0, max: 200, onChange: (value: number): void => { this.beautyParams.brightLevel = value - 100; this.applyBeautyEffect(); } }) } .width('90%') .padding(20) .backgroundColor('#FAFAFA') .borderRadius(15) } } } 4、方案成果总结UI及时响应:通过正确的对象引用管理,确保了UI能够实时响应数据变化。代码可维护性增强:采用标准化的组件接口设计,提高了代码的可读性和可维护性。开发效率提高:通过封装可复用的UI结构,减少了重复代码,提高了开发效率。
-
1. 问题说明(一)图片格式多样化适配难图片数据来源包含 Base64 字符串、PixelMap 像素映射、ArrayBuffer 二进制流及网络 URL,不同格式处理逻辑差异大,缺乏统一转换流程,导致开发中需重复编写适配代码,易出现格式解析失败。(二)第三方平台分享限制多微信等平台对分享图片有明确大小限制(如≤100KB),未压缩的原图直接分享会被拦截;且平台对图片格式(如 JPEG/PNG)有偏好,格式不匹配会导致分享失败,影响用户操作连贯性。(三)相册权限与访问复杂鸿蒙系统对相册访问需用户明确授权,未处理权限申请流程会导致保存功能直接报错;同时相册接口调用需遵循系统规范,不当使用会出现 “文件创建失败”“权限被拒” 等问题。(四)单 / 多张图片处理逻辑割裂现有代码仅支持单张图片处理,多张图片保存 / 分享时需循环调用单张逻辑,导致 IO 操作频繁、性能下降;且缺乏批量进度反馈,用户无法知晓整体处理状态。(五)网络图片下载效率低直接下载网络图片后未做本地缓存,重复操作时需重新请求网络,耗时较长;且未处理下载中断、超时等异常,易出现 “图片损坏”“保存失败” 等情况。2. 原因分析(一)格式转换逻辑缺失未针对 Base64、PixelMap 等格式设计标准化转换链,对鸿蒙image模块接口(如createImageSource、createPixelMap)使用不熟悉,导致格式转换过程中出现数据丢失或解析错误。(二)平台限制未做适配未调研第三方平台(微信、QQ)的分享规则,未实现图片压缩逻辑(如调整质量、尺寸);对 “质量 - 大小” 平衡把控不足,压缩过度会导致图片模糊,压缩不足则超出平台限制。(三)系统权限机制不了解未通过鸿蒙photoAccessHelper模块的标准接口请求相册权限,或未处理 “用户拒绝权限” 的异常场景;对系统沙箱目录(如cacheDir)使用不规范,导致文件无法写入或读取。(四)批量处理架构未设计未采用数组化参数接收多图数据,单张处理逻辑与批量场景强耦合;缺乏批量任务调度机制,循环处理时未优化 IO 操作,导致内存占用过高、处理耗时翻倍。(五)网络下载未做优化未使用鸿蒙http模块的异步请求最佳实践,同步下载阻塞 UI 线程;未实现下载缓存逻辑,重复下载相同图片时浪费流量与时间,且未处理网络异常(如断网、超时)。3. 解决思路(一)构建统一处理流程设计 “输入格式→标准化转换→大小管控→存储 / 分享” 的流水线逻辑,支持 Base64、PixelMap、ArrayBuffer、网络 URL 四种输入,输出统一的 ArrayBuffer 用于后续操作,减少格式适配成本。(二)优化格式转换链实现 “Base64→PixelMap→ArrayBuffer” 高效转换:Base64 先去除前缀并解码为二进制流,再生成可编辑 PixelMap,最后通过压缩参数控制输出大小,适配第三方平台限制。(三)图片大小精准管控通过image.createImagePacker调整quality参数(0-100),结合尺寸裁剪(如缩小分辨率),将图片大小控制在第三方平台限制内(如≤100KB);提供压缩预览,确保画质与大小平衡。(四)标准化权限与相册交互基于photoAccessHelper模块,封装 “权限请求→相册写入→结果反馈” 的完整流程;用户拒绝权限时提供引导弹窗,明确告知权限用途,提升授权率。(五)沙箱缓存中间存储使用应用cacheDir作为中间缓存目录,生成随机文件名避免冲突;先将图片缓存至沙箱,再同步至相册,减少直接操作相册的 IO 开销,提升处理效率。(六)数组化批量处理采用数组参数接收多图数据,通过 Promise.all 优化批量任务调度;统一反馈批量处理结果(成功 / 失败数量),提供进度提示,提升用户体验。4. 解决方案(一)格式转换工具封装统一处理 Base64、PixelMap 等格式转换,输出适配存储 / 分享的 ArrayBuffer: /** * Base64字符串转PixelMap(可编辑) * @param base64 带前缀的Base64字符串(如data:image/jpeg;base64,...) * @returns 可编辑的PixelMap对象 */ async base64ToPixelMap (base64:string){ const str = base64.replace(/^data:image\/\w+;base64,/i, ''); let helper = new util.Base64Helper(); let buffer: ArrayBuffer = helper.decodeSync(str, util.Type.MIME).buffer as ArrayBuffer; let imageSource = image.createImageSource(buffer); let opts: image.DecodingOptions = { editable: true }; let pixelMap =await imageSource.createPixelMap(opts); console.log('base64ToPixelMap',pixelMap) return pixelMap } /** * PixelMap压缩为ArrayBuffer(控制大小,适配第三方平台) * @param pixmap 输入PixelMap * @param quality 压缩质量(0-100,默认90,值越小体积越小) * @param format 输出格式(默认image/jpeg,比PNG体积更小) * @returns 压缩后的ArrayBuffer */ async PixelMapToArrayBuffer (pixmap: image.PixelMap | undefined): Promise<ArrayBuffer>{ const imagePackerApi = image.createImagePacker(); let packOpts: image.PackingOption = { format: "image/jpeg", quality: 98 }; let dataBuffer: ArrayBuffer = new ArrayBuffer(0) await imagePackerApi.packing(pixmap, packOpts) .then((data: ArrayBuffer) => { dataBuffer = data }).catch((error: BusinessError) => { console.error('Failed to pack the image. And the error is: ' + error); }) return dataBuffer } (二)缓存管理与路径生成使用沙箱cacheDir存储临时文件,生成唯一路径避免冲突: /** * ArrayBuffer写入沙箱缓存,生成唯一文件URI * @param buffer 待写入的图片ArrayBuffer * @returns 沙箱文件的URI(用于后续相册保存/分享) */ async getOnlyPath (buffer: ArrayBuffer): Promise<string> { const path = getContext().cacheDir + '/Photo' + `${Date.now()}baicizhan${Math.random()}IMG.jpg` const newFileUri = fileUri.getUriFromPath(path); console.log('newFileUri', newFileUri) try { const value = await fs.open(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE) fs.writeSync(value.fd, buffer) fs.closeSync(value) return newFileUri || '' } catch (e) { console.error(`缓存写入失败:${(e as BusinessError).message}`); return '' }} (三)相册保存与权限处理基于photoAccessHelper实现标准化相册保存,自动处理权限请求: /** * 批量保存图片到系统相册 * @param cacheUris 沙箱缓存文件的URI数组(单张/多张) * @param onComplete 处理完成回调(返回成功数量) */ async downLoadImgToAlbum (pixmapUris: string[],context:Context,buffer:ArrayBuffer,fn?: (str?: string) => void,){ try { const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(getContext()) const srcFileUris = pixmapUris let phCreationConfig: Array<photoAccessHelper.PhotoCreationConfig> = [] pixmapUris.forEach((item: string, index: number) => { phCreationConfig.push({ title: 'dowmload' + index, fileNameExtension: "png", photoType: photoAccessHelper.PhotoType.IMAGE, subtype: photoAccessHelper.PhotoSubtype.MOVING_PHOTO, }) }) try { const desFileUris: Array<string> = await phAccessHelper.showAssetsCreationDialog(srcFileUris, phCreationConfig) const successCount:number = desFileUris.length; phCreationConfig // 反馈结果 if (successCount > 0) { console.log('desFileUris',JSON.stringify(desFileUris)) desFileUris.forEach((item: string, index: number) => { this.createAssetByIo(phAccessHelper,context,buffer,successCount) }) } else { promptAction.showToast({ message: '取消保存' }); } } catch (err) { console.error('showAssetsCreationDialog failed, errCode is 1' + err.code + ', errMsg is ' + err.message); } } catch (err) { console.error('showAssetsCreationDialog failed, errCode is ' + err.code + ', errMsg is ' + err.message); }} async createAssetByIo(phAccessHelper: photoAccessHelper.PhotoAccessHelper,context:Context,buffer:ArrayBuffer,successCount:number){ // 获取相册的保存路径 const helper = photoAccessHelper.getPhotoAccessHelper(context); // 获取相册管理模块的实例 const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg'); // 指定待创建的文件类型、后缀和创建选项,创建图片或视频资源 const file = await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); let r = await fs.write(file.fd, buffer); await fs.close(file.fd); promptAction.showToast({ message: `成功保存${successCount}张图片到相册` }); } (四)网络图片下载工具优化网络图片下载,处理异常: /** * 从URL下载图片,返回ArrayBuffer * @param url 网络图片URL * @param timeout 超时时间(默认5000ms) * @returns 图片ArrayBuffer */ async downloadImageFromUrl (url: string): Promise<ArrayBuffer> { return new Promise((resolve, reject) => { const httpRequest = http.createHttp(); try { httpRequest.request(url, (err: BusinessError, response: http.HttpResponse) => { if (err || response.responseCode !== 200) { reject(err || new Error(`请求失败,状态码:${response.responseCode}`)); return; } const result = response.result as ArrayBuffer; if (result.byteLength === 0) { reject(new Error('下载图片为空')); return; } resolve(result); }); } catch (err) { reject(err); } }); }; (五)分享图片图片类型分享目标应用。 private async StartShareImage() { if (this.isLoading) return; this.isLoading = true; let buffer: ArrayBuffer; const shareManger= this.shareModuleManger.getInstance() // 1. 获取图片ArrayBuffer(区分网络URL/Base64) if (this.isNetworkSource) { buffer = await shareManger.downloadImageFromUrl(this.imageSource); } else { const pixelMap = await shareManger.base64ToPixelMap(this.imageSource); buffer = await shareManger.PixelMapToArrayBuffer(pixelMap); // 压缩质量90 } // 2. 写入缓存并生成URI const cacheUri = await shareManger.getOnlyPath(buffer); try { const utdTypeId = utd.getUniformDataTypeByFilenameExtension('.jpg', utd.UniformDataType.IMAGE); const shareData: systemShare.SharedData = new systemShare.SharedData({ utd: utdTypeId, uri: cacheUri, title: 'Picture Title', description: 'Picture Description', }); const controller: systemShare.ShareController = new systemShare.ShareController(shareData); controller.show(this.context, { selectionMode: systemShare.SelectionMode.SINGLE, previewMode: systemShare.SharePreviewMode.DETAIL, }).then(() => { logger.info('ShareController show success.'); }) } catch (error) { logger.error(`ShareController show error. code: ${error?.code}, message: ${error?.message}`); } finally { this.isLoading = false; } } (六)整合使用示例封装图片保存组件,串联 “下载→转换→缓存→相册保存” 全流程:import { promptAction } from "@kit.ArkUI"; import { ShareModuleManger } from "../utils/ShareModuleUtils"; import { common } from "@kit.AbilityKit"; import { uniformTypeDescriptor as utd } from '@kit.ArkData'; import { systemShare } from "@kit.ShareKit"; import Logger from "../utils/Logger"; let logger = Logger.getLogger('[ImageScenario]'); const TAG = 'ImageSaveComponent' @Entry @Component export struct ImageSaveComponent { // 输入:图片来源(网络URL/Base64) // @State imageSource: string ='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...'; @State imageSource: string='https://example.com/test.jpg' // 输入:是否为网络URL(true=URL,false=Base64) @State isNetworkSource: boolean= false; @State isLoading: boolean = false; private shareModuleManger:ShareModuleManger = new ShareModuleManger() context = this.getUIContext().getHostContext() as common.UIAbilityContext; // 核心:串联保存流程 private async handleSave() { if (this.isLoading) return; this.isLoading = true; let buffer: ArrayBuffer; const shareManger= this.shareModuleManger.getInstance() try { // 1. 获取图片ArrayBuffer(区分网络URL/Base64) if (this.isNetworkSource) { buffer = await shareManger.downloadImageFromUrl(this.imageSource); } else { const pixelMap = await shareManger.base64ToPixelMap(this.imageSource); buffer = await shareManger.PixelMapToArrayBuffer(pixelMap); // 压缩质量90 } // 2. 写入缓存并生成URI const cacheUri = await shareManger.getOnlyPath(buffer); // 3. 保存到相册 await shareManger.downLoadImgToAlbum([cacheUri],this.context,buffer); } catch (err) { promptAction.showToast({ message: `保存失败:${(err as Error).message}` }); } finally { this.isLoading = false; } } aboutToAppear(): void { if (this.imageSource.includes('base64')) { this.isNetworkSource = false }else { this.isNetworkSource = true } } private async StartShareImage() { if (this.isLoading) return; this.isLoading = true; let buffer: ArrayBuffer; const shareManger= this.shareModuleManger.getInstance() // 1. 获取图片ArrayBuffer(区分网络URL/Base64) if (this.isNetworkSource) { buffer = await shareManger.downloadImageFromUrl(this.imageSource); } else { const pixelMap = await shareManger.base64ToPixelMap(this.imageSource); buffer = await shareManger.PixelMapToArrayBuffer(pixelMap); // 压缩质量90 } // 2. 写入缓存并生成URI const cacheUri = await shareManger.getOnlyPath(buffer); try { const utdTypeId = utd.getUniformDataTypeByFilenameExtension('.jpg', utd.UniformDataType.IMAGE); const shareData: systemShare.SharedData = new systemShare.SharedData({ utd: utdTypeId, uri: cacheUri, title: 'Picture Title', description: 'Picture Description', }); const controller: systemShare.ShareController = new systemShare.ShareController(shareData); controller.show(this.context, { selectionMode: systemShare.SelectionMode.SINGLE, previewMode: systemShare.SharePreviewMode.DETAIL, }).then(() => { logger.info('ShareController show success.'); }) } catch (error) { logger.error(`ShareController show error. code: ${error?.code}, message: ${error?.message}`); } finally { this.isLoading = false; } } build() { Column({ space: 12 }) { Image(this.imageSource) .width(200) .height(200) Text('以上图片仅做学习/演示使用') .fontSize(8) Text(this.isNetworkSource ? '网络图片保存' : 'Base64图片保存') .fontSize(16) .fontWeight(FontWeight.Medium); SaveButton({ text: SaveDescription.SAVE_TO_GALLERY, buttonType: ButtonType.Normal }) .fontColor(Color.White) .fontWeight(FontWeight.Medium) .width(200) .height(44) .borderRadius('50%') .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => { if (result == SaveButtonOnClickResult.SUCCESS) { try { this.handleSave() } catch (error) { console.error("error is " + JSON.stringify(error)); } } }) Button(this.isLoading ?'分享成功':'分享图片' ) .width(200) .height(44) .backgroundColor('#007DFF') .fontColor('#FFFFFF') .borderRadius(8) .enabled(!this.isLoading) .onClick(() => this.StartShareImage()); } .padding(20) .backgroundColor('#F5F5F5') .height('100%') .width('100%'); } } // 调用示例 // 1. 网络图片保存 // <ImageSaveComponent imageSource="https://example.com/test.jpg" isNetworkSource={true} /> // 2. Base64图片保存 // <ImageSaveComponent imageSource="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD..." isNetworkSource={false} /> 5. 方案成果总结(一)功能覆盖全面支持 Base64、PixelMap、ArrayBuffer、网络 URL 四种图片来源,兼容单张 / 多张保存,适配微信等第三方平台 100KB 大小限制,满足相册保存与分享的全场景需求。(二)性能与兼容性优化通过沙箱缓存减少内存占用,图片压缩逻、权限请求与异常处理完善。(三)代码可维护性强模块化拆分格式转换、缓存、相册保存等功能,工具函数可单独复用;TypeScript 类型约束确保代码安全,错误处理完备,后续扩展(如支持 PNG 格式)仅需修改参数,无需重构核心逻辑。
-
1. 问题说明(一)背景图随内容拉伸变形内容由服务端下发且长度变长时,预设背景图(如气泡、Android 点 9 图)会同步拉伸,破坏 UI 样式;虽某场景下代码能实现不拉伸,但换图片或场景后功能失效,无法通用,影响多场景 UI 一致性。(二)参数使用场景局限性强原代码用 px 单位设置 slice 参数(如 left: ‘100px’),受图片像素影响大,换不同像素图片需重新调整参数;且未适配组件尺寸变化,内容变长后参数易超出组件范围,直接导致功能失效。2. 原因分析(一)参数核心规则理解不透彻未掌握 backgroundImageResizable 的关键规则:left、bottom 为必设参数,left+right 总和不能超过组件长度、bottom+top 不能超过组件高度,否则参数失效;未设置的 top/right 默认为 0,易导致 slice 范围异常。(二)单位与组件适配逻辑缺失使用 px 单位设置 slice 参数,受图片像素和设备分辨率影响,参数值与组件实际尺寸不匹配;未给组件设置对应 padding,当内容变长时,slice 参数总和超出组件长度,直接触发参数失效机制。3. 解决思路(一)统一参数单位与规则放弃 px 单位,采用无单位数值设置 slice 参数(如 left: 12),避免像素误导;严格遵循 “left+right≤组件长度、bottom+top≤组件高度” 规则,确保参数始终有效。(二)组件 padding 协同适配给组件添加与 slice 参数对应的 padding(如 slice.left=12 则 padding.left=12),固定组件有效显示范围,避免内容变长导致 slice 参数超出组件尺寸,提升参数在多场景的通用性。4. 解决方案(一)通用背景图不拉伸组件实现通过 “无单位 slice 参数 + 组件 padding 协同”,实现多场景背景图不拉伸,核心代码如下:export interface sliceParamsInt { left: number; bottom: number; right: number } @Component export struct MultiSceneBackground { @Prop content: string; @Prop bgResource: Resource; // 不同背景图资源 @Prop sliceParams: sliceParamsInt; // 适配不同背景的slice参数 @Prop fontColor?:string build() { Row() { // 外层容器:承载背景图与动态内容 Text(this.content) .padding(8) .fontSize(14) .fontColor(this.fontColor) .width('100%'); } // 1. 设置背景图(如气泡、点9图资源) .backgroundImage(this.bgResource) // 2. 配置backgroundImageResizable:无单位参数,必设left、bottom .backgroundImageResizable({ slice: this.sliceParams }) // 3. 背景图尺寸模式:FILL覆盖组件,配合slice实现不拉伸 .backgroundImageSize(ImageSize.FILL) .alignItems(VerticalAlign.Bottom) // 4. 组件padding与slice对应,避免参数超出组件尺寸 .padding({ left: this.sliceParams.left, bottom: this.sliceParams.bottom, right: this.sliceParams.right }) // 5. 组件宽度自适应,高度随内容变化 .width('auto') .height('40%') .backgroundColor('transparent'); } } (二)多场景复用适配示例更换背景图或场景时,仅需调整 slice 与 padding 的对应值,无需修改核心逻辑,示例如下:import { MultiSceneBackground, sliceParamsInt } from './MultiSceneBackground'; @Entry @Component export struct StableBackgroundComponent { build() { Column({ space:20 }) { // 场景1:气泡背景使用 MultiSceneBackground({ content: '服务端下发的长文本内容,长度可动态变化...', bgResource: $r('app.media.bubble_bg'), sliceParams: { left: 15, bottom: 10, right: 15 }, fontColor:'#fff' }) // 场景2:点9图背景使用 MultiSceneBackground({ content: '另一处动态文本内容...', bgResource: $r('app.media.patch_bg'), sliceParams: { left: 8, bottom: 8, right: 8 }, fontColor:'#000' }) } } } 5. 方案成果总结(一)功能通用性显著提升彻底解决背景图拉伸问题,方案适用于气泡、点 9 图等多类背景,换场景或图片时仅需调整参数。(二)UI 一致性得到保障采用无单位参数 + padding 协同,摆脱图片像素和设备分辨率限制,不同设备、不同图片下背景图均保持预设样式。(三)参数有效性稳定可靠严格遵循参数规则并配合组件 padding,解决 “内容变长导致参数超出组件尺寸” 的核心问题。
上滑加载中
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签