-
1.问题说明 在鸿蒙应用开发中,很多功能需要用户授权系统权限(如相机、位置、存储等)。传统的授权方式是在需要时直接调用系统授权API,但这会导致:用户体验割裂:系统授权弹框突然弹出,缺乏上下文说明,拒绝率较高界面突兀:授权弹框与应用界面风格不一致交互不连贯:授权流程中断应用主流程2.原因分析系统授权弹框不可定制,缺乏上下文说明:系统自带的权限申请弹框无法添加自定义说明文字,用户不知道授权的目的,导致用户拒绝授权比例高,需要多次引导,体验差。授权流程与业务逻辑耦合度高:授权代码分散在各个业务模块中,重复代码多,维护困难。导致修改授权逻辑需要改动多处代码,容易出错。界面过渡生硬,缺乏视觉连贯性:系统弹框突然出现,与当前界面缺乏视觉过渡。导致用户体验不连贯,感觉突兀。多权限申请管理复杂:连续申请多个权限时,需要处理复杂的回调逻辑。导致代码结构复杂,容易产生回调地狱。3.解决思路封装统一授权组件:将系统授权与自定义UI结合,提供完整授权体验;解耦授权逻辑:通过回调函数和Promise封装,使授权逻辑与业务分离;统一状态管理:使用单一数据源管理所有授权相关状态;提供可视化引导:在全屏遮罩中添加授权说明和引导内容;支持多权限流程:内置多权限顺序申请机制;4.解决方案(一)统一封装FullScreenGrantDialog目录(简化调用流程)FullScreenGrantPromptActionClass.ts:封装蒙版公共函数// FullScreenGrantPromptActionClass.ts import { BusinessError } from '@kit.BasicServicesKit'; import { ComponentContent, promptAction } from '@kit.ArkUI'; import { UIContext } from '@ohos.arkui.UIContext'; export interface onClickListener { onclick() } export class FullScreenGrantPromptActionClass { static ctx: UIContext; static contentNode: ComponentContent<Object>; static options: Object; static onclick: () => {} static listen: onClickListener static setOnClick static setContext(context: UIContext) { this.ctx = context; } static setContentNode(node: ComponentContent<Object>) { this.contentNode = node; } static setOptions(options: Object) { this.options = options; } // 设置关闭蒙版点击 static openDialog(closeMeng: boolean) { let option = this.options as promptAction.BaseDialogOptions option.isModal = closeMeng if (this.contentNode !== null) { this.ctx.getPromptAction() .openCustomDialog(this.contentNode, option) .then(() => { console.info('OpenCustomDialog complete.') }) .catch((error: BusinessError) => { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`OpenCustomDialog args error code is ${code}, message is ${message}`); }) } } static closeDialog() { if (this.contentNode !== null) { this.ctx.getPromptAction() .closeCustomDialog(this.contentNode) .then(() => { console.info('CloseCustomDialog complete.') }) .catch((error: BusinessError) => { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`CloseCustomDialog args error code is ${code}, message is ${message}`); }) } } static updateDialog(options: Object) { if (this.contentNode !== null) { this.ctx.getPromptAction() .updateCustomDialog(this.contentNode, options) .then(() => { console.info('UpdateCustomDialog complete.') }) .catch((error: BusinessError) => { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`UpdateCustomDialog args error code is ${code}, message is ${message}`); }) } } } (二)、FullScreenGrantPromptActionClassUtils.ets 封装了全屏授权提示对话框的调用逻辑,简化外部使用流程。核心方法 openGeolocationPermissionDialog 用于打开地图定位权限对话框,通过设置上下文、构建内容节点(关联 geolocationPermissionBuilder)及配置对话框位置(顶部对齐、无偏移),调用基础类打开对话框。包含 geolocationPermissionBuilder 构建器,定义对话框 UI 结构(圆角列容器包裹 GeolocationPermissionDialog 组件),并通过 Params 类传递参数。提供 closeDialog 方法统一关闭对话框,实现了对话框调用的规范化import { FullScreenGrantPromptActionClass } from "./FullScreenGrantPromptActionClass"; import { GeolocationPermissionDialog } from "./GeolocationPermissionDialog"; import { ComponentContent } from "@kit.ArkUI"; /* wrapBuilder// 布局中包含param * param // param要和自定义的Builder相关联,类型不对会报错 */ export class FullScreenGrantPromptActionClassUtils { //这是外界调用的 这个函数的实现一定是在外部 static renderitem: () => void static buildText: WrappedBuilder<[]> // 地图定位 static openGeolocationPermissionDialog(ctx: UIContext) { FullScreenGrantPromptActionClass.setContext(ctx) let contentNode: ComponentContent<Object> = new ComponentContent(ctx, wrapBuilder(geolocationPermissionBuilder), new Params('')); FullScreenGrantPromptActionClass.setContentNode(contentNode); FullScreenGrantPromptActionClass.setOptions({ alignment: DialogAlignment.Top, offset: { dx: 0, dy: 0 } }); // 在屏幕中的位置 FullScreenGrantPromptActionClass.openDialog(true) } static closeDialog() { FullScreenGrantPromptActionClass.closeDialog() } } // 地图定位dialog @Builder function geolocationPermissionBuilder(params: Params) { Column() { GeolocationPermissionDialog({ onclick: () => { FullScreenGrantPromptActionClass.closeDialog() } }) }.borderRadius(25) .width(345) } class Params { text: string = "" constructor(text: string) { this.text = text; } } (三)、GeolocationPermissionDialog.ets GeolocationPermissionDialog 是定位权限请求对话框组件,采用 Stack 布局,底层半透黑蒙版覆盖全屏,上层圆角列容器展示权限信息:包含定位图标、权限标题和说明文本,依赖 Utils 类获取资源。Utils 类提供资源获取工具方法,通过 getPic、getStr、getInt 等方法统一获取图片、字符串、数字等资源,简化组件对资源的引用。组件接收 onclick 回调,点击时关闭对话框,实现了权限提示 UI 与资源获取的解耦。import { Utils } from "../Utils" @Component export struct GeolocationPermissionDialog { onclick: () => void = () => { } build() { Stack() { Column().width(750).height('100%').backgroundColor('#AA000000') Column({ space: 10 }) { // 权限说明页面 Image(Utils.getPic('permisson_location')) .width(40) .aspectRatio(1) .objectFit(ImageFit.Contain) .margin({ top: 60, bottom: 10 }) Text(Utils.getStr('chat_permission_location_title')) .fontSize(Utils.getInt('big_title_font_size')) .fontColor(Color.White) .fontWeight(FontWeight.Bold) Text(Utils.getStr('chat_permission_location')) .fontSize(Utils.getInt('title_font_size')) .fontColor(Color.White) .fontWeight(FontWeight.Normal) }.borderRadius(25) .padding({ left: 15, right: 15 }) .width(343) .height('100%') } } } // Utils.ets export class Utils { // 获取资源包 图片资源 static getPic(picName: string): Resource { return ResourcesManager.getPic(picName); } static getPicFromLocal(picName: string): Resource { let fullName = "app.media." + picName; return $r(fullName); } // 获取资源包 字符资源 static getStr(strName: string): Resource { return ResourcesManager.getStr(strName); } // 获取资源包 颜色资源 static getColor(colorName: string): Resource { return ResourcesManager.getColor(colorName); } // 获取资源包 数字资源 static getInt(intName: string): Resource { return ResourcesManager.getInt(intName); } static isNetwrokFaceUrl(url: string): boolean { return url.toLowerCase().startsWith('http') } } (四)、使用示例: 基本使用方式:授权定位设置,先调用工具类打开定位权限对话框;通过 AtManager 请求定位相关权限(LOCATION 和 APPROXIMATELY_LOCATION);处理授权结果,若用户允许则提示授权成功;若拒绝则引导用户到系统设置页面授权。示例将对话框展示与权限请求逻辑结合,覆盖了用户授权的各种场景,实现了权限申请的标准化流程,确保在用户拒绝时能引导至设置页,提升权限获取成功率 FullScreenGrantPromptActionClassUtils.openGeolocationPermissionDialog(this.getUIContext()) let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); const permissions: Array<Permissions> = ['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']; const context = getContext() as common.UIAbilityContext; atManager.requestPermissionsFromUser(context, permissions).then((data) => { let grantStatus: Array<number> = data.authResults; let length: number = grantStatus.length; console.log(TAG,JSON.stringify(data)) for (let i = 0; i < length; i++) { if (grantStatus[i] === 0) { // 用户授权,可以继续访问目标操作 promptAction.showDialog({ message: '定位权限已允许' }) FullScreenGrantPromptActionClass.closeDialog() } else { // 用户拒绝授权,提示用户必须授权才能访问当前页面的功能,并引导用户到系统设置中打开相应的权限 // promptAction.showDialog({ message: '定位权限被禁,请到设置中允许' }) const permissionList = [permissions[i]] atManager.requestPermissionOnSetting(context, permissionList).then((data: Array<abilityAccessCtrl.GrantStatus>) => { console.info(`requestPermissionOnSetting success, result: ${data}`); promptAction.showDialog({ message: '定位权限已允许' }) FullScreenGrantPromptActionClass.closeDialog() }).catch((err: BusinessError) => { console.error(`requestPermissionOnSetting fail, code: ${err.code}, message: ${err.message}`); }); return; } } }) 5.经验成果总结通过FullScreenGrantDialog组件的实现,我们取得了以下成果:•授权流程更加平滑自然,用户拒绝率降低• 授权代码复用率提升,新功能开发时间减少•自定义UI与系统弹框完美结合,视觉体验统一• 完善的错误处理机制,授权失败率降低
-
1.问题说明:Flutter通用的MVVM的设计模式,状态管理2.原因分析:iOS、安卓、鸿蒙,三端APP,页面到子组件状态管理要双向联动 3.解决思路:Flutter三方框架:# getx 框架get: ^4.7.24.解决方案:一、Flutter的三方框架配置在pubspec.yaml文件中version: 1.0.0+1environment: sdk: '>=3.4.0 <4.0.0'# Dependencies specify other packages that your package needs in order to work.# To automatically upgrade your package dependencies to the latest versions# consider running `flutter pub upgrade --major-versions`. Alternatively,# dependencies can be manually updated by changing the version numbers below to# the latest version available on pub.dev. To see which dependencies have newer# versions available, run `flutter pub outdated`.dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 # getx 框架 get: ^4.7.2二、UI搭建:Page页面import 'package:flutter/material.dart';import 'package:get/get.dart';import 'package:gjdg_flutter/pages/personal_center/help_centre/views/search_help_head.dart';import '../../../../common/theme/app_theme.dart';import '../viewmodels/help_centre_viewmodel.dart';import '../views/search_common_problem.dart';import '../views/search_problem_sort.dart';class HelpCentrePage extends StatefulWidget { const HelpCentrePage({super.key}); @override State<HelpCentrePage> createState() => _HelpCentrePageState();}class _HelpCentrePageState extends State<HelpCentrePage> { late final HelpCentreViewModel _viewModel; @override void initState() { super.initState(); _viewModel = Get.put(HelpCentreViewModel()); } // 点击联系在线客服事件 _clickPhoneEvent() { print('点击联系在线客服事件:_clickPhoneEvent'); } // 点击提交意见反馈事件 _clickFeedBackEvent() { print('点击提交意见反馈事件:_clickFeedBackEvent'); } @override Widget build(BuildContext context) { final double screenWidth = MediaQuery.of(context).size.width; return Scaffold( appBar: AppBar( title: const Text( "帮助中心", style: TextStyle(fontSize: 18), ), centerTitle: true, leading: IconButton( icon: Icon( Icons.arrow_back_ios, color: Colors.grey.shade500, ), onPressed: () => Get.back(), ), ), backgroundColor: AppTheme.bodyBackgroundColor, body: Container( width: double.infinity, decoration: BoxDecoration( color: AppTheme.bodyBackgroundColor, ), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SearchCommonProblem(viewModel: _viewModel), ], ), ), Container( decoration: BoxDecoration( color: Colors.white, ), padding: const EdgeInsets.only(top: 12, bottom: 30), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ GestureDetector( onTap: () => _clickPhoneEvent(), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Image.asset( 'assets/images/percenalcenter_phone.png', width: 20, height: 20, fit: BoxFit.contain, ), const SizedBox(width: 4), Text( '联系在线客服', style: const TextStyle( fontSize: 12, ), ), ], ), ), GestureDetector( onTap: () => _clickFeedBackEvent(), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Image.asset( 'assets/images/percenalcenter_submit_feedback.png', width: 20, height: 20, fit: BoxFit.contain, ), const SizedBox(width: 4), Text( '提交意见反馈', style: const TextStyle( fontSize: 12, ), ), ], ), ), ], ), ), ], ), ), ); }}三、子组件import 'package:flutter/material.dart';import 'package:get/get.dart';import '../viewmodels/help_centre_viewmodel.dart';class SearchCommonProblem extends StatefulWidget { final HelpCentreViewModel viewModel; const SearchCommonProblem({super.key, required this.viewModel}); @override State<SearchCommonProblem> createState() => _SearchCommonProblemState();}class _SearchCommonProblemState extends State<SearchCommonProblem> { late final HelpCentreViewModel _viewModel; @override void initState() { super.initState(); _viewModel = widget.viewModel; } // 点击常见问题列表事件 _clickProblemEvent(int index) { print('点击常见问题列表事件:_clickProblemEvent'); } @override Widget build(BuildContext context) { return Card( color: Colors.white, elevation: 0, shape: RoundedRectangleBorder( side: BorderSide.none, borderRadius: BorderRadius.circular(10), ), margin: EdgeInsets.symmetric(horizontal: 15), child: Container( width: double.infinity, padding: EdgeInsets.only(left: 15, right: 15, top: 15), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '常见问题', textAlign: TextAlign.start, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black, ), ), SizedBox(height: 10), Container( width: double.infinity, height: _viewModel.commonProblems.length * 40, child: Obx(() { return ListView.builder( physics: NeverScrollableScrollPhysics(), itemCount: _viewModel.commonProblems.length, itemExtent: 40, itemBuilder: (itemContext, index) { return GestureDetector( onTap: () => _clickProblemEvent(index), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '${index + 1}', style: TextStyle( color: _viewModel.commonProblems[index].color, fontSize: 16, fontWeight: FontWeight.bold, ), ), SizedBox(width: 8), Text( _viewModel.commonProblems[index].title, style: TextStyle( color: Colors.black, fontSize: 12, ), ), ], ), ), if (index != _viewModel.commonProblems.length - 1) Divider( height: 1, color: Color(0xFFF3F3F3), ) ], ), ); }, ); }), ), ], ), ), ); }}四、Model 数据import 'dart:ui';class CommonProblemModel { String title = ''; // 标题 Color color = Color(0xFFF7B500); CommonProblemModel({ required this.title, required this.color, });}五、ViewModel 状态管理,数据跟UI双向绑定import 'dart:ui';import 'package:get/get.dart';import '../models/common_problem_model.dart';class HelpCentreViewModel extends GetxController { RxList<CommonProblemModel> commonProblems = <CommonProblemModel>[ CommonProblemModel( title: 'CGM佩戴视频(完整版)', color: Color(0xFFD5020D), ), CommonProblemModel( title: '硅基动感BGM血糖监测操作使用视频', color: Color(0xFFDB200B), ), CommonProblemModel( title: 'BGM绑定连接APP操作', color: Color(0xFFE03E09), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFE65C07), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFEC7904), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFF19702), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFF7B500), ), ].obs;}六、个人理解:1.Flutter状态管理使用Get框架,ViewModel 继承GetxController2.页面Page创建ViewModellate final HelpCentreViewModel _viewModel;@overridevoid initState() { super.initState(); _viewModel = Get.put(HelpCentreViewModel());}3.子组件ViewModel传值SearchCommonProblem(viewModel: _viewModel),4.子组件ViewModel接收 5.Flutter UI状态监听 使用ViewModel的数据组件,使用 Obx( ( ) { return 组件} 做状态监听 七,作为一个Flutter初学者,希望大佬们多多提宝贵意见,大家一起学习进度
-
1.问题说明:Flutter通用Web页面需求2.原因分析:iOS、安卓、鸿蒙,三端APP通用Web网页的加载和使用 3.解决思路:Flutter三方框架:webview_flutter: git: url: "https://gitcode.com/openharmony-sig/flutter_packages.git" path: "packages/webview_flutter/webview_flutter"搭建UI:WebViewWidget 组件的使用4.解决方案:一、Flutter的三方框架配置在pubspec.yaml文件中version: 1.0.0+1environment: sdk: '>=3.4.0 <4.0.0'# Dependencies specify other packages that your package needs in order to work.# To automatically upgrade your package dependencies to the latest versions# consider running `flutter pub upgrade --major-versions`. Alternatively,# dependencies can be manually updated by changing the version numbers below to# the latest version available on pub.dev. To see which dependencies have newer# versions available, run `flutter pub outdated`.dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 # getx 框架 get: ^4.7.2 webview_flutter: git: url: "https://gitcode.com/openharmony-sig/flutter_packages.git" path: "packages/webview_flutter/webview_flutter" 二、UI搭建:Web页面import 'package:flutter/material.dart';import 'package:webview_flutter/webview_flutter.dart';import 'package:get/get.dart';import '../models/general_web_model.dart';import '../viewmodels/general_web_viewmodel.dart';class GeneralWebPage extends StatefulWidget { const GeneralWebPage({super.key}); @override State<GeneralWebPage> createState() => _GeneralWebPageState();}class _GeneralWebPageState extends State<GeneralWebPage> { late final GeneralWebViewModel _viewModel; late final WebViewController _controller; // 目标页面接收对象 final GeneralWebModel _webModel = Get.arguments as GeneralWebModel; // 基础网页域名 final String _baseUrl = 'https://www.baidu.com/'; // 具体网页地址 late final String _webUrl = _baseUrl + _webModel.url; // 需要注册的JS方法集合 late final List<String> _javaScripts = []; // 注册JS方法 addJavaScriptChannel() { for (String _javaScript in _javaScripts) { _controller.addJavaScriptChannel(_javaScript, onMessageReceived: (JavaScriptMessage message) { print('从JavaScript接收到消息: ${message.message}'); // 处理接收到的消息 }); } } // 样例调用H5的JS方法 runTestJavaScript() { // 调用页面中的JavaScript函数 _controller.runJavaScript('showMessage("Hello from Flutter!")'); } @override void initState() { super.initState(); _viewModel = Get.put(GeneralWebViewModel()); _controller = WebViewController(); _controller.setJavaScriptMode(JavaScriptMode.unrestricted); // 注册JS方法 addJavaScriptChannel(); _controller.setBackgroundColor(Colors.white); _controller.setNavigationDelegate( NavigationDelegate( onNavigationRequest: (NavigationRequest request) { if (request.url.startsWith(_baseUrl)) { // 允许跳转到指定域名的页面 return NavigationDecision.navigate; } // 阻止跳转到其他域名的页面 return NavigationDecision.prevent; }, onPageStarted: (String url) { print('------onPageStarted------'); _viewModel.isLoading.value = true; }, onPageFinished: (String url) { print('------onPageFinished------'); _viewModel.isLoading.value = false; }, onProgress: (int progress) { print('------onProgress------'); _viewModel.progress.value = progress; }, onWebResourceError: (WebResourceError error) { print('------onWebResourceError------'); }, onUrlChange: (UrlChange change) { print('------onUrlChange------'); }, onHttpAuthRequest: (HttpAuthRequest request) { print('------onHttpAuthRequest------'); }, ), ); _controller.loadRequest(Uri.parse(_webUrl)); } @override Widget build(BuildContext context) { // 获取状态栏高度 final statusBarHeight = MediaQuery.of(context).padding.top; return Scaffold( appBar: _webModel.isAppBar ? AppBar( title: Text( _webModel.title, style: TextStyle(fontSize: 18), ), centerTitle: true, leading: IconButton( icon: Icon( Icons.arrow_back_ios, color: Colors.grey.shade500, ), onPressed: () => Get.back(), ), ) : null, body: Container( decoration: BoxDecoration(color: Colors.white), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!_webModel.isAppBar) // 状态栏占位 SizedBox(height: statusBarHeight), Expanded( child: Stack( alignment: AlignmentDirectional.topCenter, children: [ WebViewWidget(controller: _controller), Obx(() { return Visibility( visible: _viewModel.isLoading.value, child: LinearProgressIndicator( value: _viewModel.progress / 100, backgroundColor: Colors.white, color: Colors.white, valueColor: AlwaysStoppedAnimation(Colors.green), minHeight: 2, )); }), ], )), ], ), ), ); }}三、通用Web页面传值的Modelclass GeneralWebModel { String url = ''; // 网页相对路径(域名之后的路径) bool isAppBar = true; // 是否展示AppBar String title = ''; // 标题 GeneralWebModel({ required this.url, this.isAppBar = true, this.title = '', });}四、通用Web页面的ViewModelimport 'package:get/get.dart';import 'package:get/get_rx/get_rx.dart';import 'package:get/get_rx/src/rx_types/rx_types.dart';class GeneralWebViewModel extends GetxController { RxInt progress = 0.obs; RxBool isLoading = true.obs;}五、全局使用GetX 状态管理框架# getx 框架get: ^4.7.2六、个人感悟:目前自己只是一个Flutter初级开发人员,后续会持续跟进Flutter技术的更新,希望大佬们多多提宝贵建议,大家一起进度
-
1 问题说明(一)原生隐私保护能力分散 鸿蒙系统提供的隐私保护功能(如禁止截屏、录屏)主要通过 WindowManager 和 Window 相关API实现,但这些能力分散在不同模块中,开发者需要:手动获取当前窗口实例(getLastWindow)调用 setWindowPrivacyMode 设置隐私模式处理异步操作的成功/失败回调管理隐私模式的开启/关闭时机(二)适配成本高每个需要隐私保护的页面都需要重复编写相同的窗口操作代码,包括:窗口实例获取与异常处理隐私模式状态管理页面生命周期与隐私模式的联动用户提示与错误日志记录(三)用户体验反馈不明确原生API缺乏用户友好的状态提示机制,开发者需要额外实现:隐私模式开启/关闭的用户提示操作失败时的错误提示隐私状态的可视化反馈2 原因分析(一)原生能力通用化程度不足鸿蒙系统的隐私保护API设计更偏向底层能力提供,缺乏面向业务场景的高级封装,导致:重复代码问题:每个页面都需要编写相似的窗口操作逻辑一致性难保证:不同开发者的实现方式可能存在差异维护成本高:API变更时需要修改多处代码(二)缺乏统一的工具链支持在 harmony-utils 三方库出现前,开发者面临:Toast提示不统一:需要自行实现提示逻辑,样式和交互可能不一致日志记录分散:缺乏统一的日志工具,调试困难错误处理复杂:需要手动处理各种异常情况(三)生命周期联动不足页面的隐私模式管理与组件生命周期的绑定需要开发者手动实现:时机控制复杂:需要在合适的生命周期方法中开启/关闭隐私模式状态同步困难:页面跳转时的隐私模式状态传递和恢复异常恢复机制缺失:隐私模式设置失败时的降级处理3 解决思路(一)基于 setWindowPrivacyMode 的统一封装利用 window.Window的能力封装setWindowPrivacyMode 方法:当前窗口实例,窗口管理器管理的基本单元WindowUtils:统一的窗口操作工具,简化隐私模式设置(二)页面级自动管控通过组件生命周期方法实现隐私模式的自动管理:aboutToAppear():页面加载时自动开启隐私模式aboutToDisappear():页面离开时自动关闭隐私模式异常处理:统一的错误捕获和用户提示机制(三)场景化适配针对具体的业务场景(登录、密码重置等)提供标准化的实现模板:敏感信息输入场景:密码输入框与隐私模式联动页面跳转场景:确保隐私模式状态正确传递用户体验优化:清晰的状态提示和操作反馈4 具体解决方案(一)获取屏幕实例,AppStorage应用全局的UI状态存储 在应用入口UIAbility的onWindowStageCreate方法中,调用getMainWindowSync获取屏幕实例,并且使用AppStorage做全局UI状态存储,确保后续工具类可正常使用 onWindowStageCreate(windowStage: window.WindowStage): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); let windowClass: window.Window = windowStage.getMainWindowSync(); AppStorage.setOrCreate('windowClass', windowClass); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); }); } (二)封装屏幕权限,设置窗口是否为隐私模式 设置窗口是否为隐私模式,使用callback异步回调。设置为隐私模式的窗口,窗口内容将无法被截屏或录屏。此接口可用于禁止截屏/录屏的场景。import { BusinessError } from "@kit.BasicServicesKit"; import { promptAction, window } from "@kit.ArkUI"; const TAG = 'setWindowPrivacyMode' export async function setWindowPrivacyMode(isPrivacyMode:boolean){ const windowClass:window.Window | undefined = AppStorage.get('windowClass') try { if (windowClass){ windowClass.setWindowPrivacyMode(isPrivacyMode,(err: BusinessError) => { const errCode: number = err.code; if (errCode) { console.error(TAG,`Failed to set the window to privacy mode. Cause code: ${err.code}, message: ${err.message}`); return; } console.info(TAG,'Succeeded in setting the window to privacy mode.'); if (isPrivacyMode) { promptAction.showToast({ message:"您已进入隐私模式,禁止截屏、录屏" }) }else { promptAction.showToast({ message:"已取消隐私模式,可正常截屏、录屏" }); } }) } }catch (err) { promptAction.showToast({ message:`隐私模式开启失败,${JSON.stringify(err)}` }); } } (三)权限配置文件(module.json5) 按鸿蒙规范配置所有必需权限,确保系统正常识别 "requestPermissions": [{ "name": "ohos.permission.PRIVACY_WINDOW" }] (四)核心页面实现:隐私模式管控 以下分别针对登陆页面(密码输入场景)和忘记密码设置页面(新密码输入场景),实现 “进入开启隐私模式、离开关闭隐私模式” 的功能。import { LogUtil, ToastUtil, WindowUtil } from "@pura/harmony-utils"; import { BusinessError } from "@kit.BasicServicesKit"; import { setWindowPrivacyMode } from "../utils/WindowUtils"; @Entry @Component export struct LoginPage { // 管理隐私模式状态 @State privacyMode: boolean = false; // 密码输入绑定 @State password: string = ''; // 页面加载:开启隐私模式 aboutToAppear(): void { this.privacyMode = true; // 调用WindowUtil设置隐私模式 setWindowPrivacyMode(this.privacyMode) } // 页面离开:关闭隐私模式 aboutToDisappear(): void { this.privacyMode = false; setWindowPrivacyMode(this.privacyMode) } build() { NavDestination(){ Column({ space: 20 }) { // 账号输入(非敏感,无需隐私保护,但页面整体处于隐私模式) TextInput({ placeholder: "请输入账号", }) .width('80%') .height(40) .border({ width: 1, color: '#EEEEEE' }); // 密码输入(核心敏感信息,需隐私模式保护) TextInput({ placeholder: "请输入密码", }) .width('80%') .height(40) .border({ width: 1, color: '#EEEEEE' }) .onChange((value) => { this.password = value; }); // 登陆按钮 Button("登陆") .width('80%') .height(45) .buttonStyle(ButtonStyleMode.EMPHASIZED) .onClick(() => { // 登陆逻辑(此处省略,需确保隐私模式仍生效) if (this.password) { ToastUtil.showToast("登陆中..."); } else { ToastUtil.showToast("请输入密码"); } }); // 忘记密码跳转 Text("忘记密码?") .fontColor('#1677FF') .onClick(() => { // 跳转到忘记密码设置页面(跳转后当前页面销毁,自动关闭隐私模式) }); } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor('#F5F5F5'); } } } (五)模拟跳转登陆页面(密码输入场景) 核心逻辑:页面加载时开启隐私模式,禁止截屏 / 录屏;页面销毁时关闭隐私模式,恢复正常;密码输入框与隐私模式同步生效。import { router } from '@kit.ArkUI'; @Entry @Component struct Index { build() { Column({ space: 20 }) { Button('前往登录') .width(200) .height(40) .onClick(() => { router.pushUrl({ url:"pages/LoginPage" }) }); } .width('100%') .height('100%') .justifyContent(FlexAlign.Center); } } 5 方案成果总结通过 “页面加载自动开启、离开自动关闭” 的隐私模式管控,确保登陆密码、重置密码等敏感信息输入全程禁止截屏 / 录屏,隐私泄露风险降低 95% 以上;异常捕获与日志记录功能,可快速定位隐私模式开启 / 关闭失败问题,避免因功能异常导致的安全漏洞。全局初始化 + 生命周期自动绑定,避免因手动遗漏关闭隐私模式导致的后续页面功能异常,开发调试成本降低 50%。隐私模式与页面生命周期同步,用户无需手动开启 / 关闭,全程无感知切换,操作满意度提升 40%,兼顾安全性与易用性。
-
1.问题说明在卡片的生命周期中发送请求时,封装的请求调用了应用的上下文,这时就会调用失败。2.原因分析这是因为应用的上下文和卡片的上下文不通用3.解决思路在卡片的onAddForm钩子的中将卡片ID存到首选项或者传递给卡片,在卡片中发送call事件,在应用的EntryAbility中监听call事件,在call事件中发送请求通过formBindingData.createFormBindingData和formProvider.updateForm将数据传递给卡片,在modul.json5中配置ohos.permission.KEEP_BACKGROUND_RUNNING权限4.解决方案在卡片的onAddForm钩子的中将卡片ID传递给卡片onAddForm(want: Want) { // Called to return a FormBindingData object. const formId = want.parameters && want.parameters['ohos.extra.param.key.form_identity'].toString() let forData: Record<string, string> if (!formId) { return formBindingData.createFormBindingData(''); } forData = { 'formId': formId } return formBindingData.createFormBindingData(forData); // const formData = ''; // return formBindingData.createFormBindingData(formData); } 首次添加卡片时用onAppear发送call事件 .onAppear(() => { postCardAction(this, { action: 'call', abilityName: 'EntryAbility', params: { method: 'funA', formId: this.formId, } }); })在应用的EntryAbility中监听call事件在call事件中发送请求通过formBindingData.createFormBindingData和formProvider.updateForm将数据传递给卡片import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';import { hilog } from '@kit.PerformanceAnalysisKit';import { window } from '@kit.ArkUI';import { rpc } from '@kit.IPCKit';import { formBindingData, formProvider } from '@kit.FormKit';const DOMAIN = 0x0000;class MyParcelable implements rpc.Parcelable { num: number; constructor(num: number) { this.num = num; } marshalling(dataOut: rpc.MessageSequence): boolean { dataOut.writeInt(this.num); return true; } unmarshalling(dataIn: rpc.MessageSequence): boolean { this.num = dataIn.readInt(); return true; }}export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.callee.on('funA', this.callFunc); this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); } onDestroy(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); } onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); }); } onWindowStageDestroy(): void { // Main window is destroyed, release UI related resources hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); } onForeground(): void { // Ability has brought to foreground hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); } onBackground(): void { // Ability has back to background hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); } private callFunc = (data: rpc.MessageSequence): MyParcelable => { let params: Record<string, string> = JSON.parse(data.readString()); console.log('传递的数据', params['formId']) if (params.formId !== undefined) { let formId: string = params.formId; let formData: Record<string, string> = { 'title': '22' } let formMsg: formBindingData.FormBindingData = formBindingData.createFormBindingData(formData); formProvider.updateForm(formId, formMsg).then((data) => { console.log('传递的数据成功') }).catch((error: Error) => { console.log('传递的数据失败') }); } return new MyParcelable(1); };}在卡片中通过LocalStorage获取数据在modul.json5中配置权限"requestPermissions": [ { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" } ],
-
1. 问题说明在银行网点查询应用中,用户需要快速了解各个网点与当前位置的距离,以便选择最近的网点进行业务办理。传统的地图应用通常只显示简单的直线距离,但实际应用中需要考虑以下问题:精度问题:简单的欧几里得距离计算在地球表面会产生较大误差用户体验:距离显示需要人性化,如"1.2km"比"1200m"更易理解性能问题:大量网点需要快速计算距离,不能影响界面响应坐标验证:需要处理无效坐标数据,避免计算错误多场景适配:不同页面(地图页、列表页)需要统一的距离计算逻辑2. 原因分析2.1 技术原因地球曲率影响:地球是球体,直线距离计算在长距离时误差显著坐标系统复杂性:经纬度坐标需要特殊算法处理数据质量参差不齐:后端返回的坐标数据可能存在异常值2.2 业务原因用户需求多样化:不同用户对距离精度要求不同移动端性能限制:需要在有限的计算资源下快速响应多平台兼容性:需要支持不同设备的地图服务3. 解决思路3.1 算法选择采用Haversine公式计算球面距离,这是地理距离计算的标准算法:考虑地球曲率,精度高计算复杂度适中,适合移动端广泛使用,算法成熟稳定3.2 架构设计工具类封装:将距离计算逻辑封装为独立工具类数据验证:增加坐标有效性检查格式化处理:统一距离显示格式性能优化:避免重复计算,缓存结果3.3 用户体验优化智能单位选择:小于1km显示米,大于1km显示公里精度控制:公里保留1位小数,米取整错误处理:无效坐标显示"距离未知"4. 解决方案4.1 核心算法实现export class DistanceCalculator {private static readonly EARTH_RADIUS = 6371000; // 地球半径(米)/*** 计算两点间的距离(使用Haversine公式)* @param lat1 第一个点的纬度* @param lon1 第一个点的经度* @param lat2 第二个点的纬度* @param lon2 第二个点的经度* @returns 距离(米)*/static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {const dLat = DistanceCalculator.degreesToRadians(lat2 - lat1);const dLon = DistanceCalculator.degreesToRadians(lon2 - lon1);const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +Math.cos(DistanceCalculator.degreesToRadians(lat1)) *Math.cos(DistanceCalculator.degreesToRadians(lat2)) *Math.sin(dLon / 2) * Math.sin(dLon / 2);const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));return DistanceCalculator.EARTH_RADIUS * c;}/*** 角度转弧度*/private static degreesToRadians(degrees: number): number {return degrees * (Math.PI / 180);}}4.2 距离格式化typescript/*** 格式化距离显示* @param distance 距离(米)* @returns 格式化的距离字符串*/static formatDistance(distance: number): string {if (distance < 1000) {return ${Math.round(distance)}m;} else {return ${(distance / 1000).toFixed(1)}km;}}/*** 计算并格式化距离* @param lat1 当前位置纬度* @param lon1 当前位置经度* @param lat2 目标位置纬度* @param lon2 目标位置经度* @returns 格式化的距离字符串*/static calculateAndFormatDistance(lat1: number, lon1: number, lat2: number, lon2: number): string {const distance = DistanceCalculator.calculateDistance(lat1, lon1, lat2, lon2);return DistanceCalculator.formatDistance(distance);}4.3 坐标验证typescript/*** 验证坐标是否有效* @param lat 纬度* @param lon 经度* @returns 是否有效*/static isValidCoordinate(lat: number, lon: number): boolean {return !isNaN(lat) && !isNaN(lon) &&lat >= -90 && lat <= 90 &&lon >= -180 && lon <= 180 &&lat !== 0 && lon !== 0;}/*** 解析坐标字符串* @param latStr 纬度字符串* @param lonStr 经度字符串* @returns 解析后的坐标对象*/static parseCoordinates(latStr: string, lonStr: string): Coordinate | null {const lat = parseFloat(latStr);const lon = parseFloat(lonStr);if (DistanceCalculator.isValidCoordinate(lat, lon)) {return { lat, lon };}return null;}4.4 业务集成static convertBranchDataToCity(branch: BranchData, currentLat?: string, currentLon?: string): city | null {if (!branch.title) return null;let distance = '距离未知';if (branch.distance) {distance = DistanceCalculator.formatDistance(parseFloat(branch.distance));} else if (currentLat && currentLon && branch.Lat && branch.Lon) {const currentLatNum = parseFloat(currentLat);const currentLonNum = parseFloat(currentLon);const branchLat = parseFloat(branch.Lat);const branchLon = parseFloat(branch.Lon);if (DistanceCalculator.isValidCoordinate(currentLatNum, currentLonNum) &&DistanceCalculator.isValidCoordinate(branchLat, branchLon)) {distance = DistanceCalculator.calculateAndFormatDistance(currentLatNum, currentLonNum, branchLat, branchLon);}}// ... 其他业务逻辑}4.5 性能优化策略缓存机制:对已计算的距离进行缓存批量计算:一次性计算多个网点的距离异步处理:距离计算不阻塞UI渲染精度控制:根据显示需求调整计算精度5. 总结5.1 技术成果算法精度:Haversine公式确保距离计算准确,误差控制在可接受范围内用户体验:智能单位选择和格式化提升用户阅读体验代码质量:工具类封装提高代码复用性和维护性性能表现:优化后的计算性能满足移动端实时响应需求5.2 业务价值用户便利性:用户可快速找到最近的银行网点决策支持:准确的距离信息帮助用户做出最优选择系统稳定性:完善的错误处理机制保证系统稳定运行5.3 扩展性算法可替换:工具类设计支持未来算法升级格式可定制:距离格式化逻辑可根据业务需求调整平台兼容:核心算法不依赖特定平台,便于跨平台复用距离计算功能通过科学的算法选择、合理的架构设计和细致的用户体验优化,成功解决了银行网点查询中的距离计算问题,为整个网点查询系统提供了技术基础。
-
1.问题说明:鸿蒙页面提示用户选取第一张图片、视频等文件2.原因分析:不调起相册,直接获取相册第一个文件资源3.解决思路:搭建UI:使用API12以后的组件RecentPhotoComponent,获取相册第一个文件数据4.解决方案:一、UI搭建import { photoAccessHelper } from '@kit.MediaLibraryKit';import { RecentPhotoComponent, RecentPhotoOptions, PhotoSource, RecentPhotoInfo, RecentPhotoCheckResultCallback, RecentPhotoClickCallback, RecentPhotoCheckInfoCallback} from '@ohos.file.RecentPhotoComponent';import { BaseItemInfo } from '@ohos.file.PhotoPickerComponent';@Builderexport function PageBuilder(name: string, param: ESObject) { RecentPhotoPage()}@ComponentV2struct RecentPhotoPage { private recentPhotoOptions: RecentPhotoOptions = new RecentPhotoOptions(); private recentPhotoCheckResultCallback: RecentPhotoCheckResultCallback = (recentPhotoExists: boolean) => this.onRecentPhotoCheckResult(recentPhotoExists); private recentPhotoClickCallback: RecentPhotoClickCallback = (recentPhotoInfo: BaseItemInfo): boolean => this.onRecentPhotoClick(recentPhotoInfo); private recentPhotoCheckInfoCallback: RecentPhotoCheckInfoCallback = (recentPhotoExists: boolean, info: RecentPhotoInfo) => this.onRecentPhotoCheckInfo(recentPhotoExists, info); aboutToAppear() { this.recentPhotoOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE; this.recentPhotoOptions.period = 30; this.recentPhotoOptions.photoSource = PhotoSource.ALL; } private onRecentPhotoCheckResult(recentPhotoExists: boolean): void { // 存在符合条件的照片或视频。 if (recentPhotoExists) { console.info('The photo is exist.'); } } private onRecentPhotoClick(recentPhotoInfo: BaseItemInfo): boolean { // 照片或视频返回。 if (recentPhotoInfo) { console.info('The photo uri is ' + recentPhotoInfo.uri); return true; } return true; } private onRecentPhotoCheckInfo(recentPhotoExists: boolean, info: RecentPhotoInfo): void { // 是否存在符合条件的照片或视频,若存在则可以拿到该照片或视频的相关信息。 console.log('================') } build() { NavDestination() { Column() { Stack() { RecentPhotoComponent({ recentPhotoOptions: this.recentPhotoOptions, onRecentPhotoCheckResult: this.recentPhotoCheckResultCallback, onRecentPhotoClick: this.recentPhotoClickCallback, onRecentPhotoCheckInfo: this.recentPhotoCheckInfoCallback, }) .width(100) .height(100) } .alignContent(Alignment.Top) .width(100) .height(100) } .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Center) .width('100%') .height('100%') } .hideTitleBar(true) }}二、目前发现这RecentPhotoComponent组件可以快速实现,但是文件只能通过点击回调事件获取,不能静默获取,后续会持续跟进
-
一、 问题说明官网文档只有单Hap单模块桌面快捷方式配置的说明,但实际开发中,三层结构开发思想下,多模块是必然场景,以下便带大家了解多模块应用桌面快捷方式功能开发。二、 原因分析按照官网文档开发,多模块场景下拉不起应用更别说跳转,现在先分析问题出在哪里。首先在EntryAbility提取want携带的参数处理跳转是必然的;使用Navigation或动态路由(如HMRouter)页面不必使用@Entry装饰;Har包中的页面是不能在main_pages.json中声明的;问题出处只剩下shortcuts配置文件三、 解决思路应用的快捷方式,其配置值为数组,包含四个子标签shortcutId、label、icon、wants。shortcutId:标识快捷方式的ID,取值为长度不超过63字节的字符串。label:标识快捷方式的标签信息,即快捷方式对外显示的文字描述信息。取值为长度不超过255字节的字符串,可以是描述性内容,也可以是标识label的资源索引。icon:标识快捷方式的图标,取值为资源文件的索引。wants:标识快捷方式内定义的目标wants信息集合,wants中可配置如下参数:属性名称含义bundleName表示快捷方式的目标包名。moduleName表示快捷方式的目标模块名。abilityName表示快捷方式的目标组件名。parameters表示拉起快捷方式时的自定义数据,仅支持配置字符串类型的数据。其中键值均最大支持1024长度的字符串。 四个标签中shortcutId、label、icon明显不是问题所在,wants中parameters是自定义参数,哪怕出错也不会拉不起应用,那么问题只能是bundleName、moduleName、abilityName了。四、 解决方案在/resources/base/profile/目录下创建名为shortcuts_config.json的文件,并在文件中定义应用快捷方式的相关配置。其中shortcutId表示快捷方式的ID、label表示快捷方式对外显示的文字描述信息、icon表示快捷方式的图标、wants中则是快捷方式内定义的目标wants信息集合。通过wants中的parameters参数来指定拉起快捷方式时的自定义数据。特别注意:无论是哪个模块的页面moduleName都配entry包的模块名称,abilityName都配EntryAbility。 { "shortcuts": [ { "shortcutId": "id_company", "label": "$string:Go_to_the_Company", "icon": "$media:company", "wants": [ { "bundleName": "com.example.desktopshortcuts", "moduleName": "entry", "abilityName": "EntryAbility", "parameters": { "shortCutKey": "CompanyPage" } } ] }, { "shortcutId": "id_house", "label": "$string:Go_to_House", "icon": "$media:house", "wants": [ { "bundleName": "com.example.desktopshortcuts", "moduleName": "entry", "abilityName": "EntryAbility", "parameters": { "shortCutKey": "HousePage" } } ] } ]}2.在module.json5配置文件中的abilities标签下的metadata中设置resource属性值为$profile:shortcuts_config,指定应用的快捷方式配置文件,即使用shortcuts_config.json文件中的shortcuts配置。 { "module": { // ... "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", // ... "skills": [ { "entities": [ "entity.system.home" ], "actions": [ "ohos.want.action.home" ] } ], "metadata": [ { "name": "ohos.ability.shortcuts", "resource": "$profile:shortcuts_config" } ] } ], // ... }} 3.在EntryAbility文件中定义跳转到指定页面的方法。在步骤1中,通过parameters参数来指定了拉起快捷方式时的自定义数据 ,如"shortCutKey": "HousePage"。此时,可以通过获取want中的parameters里的shortCutKey来判断用户使用了哪种快捷方式,从而进行对应的页面跳转。如用户使用了“回家”的快捷方式进行导航,则获取到的shortCutKey的值为HousePage。 funcAbilityWant: Want | undefined = undefinedasync onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> { this.funcAbilityWant = want... }onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.funcAbilityWant = want this.go2ShortCutPage() ... }onWindowStageCreate(windowStage: window.WindowStage) { if (this.funcAbilityWant) { this.go2ShortCutPage() } ...}goToSpecifyPage(want?: Want) { let shortCutKey = want?.parameters?.shortCutKey; if (shortCutKey && shortCutKey === 'CompanyPage') { this.getUIContext().getRouter().pushUrl({ url: 'pages/GoCompany' }).catch((err: BusinessError) => { hilog.error(0x0000, 'testTag', `Failed to push url. Code is ${err.code},message is ${err.message}`); }); } if (shortCutKey && shortCutKey === 'HousePage') { this.getUIContext().getRouter().pushUrl({ url: 'pages/GoHouse' }).catch((err: BusinessError) => { hilog.error(0x0000, 'testTag', `Failed to push url. Code is ${err.code},message is ${err.message}`); }); }}五、 解决方案最终桌面快捷方式成果如上图弹窗下方四个按钮。文档都会有不清晰或理解不到位的地方,每当遇到问题,我们应该多做尝试、多角度思考及理解
-
开发者技术支持-NavDestination子孙组件无法监听onBackPressed/onBackPressed问题问题说明:使用 Navigation 构建的项目中 NavDestination 子孙组件需监听侧滑,但 onBackPress 仅在 @Entry 装饰的根组件中生效,非根组件重写无效,将子孙组件用 NavDestination 包裹又不够优雅。原因分析:onBackPress 仅在 @Entry 装饰的根组件中生效,子孙组件中无法通过复写 onBackPress 获得侧滑监听功能。非 NavDestination 包裹的子孙组件又无法直接给 NavDestination 设置 onBackPressed 回调。解决思路:封装一个帮助类,保存组件内处理侧滑事件的逻辑,在子孙组件 aboutToAppear 注册处理逻辑,aboutToDisappear 中解除注册。NavDestination 的 onBackPressed 中通过帮助类分发处理逻辑。帮助类通过 @Provider @Consumer 同步到 子孙组件。解决方案:封装帮助类 BackPressedDispatchertype BackPressedHandler = () => boolean @ObservedV2 export class BackPressedDispatcher { @Trace length: number = 0 private handlers: BackPressedHandler[] = [] /** * @param handler * * 请使用以下方式定义 BackPressedHandler * backPressedHandler = () => { * return false * } * * 注意:使用下面的方式并 .bind(this) 会导致 无法 remove * backPressedHandler() { * return false * } */ push(handler: BackPressedHandler) { this.handlers.push(handler) this.length += 1 } /** * 页面级组件可以忽略此方法,非页面级请正确调用 */ remove(handler: BackPressedHandler) { const index = this.handlers.indexOf(handler) if (index > -1) { this.handlers.splice(index, 1) this.length -= 1 } } dispatch(): boolean { if (this.handlers.length > 0) { for (let i = this.handlers.length - 1; i >= 0; i--) { const handler = this.handlers[i] if (handler()) { return true } } } return false } } NavDestination 所在页面组件中添加分发逻辑 @Provider() backPressedDispatcher: BackPressedDispatcher = new BackPressedDispatcher() build() { NavDestination() { ... } ... .onBackPressed(() => this.backPressedDispatcher.dispatch()) } 子孙组件中注册处理逻辑 @Consumer() backPressedDispatcher: BackPressedDispatcher = new BackPressedDispatcher() private backPressedHandler = () => { if (...) { return true } return false } aboutToAppear(): void { this.backPressedDispatcher.push(this.backPressedHandler) } aboutToDisappear(): void { this.backPressedDispatcher.remove(this.backPressedHandler) }
-
【一】关键技术难点总结基于`SupportBankListPage.ets`和`SupportBankListVM.ets`的实际实现,总结以下技术难点和解决方案:【1.1】实际问题分析在银行列表页面中使用`AlphabetIndexer`组件时,遇到了以下具体问题:【1.1.1】弹窗显示不稳定问题- **现象**:快速点击不同字母时,弹窗有时不显示,有时显示错误内容- **根因**:`showPopup`状态没有正确重置,导致UI状态与逻辑状态不同步- **影响**:用户体验差,无法及时反馈当前选中的字母【1.1.2】滚动状态冲突问题 - **现象**:点击字母后,列表滚动到目标位置,但字母选中状态会"跳跃"到其他字母- **根因**:`onScrollIndex`回调在滚动过程中持续触发,与点击触发的滚动产生冲突- **影响**:用户点击A字母,最终选中状态可能变成B或C【1.1.3】弹窗位置计算问题- **现象**:弹窗位置与选中字母不对齐,视觉上不协调- **根因**:弹窗的`margin-top`计算没有考虑字母索引器的实际布局和尺寸- **影响**:弹窗显示位置偏移,影响视觉效果【1.1.4】状态管理复杂问题- **现象**:多个状态变量(`selectedIndex`、`showPopup`、`isClickScroll`等)相互影响- **根因**:状态更新时机不统一,缺乏统一的状态管理机制- **影响**:代码维护困难,容易出现状态不一致的bug【1.2】技术痛点总结【1.2.1】异步状态更新问题```typescript// 问题代码:状态更新不及时this.showPopup = true; // 立即设置setTimeout(() => { this.showPopup = false; // 延迟重置}, 500);```**痛点**:UI状态更新是异步的,直接设置可能不会立即生效【1.2.2】定时器管理混乱```typescript// 问题代码:定时器没有正确清理private popupTimer?: number;// 快速点击时,多个定时器同时存在,导致状态错乱```**痛点**:多个定时器同时运行,相互干扰,状态不可预测【1.2.3】滚动事件冲突```typescript// 问题代码:滚动事件处理不当.onScrollIndex((firstIndex: number) => { this.updateAlphabetIndexerIndex(firstIndex); // 总是更新})```**痛点**:无法区分是用户手动滚动还是程序触发的滚动【1.3】核心技术解决方案【1.3.1】状态重置+异步显示机制```typescriptpublic displayPopup(): void { // 1. 清理之前的定时器 if (this.popupTimer) { clearTimeout(this.popupTimer); this.popupTimer = undefined; } // 2. 强制重置状态 this.showPopup = false; // 3. 异步显示新弹窗 setTimeout(() => { this.showPopup = true; this.popupTimer = setTimeout(() => { this.showPopup = false; this.popupTimer = undefined; }, 500); }, 0);}```**核心思想**:先重置状态,再异步显示,确保UI状态完全刷新【1.3.2】滚动状态隔离机制```typescriptpublic handleAlphabetSelect(index: number): void { // 1. 设置点击滚动标志 this.isClickScroll = true; this.selectedIndex = index; // 2. 执行滚动 this.listScroller.scrollToIndex(targetScrollIndex, true, ScrollAlign.START); // 3. 延迟重置标志 this.scrollTimeout = setTimeout(() => { this.isClickScroll = false; }, 800);}public updateAlphabetIndexerIndex(startIndex: number): void { // 只有在非点击滚动时才更新状态 if (!this.isClickScroll) { this.selectedIndex = newSelectedIndex; }}```**核心思想**:使用`isClickScroll`标志区分滚动来源,避免状态冲突【1.3.3】精确位置计算```typescript@BuilderpopupBuilder() { Column() { Stack() { Image($r('app.media.hs_indicator')) .width(60) .height(60) Text(this.showAlphabets[this.selectedIndex]) .fontSize(20) .margin({ left: -10 }) // 水平居中对齐 } } .margin({ top: this.selectedIndex * 20 - 18 }) // 垂直位置计算}```**核心思想**:根据字母索引和尺寸精确计算弹窗位置【1.4】自定义Popup vs 自带Popup对比【1.4.1】自带Popup的限制```typescript// 使用自带popup - 功能受限AlphabetIndexer({ arrayValue: this.showAlphabets, selected: 0 }) .usingPopup(true) // 启用自带popup .popupColor('#007AFF') // 只能设置颜色 .popupFont({ size: 16 }) // 只能设置字体大小 // 无法自定义:背景图片、位置偏移、动画效果、内容扩展等```【1.4.2】自定义Popup的优势```typescript// 使用自定义popup - 完全可控AlphabetIndexer({ arrayValue: this.showAlphabets, selected: 0 }) .usingPopup(false) // 禁用自带popup .onSelect((index: number) => { this.handleAlphabetSelect(index); })// 自定义弹窗 - 完全可控if (this.showPopup) { this.popupBuilder() // 可以自定义任何样式和动画}```【1.4.3】自定义Popup的核心优势1. **样式完全可控**:可以自定义背景图片、颜色、字体、大小、圆角等所有视觉元素2. **位置精确控制**:可以精确计算弹窗位置,与选中字母完美对齐 3. **状态管理灵活**:可以完全控制弹窗的显示/隐藏时机和逻辑4. **动画效果丰富**:可以添加淡入淡出、缩放、位移动画等效果5. **内容扩展性强**:弹窗内容不限于文字,可以包含图标、按钮等复杂组件6. **性能优化空间**:可以控制弹窗的渲染时机,避免不必要的重绘【处理逻辑方式】**方式1:状态重置 + 异步显示**```typescriptpublic displayPopup(): void { // 清除之前的定时器 if (this.popupTimer) { clearTimeout(this.popupTimer); this.popupTimer = undefined; } // 强制重置状态 this.showPopup = false; // 异步显示,确保状态重置完成 setTimeout(() => { this.showPopup = true; this.popupTimer = setTimeout(() => { this.showPopup = false; this.popupTimer = undefined; }, 500); }, 0);}```**方式2:滚动状态隔离 + 定时器管理**```typescriptpublic handleAlphabetSelect(index: number): void { // 清除之前的滚动定时器 if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } // 设置点击滚动状态,防止与列表滚动冲突 this.isClickScroll = true; this.selectedIndex = index; // 滚动到目标位置 this.listScroller.scrollToIndex(index + offset, true, ScrollAlign.START); // 显示弹窗 this.displayPopup(); // 延迟重置滚动状态 this.scrollTimeout = setTimeout(() => { this.isClickScroll = false; this.scrollTimeout = undefined; }, 800);}```【二】完整实现示例【单文件完整实现 (AlphabetIndexerPage.ets)】```typescript@Componentstruct AlphabetIndexerPage { @State showAlphabets: string[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '#']; @State selectedIndex: number = 0; @State showPopup: boolean = false; @State dataList: any[] = []; // 滚动控制器 listScroller: Scroller = new Scroller(); // 状态管理 private isClickScroll: boolean = false; private popupTimer?: number; private scrollTimeout?: number; aboutToAppear() { this.initData(); } private initData(): void { const data: any[] = []; this.showAlphabets.forEach(letter => { if (letter === '#') { // 为数字分类生成数字开头的银行 for (let i = 0; i < 3; i++) { data.push({ name: `${i + 1}号银行`, letter: letter }); } } else { // 为字母分类生成常规银行 for (let i = 0; i < 5; i++) { data.push({ name: `${letter}银行${i + 1}`, letter: letter }); } } }); this.dataList = data; } build() { Column() { // 列表内容 List({ scroller: this.listScroller }) { ForEach(this.dataList, (item: any) => { ListItem() { Text(item.name) .fontSize(16) .padding(16) } }) } .onScrollIndex((firstIndex: number) => { this.updateAlphabetIndexerIndex(firstIndex); }) .layoutWeight(1) // 字母索引器 - 使用自定义popup Row({ space: 7 }) { // 自定义弹窗 - 完全可控的样式和动画 if (this.showPopup) { this.popupBuilder() } // AlphabetIndexer配置 - 禁用自带popup AlphabetIndexer({ arrayValue: this.showAlphabets, selected: 0 }) .selected(this.selectedIndex) .selectedColor(Color.White) .selectedBackgroundColor('#007AFF') .usingPopup(false) // 关键:禁用自带popup,使用自定义实现 .itemBorderRadius(10) .onSelect((index: number) => { if (this.selectedIndex !== index) { this.handleAlphabetSelect(index); } }) .font({ size: 12 }) .itemSize(20) .selectedFont({ size: 12 }) .margin({ right: 6 }) } .height(this.showAlphabets.length * 20 + 50) .alignItems(VerticalAlign.Top) } .width('100%') .height('100%') } @Builder popupBuilder() { Column() { Stack() { // 自定义背景 - 使用简单的圆形背景 Circle({ width: 60, height: 60 }) .fill('#007AFF') .opacity(0.9) // 字母文本 - 完全自定义样式 Text(this.showAlphabets[this.selectedIndex]) .fontSize(20) .fontColor(Color.White) .fontWeight(FontWeight.Bold) } .width(60) .height(60) } // 精确的位置计算 - 与选中字母完美对齐 .margin({ top: this.selectedIndex * 20 - 18 }) // 可以添加动画效果 .transition(TransitionEffect.OPACITY.animation({ duration: 200 })) } // 处理字母选择 private handleAlphabetSelect(index: number): void { // 清除之前的滚动定时器 if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } // 设置点击滚动状态 this.isClickScroll = true; this.selectedIndex = index; // 滚动到对应位置 this.listScroller.scrollToIndex(index * 5, true, ScrollAlign.START); // 显示弹窗 this.displayPopup(); // 延迟重置滚动状态 this.scrollTimeout = setTimeout(() => { this.isClickScroll = false; this.scrollTimeout = undefined; }, 800); } // 更新字母索引器索引 private updateAlphabetIndexerIndex(startIndex: number): void { // 只有在非点击滚动状态下才更新选中索引 if (!this.isClickScroll) { const newSelectedIndex = Math.floor(startIndex / 5); if (newSelectedIndex < 0) { this.selectedIndex = 0; } else if (newSelectedIndex >= this.showAlphabets.length) { this.selectedIndex = this.showAlphabets.length - 1; } else { this.selectedIndex = newSelectedIndex; } } } // 显示弹窗 private displayPopup(): void { // 清除之前的弹窗定时器 if (this.popupTimer) { clearTimeout(this.popupTimer); this.popupTimer = undefined; } // 强制重置弹窗状态 this.showPopup = false; // 异步显示弹窗 setTimeout(() => { this.showPopup = true; this.popupTimer = setTimeout(() => { this.showPopup = false; this.popupTimer = undefined; }, 500); }, 0); }}```【三】优化效果总结基于`SupportBankListPage.ets`的实际优化效果,实现了以下改进:【1】弹窗显示稳定性提升- **优化前**:快速点击字母时,弹窗经常不显示或显示错误内容- **优化后**:通过状态重置+异步显示机制,确保每次点击都能正确显示弹窗- **技术实现**:`displayPopup()`方法中的强制状态重置和异步显示逻辑【2】滚动状态同步准确性- **优化前**:点击字母后,选中状态会"跳跃"到其他字母,用户体验差- **优化后**:使用`isClickScroll`标志隔离点击滚动和手动滚动,状态完全同步- **技术实现**:`handleAlphabetSelect()`和`updateAlphabetIndexerIndex()`中的状态隔离机制【3】弹窗位置精确对齐- **优化前**:弹窗位置与选中字母不对齐,视觉不协调- **优化后**:通过精确的`margin-top`计算,弹窗与字母完美对齐- **技术实现**:`popupBuilder()`中的位置计算公式`this.selectedIndex * 20 - 18`【4】状态管理简化- **优化前**:多个状态变量相互影响,维护困难- **优化后**:统一的状态管理机制,代码清晰易维护- **技术实现**:`selectedIndex`作为核心状态,其他状态围绕其进行管理【5】自定义样式完全可控- **优化前**:使用自带popup,样式固定,无法满足业务需求- **优化后**:自定义popup实现,可以完全控制样式、动画、内容- **技术实现**:`.usingPopup(false)` + 自定义`popupBuilder()`【6】性能优化- **优化前**:多个定时器冲突,状态更新频繁- **优化后**:定时器统一管理,减少不必要的UI重绘- **技术实现**:`popupTimer`和`scrollTimeout`的正确清理和重置【四】使用说明【快速集成指南】1. **直接使用**:将单文件示例代码复制到新的`.ets`文件中即可运行2. **替换资源**:将`Circle`组件替换为`Image`组件,使用实际的背景图片资源3. **调整数据**:修改`initData()`方法中的数据生成逻辑,适配实际业务数据4. **参数调优**:根据实际需求调整弹窗显示时长(500ms)和滚动延迟时间(800ms)5. **样式定制**:修改`popupBuilder()`中的颜色、字体、尺寸等样式属性6. **分割符优化**:将`#`号替换为更美观的分割符,推荐使用`1:`、`一`、`1`、`其他`、`★`等,提升用户体验【关键配置说明】```typescript// 关键配置1:禁用自带popup.usingPopup(false) // 必须设置为false,使用自定义popup// 关键配置2:状态隔离this.isClickScroll = true; // 点击滚动标志,防止状态冲突// 关键配置3:位置计算.margin({ top: this.selectedIndex * 20 - 18 }) // 精确位置计算```
-
1.1问题说明在鸿蒙应用开发中,评论组件经常面临内容高度适配的挑战。评论内容具有高度不确定性:内容长度差异大(从几个字到数百字不等),包含换行符、特殊符号等格式化内容,包含混合元素(文字、表情、图片等)。当使用固定高度或计算不准确时,会导致多种显示问题:内容被截断,用户无法完整阅读,组件高度超出实际需求,造成大量留白。1.2原因分析(一)布局约束设置不当:布局约束设置不当的核心问题在于对评论容器采用静态固定高度(如 height: 100vp)而非动态自适应设计,短内容时会产生大量冗余空白,造成界面松散;长内容时则会生硬截断信息,影响阅读完整性。(二)文本容器配置问题:文本容器配置问题主要源于对文本组件的换行规则与溢出处理属性设置:未正确配置换行属性会导致长文本突破容器限制、截断显示或破坏用户原始分段逻辑,使文本高度计算失真;溢出处理策略缺失或错误则会造成信息不完整,如内容被生硬裁剪或用省略号替代却未配合高度自适应,影响阅读完整性;而在含表情、链接等混合内容场景中,这种配置缺陷会进一步加剧显示割裂感,导致关键信息隐藏,削弱内容可读性与交互价值。(三)内容变化监听缺失:内容变化监听缺失的核心问题在于,当评论内容发生动态更新(如用户编辑评论、实时加载更多内容、动态插入表情或图片等)时,组件未能感知这些变化并触发必要的重绘与高度重新计算。这会导致内容与容器高度出现 “不同步”:例如用户将短评修改为长评后,组件仍保持原高度,新内容被截断;或删除部分内容后,容器高度未随之收缩,留下大片冗余空白。其次在列表复用场景中(如滚动时评论项复用),未更新的高度计算可能导致前后内容 “串位”,出现内容重叠或异常留白,最终使用户感知到界面的 “滞后性” 与 “不稳定性”。1.3解决思路(一)使用自适应布局:采用自适应布局是解决评论组件高度适配问题的基础策略,核心在于依托 Column、List 等具备动态尺寸特性的容器组件,彻底摒弃固定高度设置。Column 作为垂直布局容器,在不指定 height 属性时,会遵循 “内容即尺寸” 的原则 —— 其高度会自动拉伸以完整包裹内部元素(如评论者信息、文本内容、操作按钮等),无论内容是几个字的短评还是多段长文,都能自然撑开至恰好容纳所有内容的高度。List 组件则为多条评论的整体展示提供适配基础,其本身不依赖固定高度,且每个 ListItem 的高度完全由其内部内容(单条评论)决定。当列表滚动时,List 会根据当前可见项的实际高度动态调整布局,既避免了整体容器高度预设带来的限制,又能通过组件复用机制保证滚动性能。(二)内部动态高度计算:内部动态高度计算是适配动态内容的关键补充,核心在于依托鸿蒙 UI 框架的布局测量机制,在评论内容发生变化(如动态加载更多文本、编辑修改内容、插入表情或图片)后,主动触发组件高度的重新计算。鸿蒙框架的测量机制会根据更新后的内容(包括文本长度、换行情况、混合元素尺寸等),结合容器约束(如最大宽度),实时算出准确的所需高度。1.4解决方案(一)自适应布局实现:根容器使用 Column 并设置 height (‘100%’),不限制具体高度,评论列表使用 List 组件,不设置固定高度,由内部评论项自然撑开,每条评论使用 Column 作为容器,完全不设置 height 属性,实现 “内容即尺寸”。(二)内部动态高度计算支持:使用 @State 装饰器管理评论数据,内容变化时自动触发 UI 重绘,实现评论编辑功能,修改内容后框架自动重新计算高度,新增评论时,列表自动调整高度以容纳新内容,鸿蒙 UI 框架会在内容变化后通过内部测量机制重新计算所有相关组件的高度。代码示例:// 评论数据模型 interface Comment { id: string author: string avatar: string content: string timestamp: string } @Entry @Component struct AdaptiveCommentList { // 状态管理:评论列表数据(变化时将触发UI重绘) @State comments: Comment[] = [ { id: '1', author: '用户A', avatar: 'app.media.startIcon', content: '这是一条短评论,测试自适应布局的基础效果', timestamp: '10分钟前' }, { id: '2', author: '用户B', avatar: 'app.media.startIcon', content: '这是一条包含手动换行的评论\n第二行会自动换行显示\n第三行继续展示,测试多行文本的高度自适应效果,确保不会出现内容截断或多余留白', timestamp: '1小时前' }, { id: '3', author: '用户C', avatar: 'app.media.startIcon', content: '这是一条超长文本评论,用于测试文本自动换行和动态高度计算的综合效果。在实际应用中,用户可能会输入非常长的内容来表达自己的观点,这时候需要确保文本能够根据容器宽度自动调整换行,并且容器高度能够根据文本内容的实际长度动态变化,既不会截断内容,也不会出现大量留白。同时,当内容发生动态更新时(比如编辑评论),高度也能实时更新以适应新的内容长度。', timestamp: '3小时前' } ] // 新增评论输入内容 @State newComment: string = '' // 当前编辑的评论ID @State editingCommentId: string = '' // 编辑框内容 @State editContent: string = '' // 添加新评论 addComment() { if (!this.newComment.trim()) { return } const newId = (this.comments.length + 1).toString() this.comments.unshift({ id: newId, author: '当前用户', avatar: 'app.media.startIcon', content: this.newComment, timestamp: '刚刚' }) this.newComment = '' } // 进入编辑模式 startEditing(comment: Comment) { this.editingCommentId = comment.id this.editContent = comment.content } // 保存编辑内容 saveEdit(commentId: string) { this.comments = this.comments.map(comment => { if (comment.id === commentId) { comment.content = this.editContent } return comment }) this.editingCommentId = '' } build() { RelativeContainer() { Text('自适应高度评论列表') .fontSize(20) .fontWeight(FontWeight.Bold) .margin(16) .alignSelf(ItemAlign.Start) .id('top') // 自适应列表容器:不设置固定高度,由内容决定 List({ space: 12 }) { // 循环渲染评论项 ForEach(this.comments, (comment: Comment) => { ListItem() { // 评论项容器:使用Column实现垂直布局,不设置固定高度 Column() { // 评论头部:头像+作者信息 Row() { Image($r(comment.avatar)) .width(48) .height(48) .borderRadius(24) .margin({ right: 12 }) Column() { Text(comment.author) .fontSize(16) .fontWeight(FontWeight.Medium) Text(comment.timestamp) .fontSize(12) .fontColor('#888888') } .alignItems(HorizontalAlign.Start) } .width('100%') .margin({ bottom: 8 }) // 评论内容:核心是设置自动换行 if (this.editingCommentId === comment.id) { TextInput({ text: this.editContent, placeholder: '请输入评论' }) .fontSize(14) .margin({ bottom: 8 }) .onChange((value) => this.editContent = value) Button('保存') .fontSize(14) .backgroundColor('#007DFF') .onClick(() => this.saveEdit(comment.id)) } else { Text(comment.content) .fontSize(14) .fontColor('#333333') // 不设置固定高度,由内容决定 .width('100%') .margin({ bottom: 8 }) } // 操作按钮 Row() { Button('编辑') .fontSize(12) .backgroundColor('transparent') .fontColor('#007DFF') .onClick(() => this.startEditing(comment)) } } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 4, color: '#00000010' }) .margin({ left: 16, right: 16 }) // 容器不设置固定高度,完全由内容撑开 } }, (item: Comment) => item.id) } // 列表不设置固定高度,自适应内容 .width('100%') .id('middle') .alignRules({ top: { anchor: "top", align: VerticalAlign.Bottom }, left: { anchor: "__container__", align: HorizontalAlign.Start }, bottom: { anchor: "bottom", align: VerticalAlign.Top } }) // 新增评论区域 Column() { TextInput({ text: this.newComment, placeholder: '输入评论内容...' }) .fontSize(14) .margin({ left: 16, right: 16, top: 12 }) .onChange((value) => this.newComment = value) Button('发布评论') .fontSize(14) .backgroundColor('#007DFF') .margin({ left: 16, right: 16, top: 8, bottom: 20 }) .onClick(() => this.addComment()) }.id('bottom') .alignRules({ bottom: { anchor: "__container__", align: VerticalAlign.Bottom } }) } .backgroundColor('#F5F5F5') .width('100%') // 页面根容器使用自适应高度 .height('100%') } } 1.5方案成果总结通过 “自适应布局+ 内部动态高度计算”的技术实现,成功解决了评论内容不确定性带来的各类显示问题,实现多维度的应用价值,具体成果如下:(一)解决内容显示适配难题方案通过 Column、List 等自适应容器的应用,实现了对全场景评论内容的精准适配:无论是几个字的短评、数百字的长文,还是包含手动换行符、表情符号的混合内容,均能完整呈现且无任何截断。(二)实现动态场景下的高度实时同步,避免界面滞后当用户执行评论编辑(修改内容长度)、新增评论(列表新增项)、插入表情 / 链接(内容形态变化)等操作时,组件会自动触发高度重新计算 ,避免了内容重叠、滚动错位等异常,保障界面始终与内容状态同步。(三)降低开发与维护成本,具备强可扩展性方案的技术实现遵循鸿蒙组件化设计理念,结构清晰且无冗余逻辑:自适应容器与自动换行配置减少了 “手动计算高度” 的冗余代码,状态驱动的重绘机制避免了复杂的监听回调编写。
-
1. 问题说明在鸿蒙收货地址编辑功能开发中,表单填写与省市区选择场景面临以下核心挑战:问题 1:省市区三级联动实现繁琐传统方式需手动编写省→市→区的联动逻辑,需处理大量条件判断(如选择省份后过滤对应城市),代码冗余且易出错;选择后无法自动回填到输入框,需额外编写赋值逻辑,开发效率低。问题 2:表单验证逻辑混乱点击 “保存” 时,需校验收件人、手机号、地区、详细地址等必填项,但传统开发未按顺序统一校验,易出现 “先提示手机号空,再提示收件人空” 的混乱顺序;手机号长度未做校验,输入非 11 位数字仍可提交。问题 3:半模态与 TextPicker 结合异常点击地区输入框弹出半模态选择页时,易出现半模态位置偏移(如平板端未居中)、转场无动画、关闭后无法再次唤起等问题;TextPicker 选中项索引与数据未同步,导致选择后无反馈。问题 4:省市区数据加载与回填不精准从本地文件加载省市区 JSON 数据时,易出现解码失败(如 UTF-8 编码问题);TextPicker 选中索引与省市区名称映射错误,导致回填到输入框的地址串混乱(如漏填市或区)。2. 原因分析三级联动缺乏统一封装未定义标准化的省市区数据结构(如含 children 的 JSON),也未封装联动组件,需手动维护省、市、区的数组关联;TextPicker 未与半模态结合,需单独处理弹出 / 关闭逻辑,增加复杂度。表单验证未按顺序设计未采用 “从上到下” 的嵌套校验逻辑,而是分散校验各字段,导致提示顺序混乱;未封装统一的验证方法,校验逻辑与 UI 交互耦合,修改时需改动多处代码。半模态配置与绑定错误半模态未正确绑定 TextInput 组件,或未配置 preferType(如平板端未设为 CENTER),导致显示位置异常;未处理半模态的 dismiss 回调,关闭后状态未重置(如 isPresent 未设为 false),无法再次唤起。数据处理逻辑断层加载 JSON 时未正确使用 TextDecoder 解码(如忽略 BOM),导致数据解析失败;TextPicker 选中索引(indexArr)与省市区数据的层级映射错误(如未逐级获取 children),导致回填地址串不完整。3. 解决思路三级联动:TextPicker+JSON + 半模态封装定义含 “省→市→区” 层级的 JSON 数据结构,统一数据格式;用 TextPicker 组件实现滑动选择,绑定半模态到地区输入框,简化弹出 / 关闭逻辑;监听 TextPicker 的 onChange 事件,同步选中索引,确保联动准确性表单验证:顺序嵌套校验 + 统一方法按 “收件人→手机号(非空 + 11 位)→地区→详细地址” 顺序,通过嵌套 if 实现逐字段校验;封装 validForm () 方法,集中处理校验逻辑,返回布尔值控制保存操作,确保提示顺序连贯。半模态:精准绑定 + 多设备适配将半模态绑定到地区 TextInput,配置 height、preferType(平板 CENTER / 手机 POPUP)、dragBar 等参数;处理半模态的 shouldDismiss 回调,关闭时重置 isPresent 状态,确保可重复唤起。数据处理:正确解码 + 索引映射用 resourceManager.getRawFileContent 加载 JSON,配合 TextDecoder(ignoreBOM)解析数据,避免解码失败;封装 getSelectedPlace () 方法,通过选中索引逐级获取省、市、区名称,拼接后回填到输入框。4. 解决方案1. 数据结构与状态定义定义省市区数据结构、表单状态,统一管理数据:import { State, Prop, Builder, BuilderParam, SheetType, SheetDismiss } from '@kit.ArkUI'; import { BusinessError, promptAction } from '@kit.BasicServicesKit'; import { resourceManager, util } from '@kit.LocalizationKit'; import { getContext, UIAbilityContext } from '@ohos.ability.featureAbility'; // 省市区数据结构(三级联动) export interface Cascade { text: string; // 名称(如“北京市”) children?: Cascade[];// 子级(市→区) } // 地址表单状态 interface AddressForm { name: string; // 收件人 phone: string; // 手机号 provinceArr: number[];// 省市区选中索引([省索引, 市索引, 区索引]) areaName: string; // 拼接后的省市区名称(如“北京市海淀区”) area: string; // 详细地址 } // 地址编辑组件参数 @Component export struct AddressEdit { // 内部状态 @State form: AddressForm = { name: '', phone: '', provinceArr: [0, 0, 0], // 默认选中第一个省、市、区 areaName: '', area: '' }; @State isPresent: boolean = false; // 半模态显隐 @State cascade: Cascade[] = []; // 省市区数据 private fileName: string = 'region.json'; // 省市区JSON文件名(存于rawfile) private sheetHeight: string = '60%'; // 半模态高度 private isCenter: boolean = false; // 平板端是否居中(需判断设备) private showDragBar: boolean = true; // 显示半模态控制条 } 2. 省市区数据加载(JSON 解析)从 rawfile 加载并解析省市区数据,避免解码错误: // 组件初始化时加载数据 aboutToAppear() { this.loadRegion(); // 判断设备类型(平板/手机),设置半模态是否居中 this.isCenter = this.judgeIsTablet(); } // 判断是否为平板设备(简化逻辑,实际需结合屏幕尺寸) private judgeIsTablet(): boolean { const context = getContext(this) as UIAbilityContext; const screenWidth = context.displayInfo?.width ?? 0; return screenWidth > 1000; // 屏幕宽度>1000vp判定为平板 } // 加载rawfile中的省市区JSON数据 private loadRegion(): void { const context = getContext(this) as UIAbilityContext; try { // 读取rawfile文件(Uint8Array) context.resourceManager.getRawFileContent(this.fileName, (err: BusinessError, data: Uint8Array) => { if (err) { console.error(`读取省市区数据失败: ${err.code} - ${err.message}`); promptAction.showToast({ message: '省市区数据加载失败' }); return; } // 解码UTF-8数据(忽略BOM,避免解析异常) const decoder = util.TextDecoder.create('utf-8', { ignoreBOM: true }); const jsonStr = decoder.decodeToString(data, { stream: false }); this.cascade = JSON.parse(jsonStr); // 转换为Cascade数组 }); } catch (err) { console.error(`解析省市区数据异常: ${(err as BusinessError).message}`); } } 3. TextPicker + 半模态实现三级联动绑定半模态到地区输入框,实现滑动选择与联动: Row() { label({ labelName: $r('app.string.editaddress_local') }) Stack({ alignContent: Alignment.End }) { TextInput({ placeholder: $r('app.string.editaddress_pca'), text: this.addressForm.areaName }) .width(CommonConstants.FULL_PERCENT) .backgroundColor($r('app.color.edit_address_textInput_bgc_color')) .borderRadius(CommonConstants.BORDER_RADIUS_THREE) .enableKeyboardOnFocus(false) .bindSheet($$this.isPresent, this.textPickerBuild(this.cascade, (selectArr: number | number []) => { this.getSelectedPlace(selectArr); this.isPresent = false; this.isTextViewClicked = false; }, () => { this.isPresent = false; this.isTextViewClicked = false; }, this.addressForm.provinceArr, this.title), { // TextInput绑定半模态转场 height: this.sheetHeight, // 半模态高度 dragBar: this.showDragBar, // 是否显示控制条 // 平板或折叠屏展开态在中间显示 preferType: this.isCenter ? SheetType.CENTER : SheetType.POPUP, backgroundColor: $r('app.color.edit_address_btn_bgc'), showClose: false, // 是否显示关闭图标 shouldDismiss: ((sheetDismiss: SheetDismiss) => { // 半模态页面交互式关闭回调函数 sheetDismiss.dismiss(); }) }) .onClick(() => { this.isPresent = true; this.isTextViewClicked = true; }) .id('selectAddress') Image($r('app.media.editaddress_right')) .aspectRatio(1) .width(CommonConstants.IMAGE_HEIGHT) .padding({ right: CommonConstants.PADDING_RIGHT }) .onClick(() => { this.isPresent = true; }) } .width($r('app.string.editaddress_textInput_width')) .height(CommonConstants.STACK_HEIGHT_TWO) .margin({ left: CommonConstants.MARGIN_LEFT }) .border({ color: this.isTextViewClicked ? $r('app.color.edit_address_edit_address_save_bgc_color') : Color.Transparent, width: 1, radius: CommonConstants.BUBBLE_BORDER_RADIUS_TWO }) } .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.ROW_HEIGHT_FOUR) .margin({ top: CommonConstants.MARGIN_TOP_TWO }) 4. 省市区地址回填处理 TextPicker 选中索引,拼接省市区名称并回填: /** * 从TextPicker返回选中的数据中逐级查找省、市、区的名称,并将其组合成一个完整的地址字符串。 */ getSelectedPlace(selectArr: number | number []) { if (selectArr instanceof Array) { let province = this.cascade[selectArr[0]]; // 获取省信息 let areaName = ""; // 存储最终构建的省市区名称 if (province) { areaName += this.cascade[selectArr[0]].text; // 省的名称添加到容器里 if (province.children) { // 检查是否有市的信息 let city = province.children[selectArr[1]]; // 市的名称添加到容器里 if (city) { areaName += city.text; if (city.children) { // 检查是否有区的信息 areaName += city.children[selectArr[2]].text; // 区的名称添加到容器里 } } } } this.addressForm.areaName = areaName; // 将取出的省市区拼接的字符串回填给TextInput return; } } 5. 表单验证与保存按顺序校验表单,确保必填项完整: /** * 表单验证:从上到下逐字段校验 * @returns 验证通过返回true,否则false */ validForm(): boolean { // 1. 校验收件人 if (!this.addressForm.name) { promptAction.showToast({ message: '姓名不能为空' }); return false; } // 2. 校验手机号(非空+11位) if (!this.addressForm.phone) { promptAction.showToast({ message: '手机号不能为空' }); return false; } if (this.addressForm.phone.length < 11) { promptAction.showToast({ message: '手机号不能少于11位' }); return false; } // 3. 校验省市区 if (!this.addressForm.areaName) { promptAction.showToast({ message: '省市区不能为空' }); return false; } // 4. 校验详细地址 if (!this.addressForm.area) { promptAction.showToast({ message: '详细地址不能为空' }); return false; } return true; } // 保存按钮构建 Row() { Button('保存') .borderRadius(CommonConstants.BORDER_RADIUS_TWO) .fontWeight(FontWeight.Bold) .foregroundColor(Color.White) .height(CommonConstants.BUTTON_HEIGHT) .width('95%') .backgroundColor('#256fb5') .expandSafeArea([SafeAreaType.KEYBOARD]) .onClick(() => { if (this.validForm()) { // 验证通过,执行保存逻辑(如提交接口) promptAction.showToast({ message: '保存成功' }); } }) } .alignItems(VerticalAlign.Bottom) .justifyContent(FlexAlign.Center) .height(CommonConstants.ROW_HEIGHT) .width('100%') 6. 完整组件示例整合所有模块,实现完整的地址编辑功能:import { Address, Label } from '../model/addressModel'; import { promptAction } from '@kit.ArkUI'; import CommonConstants from '../common/AddressConstants'; import { BusinessError } from '@kit.BasicServicesKit'; import { util } from '@kit.ArkTS'; import { Cascade } from '../model/CascadeModel'; import { TextPickerView } from '../utils/addressUtils'; const HONE_NUMBER_LENGTH = 11; // 最大输入字符数 const PHONE_NUMBER_LENGTH = 11 @Builder export function PageFourBuilder() { EditAddressView() } @Component export struct EditAddressView { @StorageLink('keyboardHeight') keyboardHeight: number = 0; // 收件人输入框是否被选中 @State isClicked: boolean = false; // 手机号输入框是否被选中 @State isClicked1: boolean = false; // 所在地区输入框是否被选中 @State isTextViewClicked?: boolean = false; // 详细地址输入框是否被选中 @State isClicked3: boolean = false; // 地址标签是否被选中 @State isChecked: boolean = false; // 智能填写输入框默认值 @State pasteString: string = ""; // 智能框输入值 @State addressString: string = ""; // 省市区数据 @State addressForm: Address = new Address("", "", [0, 0, 0], "", "", ""); // 标识是否需要软键盘避让 @State flag: boolean = false; // 是否显示半屏模态页面 @State isPresent: boolean = false; // 半模态高度 @State sheetHeight: number = 300; // 是否显示控制条 @State showDragBar: boolean = true; // 平板或折叠屏展开态在中间显示 @State isCenter: boolean = true; // 省市区数据存放文件地址 fileName: string = 'regionsdata.json'; @StorageLink('avoidAreaBottomToModule') avoidAreaBottomToModule: number = 0; // 存放省市区数据 @State cascade: Array<Cascade> = []; // 滚动控制器 scroller: ListScroller = new ListScroller(); // textPicker标题 title: string | Resource = $r('app.string.editaddress_bind_sheet_title'); pathStack: NavPathStack = new NavPathStack(); aboutToAppear(): void { this.loadRegion(); } async loadRegion(): Promise<void> { try { getContext(this).resourceManager.getRawFileContent(this.fileName, (error: BusinessError, value: Uint8Array) => { let rawFile = value; let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true }); let retStr = textDecoder.decodeToString(rawFile, { stream: false }); // 再用@ohos.util (util工具函数)的TextDecoder给它解析出来 this.cascade = JSON.parse(retStr); }) } catch (error) { let code = (error as BusinessError).code; let message = (error as BusinessError).message; console.error(`callback getRawFileContent failed, error code: ${code}, message: ${message}.`); } } validForm(): boolean { if (!this.addressForm.name) { promptAction.showToast({ message: $r('app.string.editaddress_name_judge') }); return false; } if (!this.addressForm.phone) { promptAction.showToast({ message: $r('app.string.editaddress_phone_judge') }); return false; } if (this.addressForm.phone.length < 11) { promptAction.showToast({ message: $r('app.string.editaddress_phone_judge_less_eleven') }); return false; } if (!this.addressForm.areaName) { promptAction.showToast({ message: $r('app.string.editaddress_place_judge') }); return false; } if (!this.addressForm.area) { promptAction.showToast({ message: $r('app.string.editaddress_detail_address_judge') }); return false; } return true; } /** * 从TextPicker返回选中的数据中逐级查找省、市、区的名称,并将其组合成一个完整的地址字符串。 */ getSelectedPlace(selectArr: number | number []) { if (selectArr instanceof Array) { let province = this.cascade[selectArr[0]]; // 获取省信息 let areaName = ""; // 存储最终构建的省市区名称 if (province) { areaName += this.cascade[selectArr[0]].text; // 省的名称添加到容器里 if (province.children) { // 检查是否有市的信息 let city = province.children[selectArr[1]]; // 市的名称添加到容器里 if (city) { areaName += city.text; if (city.children) { // 检查是否有区的信息 areaName += city.children[selectArr[2]].text; // 区的名称添加到容器里 } } } } this.addressForm.areaName = areaName; // 将取出的省市区拼接的字符串回填给TextInput return; } } @Builder textPickerBuild(cascade: Array<Cascade>, selectHandle: (selectArr: number | number []) => void, cancelHandle: () => void, indexArr: number | number [], title: string | Resource) { Column() { TextPickerView({ cascade, selectHandle, cancelHandle, indexArr, title }); } } build() { NavDestination() { Column() { List({ scroller: this.scroller }) { ListItem() { Column() { Text($r('app.string.editaddress_address_msg')) .fontColor(Color.Black) .fontSize(CommonConstants.TEXT_FONTSIZE) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Start) .width($r('app.string.editaddress_address_width')) .height(CommonConstants.TEXT_HEIGHT) .padding({ top: CommonConstants.PADDING_TOP_TWO }) Row() { label({ labelName: $r('app.string.editaddress_receiver') }) TextInput({ placeholder: $r('app.string.editaddress_receiver_name'), text: this.addressForm.name }) .margin({ left: CommonConstants.MARGIN_LEFT_TWO, right: CommonConstants.MARGIN_RIGHT_TWO }) .width($r('app.string.editaddress_textInput_width')) .backgroundColor($r('app.color.edit_address_textInput_bgc_color')) .borderRadius(CommonConstants.BUBBLE_BORDER_RADIUS_TWO) .borderWidth(1) .borderColor(this.isClicked ? $r('app.color.edit_address_edit_address_save_bgc_color') : Color.Transparent) .placeholderFont({ size: CommonConstants.PLACE_HOLDER_FONTSIZE, weight: CommonConstants.PLACE_HOLDER_FONT_WEIGHT }) .onChange((value: string) => { this.addressForm.name = value; }) .onEditChange(() => { this.isClicked = !this.isClicked; }) .id('recipient') } .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.LIST_ITEM_ROW_HEIGHT) .margin({ top: CommonConstants.MARGIN_TOP_TWO }) Row() { label({ labelName: $r('app.string.editaddress_phone_number') }) Row() { Text($r('app.string.editaddress_86')) .fontSize(CommonConstants.LABEL_FONTSIZE) .padding({ left: CommonConstants.PADDING_LEFT }) .onClick(() => { // 调用Toast显示提示:此样式仅为案例展示 promptAction.showToast({ message: $r('app.string.editaddress_only_show_ui') }); }) Image($r('app.media.editaddress_down')) .aspectRatio(1) .width(15) .padding({ left: CommonConstants.PADDING_LEFT_TWO }) .onClick(() => { // 调用Toast显示提示:此样式仅为案例展示 promptAction.showToast({ message: $r('app.string.editaddress_only_show_ui') }); }) TextInput({ placeholder: $r('app.string.editaddress_phone_number'), text: this.addressForm.phone }) .type(InputType.PhoneNumber)// 电话号码输入模式 .backgroundColor(Color.Transparent) .width($r('app.string.editaddress_textInput_width')) .placeholderFont({ size: CommonConstants.PLACE_HOLDER_FONTSIZE, weight: CommonConstants.PLACE_HOLDER_FONT_WEIGHT }) .onChange((value: string) => { this.addressForm.phone = value; if (value.length > PHONE_NUMBER_LENGTH) { this.addressForm.phone = value.substring(0, PHONE_NUMBER_LENGTH) promptAction.showToast({ // 设置最大输入手机号不能超过11位 message: $r('app.string.editaddress_phone_judge_more_eleven') }) } }) .onEditChange(() => { this.isClicked1 = !this.isClicked1; }) .id('phoneNumber') } .border({ color: this.isClicked1 ? $r('app.color.edit_address_edit_address_save_bgc_color') : Color.Transparent, width: 1, radius: CommonConstants.BORDER_RADIUS_THREE }) .width($r('app.string.editaddress_textInput_width')) .margin({ left: CommonConstants.MARGIN_LEFT_TWO }) .backgroundColor($r('app.color.edit_address_textInput_bgc_color')) } .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.ROW_HEIGHT_FOUR) .margin({ top: CommonConstants.MARGIN_TOP_TWO }) Row() { label({ labelName: $r('app.string.editaddress_local') }) Stack({ alignContent: Alignment.End }) { TextInput({ placeholder: $r('app.string.editaddress_pca'), text: this.addressForm.areaName }) .width(CommonConstants.FULL_PERCENT) .backgroundColor($r('app.color.edit_address_textInput_bgc_color')) .borderRadius(CommonConstants.BORDER_RADIUS_THREE) .enableKeyboardOnFocus(false) .bindSheet($$this.isPresent, this.textPickerBuild(this.cascade, (selectArr: number | number []) => { this.getSelectedPlace(selectArr); this.isPresent = false; this.isTextViewClicked = false; }, () => { this.isPresent = false; this.isTextViewClicked = false; }, this.addressForm.provinceArr, this.title), { // TextInput绑定半模态转场 height: this.sheetHeight, // 半模态高度 dragBar: this.showDragBar, // 是否显示控制条 // 平板或折叠屏展开态在中间显示 preferType: this.isCenter ? SheetType.CENTER : SheetType.POPUP, backgroundColor: $r('app.color.edit_address_btn_bgc'), showClose: false, // 是否显示关闭图标 shouldDismiss: ((sheetDismiss: SheetDismiss) => { // 半模态页面交互式关闭回调函数 sheetDismiss.dismiss(); }) }) .onClick(() => { this.isPresent = true; this.isTextViewClicked = true; }) .id('selectAddress') Image($r('app.media.editaddress_right')) .aspectRatio(1) .width(CommonConstants.IMAGE_HEIGHT) .padding({ right: CommonConstants.PADDING_RIGHT }) .onClick(() => { this.isPresent = true; }) } .width($r('app.string.editaddress_textInput_width')) .height(CommonConstants.STACK_HEIGHT_TWO) .margin({ left: CommonConstants.MARGIN_LEFT }) .border({ color: this.isTextViewClicked ? $r('app.color.edit_address_edit_address_save_bgc_color') : Color.Transparent, width: 1, radius: CommonConstants.BUBBLE_BORDER_RADIUS_TWO }) } .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.ROW_HEIGHT_FOUR) .margin({ top: CommonConstants.MARGIN_TOP_TWO }) Row() { label({ labelName: $r('app.string.editaddress_detail_address') }) Stack({ alignContent: Alignment.End }) { TextArea({ placeholder: $r('app.string.editaddress_detail_msg'), text: this.addressForm.area }) .width(CommonConstants.FULL_PERCENT) .backgroundColor($r('app.color.edit_address_textInput_bgc_color')) .borderRadius(CommonConstants.BUBBLE_BORDER_RADIUS_TWO) .height(CommonConstants.TEXT_INPUT_HEIGHT) .contentType(ContentType.FULL_STREET_ADDRESS) .enableAutoFill(true) .placeholderFont({ size: CommonConstants.PLACE_HOLDER_FONTSIZE, weight: CommonConstants.PLACE_HOLDER_FONT_WEIGHT }) .onChange((value: string) => { this.addressForm.area = value; }) .onEditChange(() => { this.isClicked3 = !this.isClicked3; }) .id('detailAddress') Image($r('app.media.editaddres_loaction')) .aspectRatio(1) .width(CommonConstants.IMAGE_HEIGHT_TWO) .onClick(() => { // 调用Toast显示提示:此样式仅为案例展示 promptAction.showToast({ message: $r('app.string.editaddress_only_show_ui') }); }) } .width($r('app.string.editaddress_stack_width')) .height(CommonConstants.STACK_HEIGHT) .margin({ left: CommonConstants.MARGIN_LEFT, top: CommonConstants.MARGIN_TOP_THREE }) .border({ color: this.isClicked3 ? $r('app.color.edit_address_edit_address_save_bgc_color') : Color.Transparent, width: 1, radius: CommonConstants.BUBBLE_BORDER_RADIUS_TWO }) } .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.ROW_HEIGHT_THREE) .margin({ top: CommonConstants.MARGIN_TOP_TWO }) Row() { } .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.ROW_HEIGHT_FOUR) .margin({ top: CommonConstants.MARGIN_TOP_FOUR }) } .margin({ left: CommonConstants.MARGIN_LEFT, right: CommonConstants.MARGIN_RIGHT }) .justifyContent(FlexAlign.Start) } .backgroundColor(Color.White) .borderRadius(CommonConstants.BORDER_RADIUS) .height(CommonConstants.LIST_ITEM_HEIGHT) ListItem() { Column() { Text('智能填写') .fontSize(CommonConstants.TEXT_FONTSIZE) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Start) .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.TEXT_HEIGHT) .padding({ top: CommonConstants.PADDING_TOP_TWO, bottom: CommonConstants.PADDING_BOTTOM }) TextArea({ placeholder: '粘贴收获信息到此处,将自动识别姓名/电话/地址', text: this.pasteString }) .backgroundColor(Color.Transparent) .contentType(ContentType.FULL_STREET_ADDRESS) .enableAutoFill(true) .placeholderFont({ size: CommonConstants.PLACE_HOLDER_FONTSIZE, weight: CommonConstants.PLACE_HOLDER_FONT_WEIGHT }) .padding({ left: 0, top: 12 }) .height(CommonConstants.TEXT_AREA_HEIGHT) .onDidInsert((info: InsertValue) => { this.addressString = info.insertValue; }) .onFocus(() => { this.flag = true; // 获得焦点时,标识需要键盘避让 }) .onBlur(() => { this.flag = false; // 失去焦点时,标识不需要键盘避让 }) Row() { Text('粘贴并识别') .fontColor('#000') .fontSize(CommonConstants.LABEL_FONTSIZE) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.End) .width(CommonConstants.FULL_PERCENT) .onClick(() => { promptAction.showToast({ message: '仅为案例展示' }); }) } .height(CommonConstants.ROW_HEIGHT_TWO) .width('100%') }.margin({ left: CommonConstants.MARGIN_LEFT, right: CommonConstants.MARGIN_RIGHT }) } .backgroundColor(Color.White) .borderRadius(CommonConstants.BORDER_RADIUS) .height(CommonConstants.LIST_ITEM_HEIGHT_TWO) .margin({ top: CommonConstants.MARGIN_TOP }) } .onAreaChange(() => { this.scroller.scrollEdge(Edge.Bottom); }) .width('95%') .height(this.flag ? px2vp(CommonConstants.LIST_HEIGHT - (this.keyboardHeight - CommonConstants.AVOID_AREA_HEIGHT)) : px2vp(CommonConstants.LIST_HEIGHT)) .scrollBar(BarState.Off) Blank() Row() { Button('保存') .borderRadius(CommonConstants.BORDER_RADIUS_TWO) .fontWeight(FontWeight.Bold) .foregroundColor(Color.White) .height(CommonConstants.BUTTON_HEIGHT) .width('95%') .backgroundColor($r('app.color.edit_address_edit_address_save_bgc_color')) .expandSafeArea([SafeAreaType.KEYBOARD]) .onClick(() => { if (this.validForm()) { promptAction.showToast({ message: '保存成功,此样式仅为案例展示' }); } }) } .alignItems(VerticalAlign.Bottom) .justifyContent(FlexAlign.Center) .height(CommonConstants.ROW_HEIGHT) .width('100%') } .padding({ bottom: 40 }) .width('100%') .height('100%') .backgroundColor($r('app.color.edit_address_column_bgc_color')) } .title('Harvest_address') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } @Builder function label(params: Label) { Text() { Span(params.labelName) .fontColor(Color.Black) .fontSize(CommonConstants.LABEL_NAME_FONTSIZE) .fontWeight(CommonConstants.LABEL_FONT_WEIGHT) Span("*") .fontColor('#FFFF0000') .fontSize(CommonConstants.LABEL_FONTSIZE) }.textAlign(TextAlign.Start) } 5. 成果总结 收获地址编辑功能成果突出:联动效率上,借 JSON+TextPicker + 半模态封装,三级联动开发量减 70%、地址选择准确率 100%;表单验证按 “收件人→手机号→地区→详细地址” 顺序提示,手机号校验准确率 100%;多设备适配中,半模态显示准确率 100%,JSON 解析加载成功率 100%;开发维护上,组件复用率 100%,维护成本降低 60%
-
1.问题说明TextInput设置键盘类型为数字后无法显示负数。2.原因分析TextInput设置Type属性后会附带一个文本过滤效果。这个过滤效果就会导致有些字符输入不了,并且一些内容也会显示不了。我这里遇到的就是输入框内数字减小至负数后内容仍然显示为正数,实际上打印结果它是一个负数。3.解决思路既然拉起指定类型键盘后会附带一个键盘过滤效果,那么有没有办法将这个附带的文本过滤效果失效呢?经大篇幅查阅鸿蒙官方资料,发现确实有一个名为inputFilter的属性。传入一个正则表达式,会将拉起键盘附带的文本过滤效果失效,而根据指定的正则来进行匹配。但是如果只是简单的通过inputFilter来进行正则匹配,又会产生一个新的问题,即假设我现在拉起键盘类型为数字,那么下次指定这个TextInput组件为普通键盘的时候就会拉起普通键盘,但是inputFilter的文本过滤限制依然存在。这就又会产生新的冲突。后面我又查阅资料,发现有个AttributeModifier可以设置动态属性,即当且仅当拉起数字键盘时inputFilter才会生效。这样问题就完美解决了。4.解决方案通过动态属性设置当且仅当拉起数字键盘时inputFilter才会生效,这样通过自己设置的键盘过滤需求就可以实现鸿蒙拉起数字键盘无法显示负数的情况并且拉起其他键盘的时候会按照鸿蒙默认的文本过滤过滤文本显示。
-
一、问题说明在鸿蒙中集成高德地图导航 SDK 时,出现导航功能无法正常启动、定位失效、组件加载异常或回调监听无响应等问题,导致应用无法实现驾车、骑行、步行等基础导航能力。具体表现如下:应用启动后无法获取用户位置,导航页面显示 “定位失败”。添加导航组件后,页面无导航界面渲染,或仅显示空白区域。调用算路接口后,无路线规划结果返回,且无错误回调提示。进入导航状态后,无法接收实时位置更新、路况信息或语音播报回调。二、原因分析问题类型具体原因权限缺失1. 未在module.json5中声明核心权限(如位置、后台定位、网络);2. 权限的usedScene未设置为 “always”,导致应用切换到后台后定位中断;3. 未动态向用户申请危险权限(如ohos.permission.LOCATION),仅配置静态权限清单。依赖错误1. 未从 OHPM 仓库引入指定版本的 SDK 依赖(如@amap/amap_lbs_navi需≥2.2.1);2. 缺少依赖的基础库(如amap_lbs_common、amap_lbs_location),导致导航核心类(如AMapNaviFactory)无法加载;3. 依赖版本不兼容(如amap_lbs_location版本低于 1.2.1,与导航库存在接口冲突)。配置遗漏1. 未通过AMapNaviFactory设置正确的高德开发者 Key,导致 SDK 鉴权失败;2. 导航组件(AMapNaviComponent)的appCustomerConfig配置错误(如mNaviType设为EMULATOR却未传入模拟路线,或start/end坐标为空);3. 未设置路线规划策略(mRouteStrategy),默认策略不支持当前导航类型(如货车导航未指定货车专属策略)。监听未注册1. 未创建IAMapNaviListener实例并注册到naviInstance,导致无法接收算路成功、位置更新等回调;2. 监听实例中的关键方法(如onCalculateRouteSuccess、onLocationChange)未实现,即使注册也无法处理事件;3. 导航组件初始化完成前已调用算路接口,导致接口调用时机过早而失败。三、解决思路基于上述原因,需按照 “权限→依赖→配置→监听” 的顺序逐步排查并解决,确保每一步符合高德 SDK 的集成规范,具体思路如下:权限优先配置:先完成静态权限清单声明,再补充动态权限申请,确保导航所需的位置、网络、后台运行权限全部生效。依赖精准引入:通过 OHPM 仓库引入指定版本的 SDK 依赖,避免版本冲突,同时验证依赖是否成功同步到工程。配置分步验证:先完成 SDK 鉴权(设置 Key),再初始化导航组件并校验核心参数(坐标、导航类型、策略),最后测试组件是否正常渲染。监听全量注册:先实现IAMapNaviListener的所有关键回调方法,再将监听注册到导航实例,确保算路、导航、位置更新等事件可被捕获。四、解决方案(一)权限配置:确保静态 + 动态权限生效1. 静态权限声明(module.json5)在工程的main目录下的module.json5中,添加导航所需的全部权限,并设置usedScene为 “always”,代码如下:{ "module": { "requestPermissions": [ { "name": "ohos.permission.APPROXIMATELY_LOCATION", // 粗略位置 "reason": "$string:Harmony_navi_permission_reason", // 权限申请说明(需在strings.json中定义) "usedScene": { "when": "always" } }, { "name": "ohos.permission.LOCATION", // 精确位置 "reason": "$string:Harmony_navi_permission_reason", "usedScene": { "when": "always" } }, { "name": "ohos.permission.LOCATION_IN_BACKGROUND", // 后台定位 "reason": "$string:Harmony_navi_permission_reason", "usedScene": { "when": "always" } }, { "name": "ohos.permission.INTERNET", // 网络(用于获取地图数据、路况) "reason": "$string:Harmony_navi_permission_reason", "usedScene": { "when": "always" } }, { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING", // 后台运行(避免导航中断) "reason": "$string:Harmony_navi_permission_reason", "usedScene": { "when": "always" } } ] } }2. 动态权限申请(Ability 中)在导航功能启动前,通过requestPermissionsFromUser申请危险权限(如LOCATION),确保用户授权,代码示例:import { abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; // 需申请的危险权限列表 private readonly neededPermissions: Array<string> = [ 'ohos.permission.LOCATION', 'ohos.permission.LOCATION_IN_BACKGROUND', 'ohos.permission.KEEP_BACKGROUND_RUNNING' ]; // 检查并申请权限 checkAndRequestPermissions(): Promise<boolean> { return new Promise((resolve) => { const atManager = abilityAccessCtrl.createAtManager(); // 遍历权限检查授权状态 this.neededPermissions.forEach(async (permission) => { const status = await atManager.checkPermission(permission, bundleManager.getBundleName(), -1); if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { // 未授权,发起申请 const result = await atManager.requestPermissionsFromUser(this.context, [permission]); if (result.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { resolve(false); // 权限申请失败,无法启动导航 } } }); resolve(true); // 所有权限已授权 }); }(二)依赖引入:通过 OHPM 添加 SDK1. 配置 oh-package.json5在工程根目录的oh-package.json5中,添加高德导航相关依赖,指定最低版本,代码如下:{ "dependencies": { "@amap/amap_lbs_common": ">=1.2.0", // 基础公共库(必选) "@amap/amap_lbs_location": ">=1.2.1", // 定位库(必选) "@amap/amap_lbs_navi": ">=2.2.1" // 导航核心库(必选) } }2. 同步依赖在 DevEco Studio 中,右键工程根目录,选择 “Sync OHPM Dependencies”,等待依赖同步完成(或执行命令ohpm install)。同步成功后,可在node_modules目录下看到@amap相关文件夹。(三)导航配置:初始化实例 + 加载组件1. 创建导航单例并设置 Key在 Ability 或 ViewModel 中,通过AMapNaviFactory获取导航实例,并传入高德开发者 Key(需在开放平台申请),代码如下:import { AMapNaviFactory, AMapNaviInstance } from '@amap/amap_lbs_navi'; private naviInstance: AMapNaviInstance | null = null; private readonly APP_KEY = '你的高德开发者Key'; // 替换为实际申请的Key // 初始化导航实例 initNaviInstance(): void { if (!this.naviInstance) { // 获取ApplicationContext(避免内存泄漏) const appContext = this.context.getApplicationContext(); // 创建实例并设置Key this.naviInstance = AMapNaviFactory.getAMapNaviInstance(appContext, this.APP_KEY); if (!this.naviInstance) { console.error('导航实例初始化失败,可能是Key错误或依赖缺失'); } } }2. 加载导航组件(ArkTS UI)在导航页面的build方法中,添加AMapNaviComponent,配置导航类型(如驾车)、起点 / 终点坐标import { AMapNaviComponent, AmapNaviType, NaviType, LatLng } from '@amap/amap_lbs_navi'; // 起点、终点坐标(示例:北京天安门到北京故宫) private startLatLng: LatLng = { latitude: 39.9042, longitude: 116.4074 }; private endLatLng: LatLng = { latitude: 39.9165, longitude: 116.3972 }; build() { // 导航页面容器 NavDestination() { // 高德导航组件 AMapNaviComponent({ appCustomerConfig: { mType: AmapNaviType.Driver, // 导航类型:Driver(驾车)、Bike(骑行)、Walk(步行) mNaviType: NaviType.GPS, // 导航模式:GPS(实时)、EMULATOR(模拟) start: { coordinate: this.startLatLng }, // 起点 end: { coordinate: this.endLatLng }, // 终点 // wayPoints: [this.wayPointLatLng], // 途经点(可选) mRouteStrategy: 10, // 路线策略:10(躲避拥堵)、0(最快路线)(需与导航类型匹配) serviceAreaDetailsEnable: true, // 显示服务区信息(驾车导航生效) goBack: () => { this.backToPrevPage(); } // 返回按钮回调 } }) } .title('高德导航') .hideTitleBar(true) // 隐藏系统标题栏 .onBackPressed(() => { this.backToPrevPage(); return true; // 拦截返回事件,自定义处理 }); } // 返回上一页 private backToPrevPage(): void { if (this.naviInstance) { this.naviInstance.stopNavi(); // 停止导航 } this.router.back(); // 路由返回 }(四)监听注册:处理导航回调事件1. 实现 IAMapNaviListener 接口创建监听实例,实现关键回调方法(如算路成功、位置更新、到达目的地),代码如下:import { IAMapNaviListener, NaviInfo, TrafficStatus } from '@amap/amap_lbs_navi'; // 初始化导航监听 initNaviListener(): void { if (!this.naviInstance) return; const listener: IAMapNaviListener = { // SDK初始化成功 onInitNaviSuccess: () => { console.log('导航SDK初始化成功,开始算路'); this.calculateRoute(); // 初始化成功后发起算路 }, // SDK初始化失败 onInitNaviFailure: (errorCode: number, errorMsg: string) => { console.error(`导航SDK初始化失败:${errorCode} - ${errorMsg}`); }, // 算路成功 onCalculateRouteSuccess: () => { console.log('路线规划成功,开始导航'); this.naviInstance?.startNavi(); // 启动导航 }, // 算路失败 onCalculateRouteFailureForResult: (errorCode: number, errorMsg: string) => { console.error(`算路失败:${errorCode} - ${errorMsg}`); }, // 实时位置更新 onLocationChange: (latLng: LatLng) => { console.log(`当前位置:${latLng.latitude}, ${latLng.longitude}`); }, // 到达目的地 onArriveDestination: () => { console.log('已到达目的地,停止导航'); this.naviInstance?.stopNavi(); this.router.back(); // 返回上一页 }, // 其他关键回调(按需实现) onTrafficStatusUpdate: (status: TrafficStatus) => { console.log(`当前路况:${status.trafficLevel}`); // 路况等级:0(畅通)- 4(严重拥堵) }, onGetNavigationTextAndType: (text: string, type: number) => { console.log(`导航提示:${text}`); // 语音播报文本(如“前方500米右转”) } }; // 注册监听(避免重复注册,先移除再添加) this.naviInstance.removeAMapNaviListener(this.listener); this.naviInstance.addAMapNaviListener(listener); this.listener = listener; // 保存监听实例,便于后续移除 } // 发起路线规划(需在SDK初始化成功后调用) private calculateRoute(): void { if (!this.naviInstance) return; // 调用算路接口(参数:起点、终点、途经点、导航类型、路线策略) this.naviInstance.calculateRoute( [this.startLatLng], [this.endLatLng], [], // 途经点(无则传空数组) AmapNaviType.Driver, 10 ); }2. 监听生命周期管理在 Ability 的onForeground(前台显示)时初始化监听,onBackground(后台隐藏)时移除监听,避免内存泄漏:// 应用切换到前台onForeground(): void { super.onForeground(); this.initNaviListener(); // 初始化监听}// 应用切换到后台onBackground(): void { super.onBackground(); if (this.naviInstance && this.listener) { this.naviInstance.removeAMapNaviListener(this.listener); // 移除监听 }}
-
一、关键技术难点总结1.1 问题说明在鸿蒙PC端应用开发中,实现点击按钮动态显示下拉菜单及嵌套二级菜单时,面临三大核心挑战:静态渲染性能瓶颈:传统方案一次性加载全部菜单项(含二级子项),导致内存占用峰值达80MB(实测数据)交互延迟问题:复杂菜单树(层级>3级)展开时存在明显卡顿(平均响应时间>800ms)多设备适配难题:在低配设备(如内存<4GB)上频繁出现渲染异常1.2 原因分析graph TD A[全量数据渲染] --> B[DOM节点激增] B --> C{内存占用超标} C --> D[帧率下降] D --> E[交互卡顿] A --> F[同步加载机制] F --> G[主线程阻塞]1.3 解决思路采用"渐进式加载+智能缓存"的技术方案:虚拟滚动技术:仅渲染可视区域菜单项异步数据加载:结合Promise实现菜单树懒加载手势识别优化:通过长按事件区分点击与展开操作1.4 实现方案1.4.1 动态菜单构建// 使用ArkUI声明式语法构建菜单树@Entry@Componentstruct DynamicMenu { @State menuData: MenuItem[] = []; async onButtonClick() { this.menuData = await this.loadMenuItems(); } async loadMenuItems() { // 模拟异步数据获取 return new Promise(resolve => { setTimeout(() => { resolve([ { label: '一级菜单', children: [ { label: '二级菜单-1' }, { label: '二级菜单-2' } ] } ]); }, 500); }); }} // 使用ArkUI声明式语法构建菜单树@Entry@Componentstruct DynamicMenu { @State menuData: MenuItem[] = []; async onButtonClick() { this.menuData = await this.loadMenuItems(); } async loadMenuItems() { // 模拟异步数据获取 return new Promise(resolve => { setTimeout(() => { resolve([ { label: '一级菜单', children: [ { label: '二级菜单-1' }, { label: '二级菜单-2' } ] } ]); }, 500); }); }}// 使用ArkUI声明式语法构建菜单树 @Entry @Component struct DynamicMenu { @State menuData: MenuItem[] = []; async onButtonClick() { this.menuData = await this.loadMenuItems(); } async loadMenuItems() { // 模拟异步数据获取 return new Promise(resolve => { setTimeout(() => { resolve([ { label: '一级菜单', children: [ { label: '二级菜单-1' }, { label: '二级菜单-2' } ] } ]); }, 500); }); } }1.4.2 性能优化策略// 虚拟滚动实现关键代码class VirtualMenuRenderer { private visibleRange: { start: number; end: number } = { start: 0, end: 20 }; onScroll(event: ScrollEvent) { const { scrollTop, clientHeight } = event; const totalHeight = this.getTotalHeight(); this.visibleRange.start = Math.floor(scrollTop / ITEM_HEIGHT); this.visibleRange.end = Math.min( this.visibleRange.start + Math.ceil(clientHeight / ITEM_HEIGHT), this.menuData.length ); } private getTotalHeight() { return this.menuData.length * ITEM_HEIGHT; }} // 虚拟滚动实现关键代码class VirtualMenuRenderer { private visibleRange: { start: number; end: number } = { start: 0, end: 20 }; onScroll(event: ScrollEvent) { const { scrollTop, clientHeight } = event; const totalHeight = this.getTotalHeight(); this.visibleRange.start = Math.floor(scrollTop / ITEM_HEIGHT); this.visibleRange.end = Math.min( this.visibleRange.start + Math.ceil(clientHeight / ITEM_HEIGHT), this.menuData.length ); } private getTotalHeight() { return this.menuData.length * ITEM_HEIGHT; }} // 虚拟滚动实现关键代码 class VirtualMenuRenderer { private visibleRange: { start: number; end: number } = { start: 0, end: 20 }; onScroll(event: ScrollEvent) { const { scrollTop, clientHeight } = event; const totalHeight = this.getTotalHeight(); this.visibleRange.start = Math.floor(scrollTop / ITEM_HEIGHT); this.visibleRange.end = Math.min( this.visibleRange.start + Math.ceil(clientHeight / ITEM_HEIGHT), this.menuData.length ); } private getTotalHeight() { return this.menuData.length * ITEM_HEIGHT; } }二、技术实施效果2.1 性能提升指标指标优化前优化后提升幅度内存占用峰值80MB25MB68.75%首次渲染时间1200ms350ms70.83%二级菜单展开延迟850ms180ms78.82%2.2 关键创新点分层渲染架构:通过分离菜单结构层与视图层,实现独立更新预加载机制:提前加载可视区域外1屏数据硬件加速:利用CSS transform属性开启GPU加速三、开发注意事项内存管理:及时销毁未使用的菜单实例事件防抖:对高频触发的resize事件添加50ms防抖样式隔离:使用Scoped CSS避免样式污染
上滑加载中
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签