• [开发技术领域专区] 开发者技术支持-自定义表情键盘组件技术案例总结
    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方案成果总结整体成果从用户体验、设备适配、开发复用三个核心维度落地,既解决了实际使用痛点,也为后续开发提供便利,具体如下:(一)用户体验成果:操作流畅、使用便捷实现 “文字输入 + 表情插入 + 键盘切换 + 内容发送” 全流程闭环,用户不用频繁切换功能,操作更顺;新增常用表情横向列表,省去在大量表情中查找的时间,快速就能用;支持 “文字 + 表情” 混排,并能把内容整理成规整格式,满足后续发送、存储需求。(二)设备适配成果:显示一致、性能稳定能自动同步系统键盘高度,不同设备上界面都能自适应调整,不会出现遮挡或空白;组件加载 / 卸载时同步绑定 / 取消键盘监听,避免内存浪费,符合鸿蒙系统性能要求;富文本输入框设置了最大高度和默认聚焦,在聊天、评论等不同场景下都能正常使用。(三)开发复用成果:可扩可改、维护方便界面拆成工具栏、表情键盘、常用表情栏等独立模块,能直接复用到其他需要 “输入 + 表情” 的场景;定义了统一的 “文字 + 表情” 数据格式,后续想加链接、@他人等新内容类型也方便扩展;有完整的错误处理(如窗口获取失败、资源读取异常),后续排查问题、修改功能更简单。
  • [技术交流] 开发者技术支持-Flutter的CustomScrollView列表组头悬浮
    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初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
  • [技术交流] 开发者技术支持-Flutter的ListView实现刻度尺组件
    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(从左到右)文本方向的自动适配,确保不同语言的正确显示
  • [技术交流] 开发者技术支持 - 鸿蒙双向 Slider 控件技术方案
    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参数,无需重构核心代码;
  • [技术交流] 开发者技术支持 - 鸿蒙横向瀑布流兼容emoji表情技术方案
    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、重复设置系统栏状态,减少资源泄漏风险,窗口操作代码复用率大幅提升。
  • [开发技术领域专区] 开发者技术支持-鸿蒙系统-JSBridge 封装技术方案总结
    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 页面场景,同时预留 “自定义序列化逻辑” 扩展点,满足复杂业务需求。
  • [技术干货] 开发者技术支持 - @Builder装饰器参数传递及UI更新问题总结
    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 格式)仅需修改参数,无需重构核心逻辑。
  • [技术干货] 开发者技术支持-鸿蒙 backgroundImageResizable 参数使用问题
    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,解决 “内容变长导致参数超出组件尺寸” 的核心问题。
  • [技术交流] 开发者技术支持---城市选择器
    1、问题说明在银行移动应用中,用户需要选择所在城市来获取相应的服务信息。传统的城市选择方式(如下拉列表)在移动端体验不佳,需要一个更加直观、易用的城市选择弹窗组件。2、原因分析用户体验需求:移动端用户需要快速、直观地选择城市,传统的下拉选择器在小屏幕上操作困难界面一致性:需要与整体应用设计风格保持一致,提供统一的交互体验功能完整性:需要支持城市列表展示、选择确认、取消操作等完整功能数据管理:需要处理城市列表数据、当前选中状态、回调处理等3、解决思路采用底部弹窗设计:利用HarmonyOS的Dialog组件,从底部弹出,符合移动端用户习惯使用TextPicker组件:利用系统原生的选择器组件,提供流畅的滚动选择体验分层架构设计:将弹窗逻辑、数据管理、UI展示分离,提高代码可维护性参数化配置:通过参数传递实现组件的灵活复用4、解决方案1. 组件架构设计@ComponentV2export struct CityPickerDialog {  @Param @Require params: DialogParams;  @Local cityList: string[] = [];  @Local tempCityIndex: number = CommonNumbers.ZERO;  @Local onCitySelected: (city: string, index: number) => void = () => {};  @Local onRefreshCityList: () => void = () => {};}核心特性:使用@ComponentV2装饰器,支持最新的组件特性通过@Param接收弹窗参数,@Local管理内部状态支持城市选择回调和刷新回调2. 交互逻辑处理private handleCancelClick(): void {  this.params.close && this.params.close(this.params.id);}3. 静态展示方法static show(cityList: string[], currentCity: string,           onCitySelected: (city: string, index: number) => void,           onRefreshCityList?: () => void): void {  const currentIndex = cityList.indexOf(currentCity);  const selectedIndex = currentIndex >= 0 ? currentIndex : 0;​  const cityPickerParams: CityPickerParams = {    cityList: cityList,    tempCityIndex: selectedIndex,    onCitySelected: onCitySelected,    onRefreshCityList: onRefreshCityList || (() => {}) };​  HSDialogUtil.open({    alignment: 'bottom',    title: getResourceStr($r('app.string.please_select_city')),    data: { params: cityPickerParams, callBack: onCitySelected } }, wrapBuilder(CityPickerDialogBuilder));}UI特点:采用三栏布局:取消-标题-确认使用TextPicker提供流畅的滚动体验添加视觉指示器突出当前选中项遵循应用设计规范,使用统一的颜色和字体4. 常量管理组件使用了统一的常量管理:CommonSizes:尺寸常量CommonFontSizes:字体大小常量CommonNumbers:数字常量CommonBorderRadius:圆角常量CommonMargins:边距常量CommonAnimations:动画时长常量总结CityPickerDialogView组件成功解决了移动端城市选择的用户体验问题,具有以下优势:技术优势组件化设计:采用现代HarmonyOS组件架构,代码结构清晰参数化配置:支持灵活的参数传递,提高组件复用性异常处理:完善的错误处理机制,确保组件稳定性统一规范:遵循应用设计规范,保持界面一致性用户体验优势直观操作:底部弹窗设计符合移动端用户习惯流畅交互:使用原生TextPicker,提供流畅的滚动体验清晰反馈:视觉指示器明确显示当前选中项便捷操作:支持取消和确认操作,操作简单明了维护性优势代码分离:UI展示与业务逻辑分离,便于维护常量管理:统一的常量管理,便于主题切换类型安全:使用TypeScript,提供类型安全保障文档完善:代码注释清晰,便于团队协作该组件为银行移动应用提供了高质量的城市选择功能,是移动端UI组件设计的优秀实践案例。
  • [知识分享] 使用HdsNavigation实现内容滑动,顶部模糊效果
    在大多数app中,会有这样的效果,顶部标题,中间图片,下面列表或者下方内容客户滑动。当底部内容滑动的时候,顶部标题栏会模糊效果。针对这种效果,鸿蒙提供了专有的api实现。下面介绍一下使用效果    import { HdsNavigation, HdsNavigationAttribute, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';import { ComponentContent, LengthMetrics, Prompt } from '@kit.ArkUI';import { BusinessError } from '@kit.BasicServicesKit';/** * 通过组件导航将标题栏设置动态模糊样式 */@Builderfunction menuComponent() { Menu() { MenuItem({ content: "copy" }).onClick(() => { Prompt.showToast({ message: 'on click' }) }) MenuItem({ content: "paste" }).enabled(false) } .width(224).menuItemDivider({ strokeWidth: LengthMetrics.px(1), color: $r('sys.color.comp_divider') })}@Entry@Componentstruct TestDesign { @State arr: number[] = []; @State targetId: string = 'bindMenu' aboutToAppear(): void { for (let index = 0; index < 40; index++) { this.arr.push(index); } } @Builder StackBuilder() { Column() { Button("HdsNavigation") } .height(56) .justifyContent(FlexAlign.Center) } @Builder BottomBuilder() { Column() { Search() } .width('100%') .height(56) } @Styles listCard() { .backgroundColor(Color.White) .height(72) .width('calc(100% - 20vp)') .borderRadius(12) .margin({ left: 10, right: 10 }) } build() { HdsNavigation() { // 创建HdsNavigation组件 // HdsNavigation组件内容区 Scroll() { Column({ space: 10 }) { Image($r('app.media.startIcon')) .width('100%') .height(300) List({ space: 10 }) { ForEach(this.arr, (item: number) => { ListItem() { Text("item " + item) .fontSize(20) .fontColor(Color.Black) }.listCard() }, (item: number) => item.toString()) } .padding({ bottom: 30 }) .edgeEffect(EdgeEffect.Spring) } .width('100%') } } // .titleMode(HdsNavigationTitleMode.FULL) .titleBar({ style: { // 设置导航组件标题栏样式 // 标题栏动态模糊样式,包括是否使能滚动动态模糊,动态模糊类型,动态模糊生效的滚动距离等 scrollEffectOpts: { enableScrollEffect: true, scrollEffectType: ScrollEffectType.COMMON_BLUR, blurEffectiveStartOffset: LengthMetrics.vp(0), blurEffectiveEndOffset: LengthMetrics.vp(20) }, originalStyle: { // 内容区滚动前初始样式设置 backgroundStyle: { // 标题栏背板样式设置 backgroundColor: $r('sys.color.ohos_id_color_background'), }, contentStyle: { // 标题栏内容区样式设置,包括标题区域,菜单区域,返回按钮区域 titleStyle: { mainTitleColor: $r('sys.color.font_primary'), subTitleColor: $r('sys.color.font_secondary') }, menuStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') }, backIconStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') } } }, scrollEffectStyle: { // 内容区滚动超过blurEffectiveEndOffset后样式设置 backgroundStyle: { backgroundColor: $r('sys.color.ohos_id_color_background_transparent'), }, contentStyle: { titleStyle: { mainTitleColor: $r('sys.color.font_primary'), subTitleColor: $r('sys.color.font_secondary') }, menuStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') }, backIconStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') } } } }, content: { // 标题栏内容设置 title: { mainTitle: 'Main', subTitle: 'Sub' }, menu: { value: [ { content: { label: 'menu1', icon: $r('sys.symbol.ohos_wifi'), isEnabled: true, action: () => { let uiContext = this.getUIContext(); let promptAction = uiContext.getPromptAction(); let contentNode = new ComponentContent(uiContext, wrapBuilder(menuComponent)) try { promptAction.openMenu( contentNode, { id: this.targetId }, { backgroundColor: Color.Yellow }) } catch (error) { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`openMenu args error code is ${code}, message is ${message}`); } console.info("model cancel"); } }, badge: { count: 9 } }, { content: { label: 'menu2', icon: $r('sys.symbol.ohos_photo'), } } ] } } }) }} 效果如下: 参考链接:cid:link_0api要求起始版本:5.1.0(18)
  • [知识分享] Canvas实现高亮型新手引导功能
    一般APP刚启动时候,第一次会有一个新手指引,就是第一步--下一步--下一步;在鸿蒙上这种功能是怎么实现的呢,下面根据具体功能分析一下怎么实现,arkTs提供给了canvas绘制功能。我们可以用这个来绘制自己想要的ui。首先,初始化canvas。    private settings: RenderingContextSettings = new RenderingContextSettings(true)private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)然后    Canvas(this.context) .width('100%') .height('110%')这里可以封装一个组件,在使用的地方用stack包裹进去首先要绘制一个蒙版    this.context.fillStyle = 'rgba(0, 0, 0, 0.4)'// 绘制原型路径进行半透明填充this.context.beginPath()this.context.moveTo(0, 0)this.context.lineTo(0, this.context.height)this.context.lineTo(this.context.width, this.context.height)this.context.lineTo(this.context.width, 0)this.context.lineTo(0, 0)然后这里用了绘制了一个图片用来引导    const context = getContext(this);const resourceMgr: resourceManager.ResourceManager = context.resourceManager;const fileData: Uint8Array = await resourceMgr.getMediaContent(resource);const buffer = fileData.buffer;const imageSource: image.ImageSource = image.createImageSource(buffer);const pixelMap: image.PixelMap = await imageSource.createPixelMap();这样绘制完第一步,接着第二步、第三步。也可以把数据封装好传进去这里需要获取你要挖空的组件的坐标和长宽所用用到获取dom元素的坐标的方法onAreaChange    .onAreaChange((oldValue: Area, newValue: Area) => { this.areas.push(newValue) console.log('Ace: on area change1:', JSON.stringify(this.areas), this.num)})最后把获取到area传给canvas绘制即可整个代码如下:    Canvas(this.context) .width('100%') .height('110%') .backgroundColor(Color.Transparent) .onReady(() => { if (this.step == 1) { this.context.fillStyle = 'rgba(0, 0, 0, 0.4)' // 绘制原型路径进行半透明填充 this.context.beginPath() this.context.moveTo(0, 0) this.context.lineTo(0, this.context.height) this.context.lineTo(this.context.width, this.context.height) this.context.lineTo(this.context.width, 0) this.context.lineTo(0, 0) this.context.rect(this.areas[2].globalPosition.x as number, this.areas[2].globalPosition.y as number, this.areas[2].width as number, this.areas[2].height as number) this.drawImage($r("app.media.startIcon"), this.areas[2].globalPosition.x as number + (this.areas[2].width as number) / 4, this.areas[2].globalPosition.y as number + (this.areas[2].height as number) + 10, 50, 50) this.context.fill() this.context.closePath() this.step = 2 } }) .onClick(() => { // console.log('Ace: on area change:', JSON.stringify(this.areas)) if (this.step == 2) { this.context.reset() this.context.fillStyle = 'rgba(0, 0, 0, 0.4)' // 绘制原型路径进行半透明填充 this.context.beginPath() this.context.moveTo(0, 0) this.context.lineTo(0, this.context.height) this.context.lineTo(this.context.width, this.context.height) this.context.lineTo(this.context.width, 0) this.context.lineTo(0, 0) this.context.rect(this.areas[1].globalPosition.x as number, this.areas[1].globalPosition.y as number, this.areas[1].width as number, this.areas[1].height as number) this.drawImage($r("app.media.startIcon"), this.areas[1].globalPosition.x as number + (this.areas[1].width as number) / 4, this.areas[1].globalPosition.y as number + (this.areas[1].height as number) + 10, 50, 50) this.context.fill() this.context.closePath() this.step = 3 return } if (this.step == 3) { this.context.reset() this.context.fillStyle = 'rgba(0, 0, 0, 0.4)' // 绘制原型路径进行半透明填充 this.context.beginPath() this.context.moveTo(0, 0) this.context.lineTo(0, this.context.height) this.context.lineTo(this.context.width, this.context.height) this.context.lineTo(this.context.width, 0) this.context.lineTo(0, 0) this.context.rect(this.areas[0].globalPosition.x as number, this.areas[0].globalPosition.y as number, this.areas[0].width as number, this.areas[0].height as number) this.drawImage($r("app.media.startIcon"), this.areas[0].globalPosition.x as number + (this.areas[0].width as number) / 4, this.areas[0].globalPosition.y as number + (this.areas[0].height as number) + 10, 50, 50) this.context.fill() this.context.closePath() this.step = 4 return } if (this.step == 4) { if (this.hasGuide) { this.hasGuide('1') } } }) .expandSafeArea([SafeAreaType.SYSTEM])最后效果如下: 当然,目前封装好的插件可以使用@ohos/high_light_guide谢谢观看!
  • [知识分享] react native项目鸿蒙化
    由于在项目开发过程中需要将一些数据隐藏,但是又不想暴露出去,可以将数据放到so库中,在so库中经过一些加密算法的加工在给arkts端使用。以下是自定义的so库的步骤。1.生成.so创建Native工程:DevEco Studio -> File -> New -> Create Project -> Native C++ 创建成功之后,main目录下会有一个cpp目录,在cpp中可以编写自己的c代码了 其中 Index.d.ts: 是一个声明文件,用来声明导出的 C++ 函数,在 JS 中可以直接使用这些函数。oh-package.json5: 这是一个配置文件,用来配置so名称、版本等信息CMakeLists.txt、napi_init.cpp: C++代码以及 CMakeLists.txt 文件,用来编译生成 .so 文件,.cpp 文件内用于编写你的逻辑代码我的c代码,大致如下:其中,.nm_modname = "entry",必须和你的目录名字保持一致。将你的函数注册到index.d.ts中即可2.打包Build -> Build Module,在build -> intermediates -> libs -> default目录下生成.so 3.使用.so将自己的so库copy到你的项目中,放到新建的libs下在oh-package.json5添加依赖在使用的地方引入以上就可以成功调用了
总条数:446 到第
上滑加载中