-
开发者技术支持-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避免样式污染
-
关键技术难点总结1. 问题说明在鸿蒙视频播放类应用中,画中画功能是提升用户体验的重要特性,允许用户在离开当前页面甚至回到桌面时继续观看视频。在实际开发过程中,我们遇到了以下关键问题:(一)画中画启动时黑屏画中画窗口启动后显示黑屏,只有声音没有画面,严重影响用户体验。(二)启用自动进入画中画功能后,直接返回桌面画中画黑屏当开启自动进入画中画功能后,用户直接返回桌面时画中画窗口显示黑屏,无法正常播放视频内容。(三)如何自定义画中画内容系统默认的画中画控制界面功能有限,需要根据业务需求自定义控制按钮和交互逻辑。2. 原因分析(一)画中画启动时黑屏经过分析,主要原因包括:SurfaceID传递时机不当:画中画启动时视频播放器的SurfaceID未正确传递资源竞争冲突:横竖屏切换与画中画启动存在资源竞争初始化顺序错误:画中画控制器在视频播放器准备完成前初始化(二)启用自动进入画中画功能后,直接返回桌面画中画黑屏根本原因在于:生命周期管理不当:应用进入后台时视频资源被过早释放Surface切换失败:从全屏Surface切换到画中画Surface时数据流中断自动启动配置错误:setAutoStartEnabled配置与实际情况不匹配3. 解决思路(一)整体架构设计采用分层架构,将画中画功能独立封装:AVPlayerUtil:负责视频播放核心功能PipWindowUtil:专门处理画中画窗口管理VideoPlayerComponent:UI组件,协调各方功能(二)关键解决策略**生命周期精准控制:**确保画中画启动时所有资源就绪**SurfaceID动态管理:**根据横竖屏状态动态切换Surface**状态同步机制:**通过事件总线实现组件间状态同步**延迟初始化:**避免资源竞争导致的初始化冲突4. 解决方案(一)解决画中画启动时黑屏确保SurfaceID正确传递// 在VideoPlayerComponent.ets中 @Builder private buildVideoXComponent(targetController: XComponentController) { Column() { XComponent({ type: XComponentType.SURFACE, controller: targetController }) .onLoad(async () => { try { // 关键:获取SurfaceID并传递给AVPlayer AVPlayerUtil.surfaceID = targetController.getXComponentSurfaceId(); console.info(`[VideoPlayer] SurfaceID获取成功: ${AVPlayerUtil.surfaceID}`); // 启动视频播放 if (typeof this.videoSource === "string") { AVPlayerUtil.avPlayerLiveDemo(this.videoSource); } } catch (err) { console.error(`[VideoPlayer] XComponent初始化失败: ${JSON.stringify(err)}`); } }) .onDestroy(() => { console.log('[VideoPlayer] 视频XComponent销毁'); }) .height(this.videoContainerHeight) .width("100%") } } private initializePipMode() { if (!canIUse("SystemCapability.Window.SessionManager") || this.videoSource === '') { return; } const pipConfig: PiPWindow.PiPConfiguration = { context: this.getUIContext().getHostContext() as Context, componentController: this.portraitXComponentController, // 使用竖屏控制器 templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY, contentWidth: 1000, contentHeight: 600, controlGroups: [PiPWindow.VideoPlayControlGroup.FAST_FORWARD_BACKWARD] }; // 关键:横屏时先切回竖屏,避免资源冲突 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } // 延时初始化,减轻系统压力 const pipDelayTimer = setTimeout(() => { PipWindowUtil.startPip(pipConfig); clearTimeout(pipDelayTimer); }, 50); } 视频播放器状态管理static setAVPlayerCallback(avPlayer: media.AVPlayer) { avPlayer.on('stateChange', async (state: string) => { switch (state) { case 'prepared': console.info('AVPlayer state prepared called.'); // 确保画中画启动前视频已准备就绪 if (AVPlayerUtil.continuePlaying) { AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime) AVPlayerUtil.continuePlaying = false } avPlayer.play(); break; case 'playing': // 更新画中画控件状态 if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus( PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY ) } break; } }); } (二)自动进入画中画黑屏优化自动启动配置static initPipController() { if (!PipWindowUtil.pipController) { return; } // 谨慎使用自动启动,确保资源就绪 PipWindowUtil.pipController.setAutoStartEnabled(false); PipWindowUtil.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => { PipWindowUtil.onStateChange(state, reason); }); } 完善前后台状态管理private setupAppStateListener() { const appStateCallback: ApplicationStateChangeCallback = { onApplicationForeground() { console.log('[VideoPlayer] App进入前台'); }, onApplicationBackground() { console.log('[VideoPlayer] App进入后台'); // 关键:仅在非画中画模式时暂停 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 锁屏状态特殊处理 screenLock.isScreenLocked((err: BusinessError, isLocked: Boolean) => { if (!err && isLocked) { AVPlayerUtil.pauseAVPlayer(); } }); } }; } (三)自定义画中画内容控制面板自定义@Builder private buildVideoControlPanel() { Column() { // 顶部返回按钮 Row() { Image($r("app.media.videoBackIcon")) .onClick(() => { if (this.isLandscapeMode) { this.switchScreenOrientation(false); } this.onBack?.(); }) } // 画中画控制按钮 if (canIUse("SystemCapability.Window.SessionManager")) { Row() { if (!this.isPipModeActive && PiPWindow.isPiPEnabled()) { Image($r("app.media.videoPipIcon")) .onClick(() => this.initializePipMode()) } else { Image($r("app.media.video_pip_restoration")) .onClick(() => PipWindowUtil.stopPip()) } } } // 底部控制栏 Row({ space: 10 }) { // 播放/暂停、进度条、横竖屏切换等自定义控件 } } } 画中画控制事件处理static onActionEvent(event: PiPWindow.PiPActionEventType, status?: number) { switch (event) { case 'playbackStateChanged': if (status === 0) { AVPlayerUtil.pauseAVPlayer() } else if (status === 1) { AVPlayerUtil.playAVPlayer() } break; case 'fastForward': AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime + 5) break; case 'fastBackward': AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime - 5) break; default: break; } } (四)完整示例代码VideoPlayerComponent组件代码:// VideoPlayerComponent.ets // 独立视频播放组件(支持画中画、横竖屏适配) import { PiPWindow, window } from '@kit.ArkUI'; import { BusinessError, emitter, screenLock } from '@kit.BasicServicesKit'; import { ApplicationStateChangeCallback, common } from '@kit.AbilityKit'; import { AVPlayerUtil } from './utils/AVPlayerUtil'; import { PipWindowUtil } from './utils/PipWindowUtil'; @Component export struct VideoPlayerComponent { @Prop videoUrl:string = ''; onBack?: () => void; // 内部状态管理 // 竖屏XComponent控制器 private portraitXComponentController: XComponentController = new XComponentController(); // 横屏XComponent控制器 private landscapeXComponentController: XComponentController = new XComponentController(); // 视频播放源(从props获取) @State videoSource: ResourceStr = this.videoUrl; // 播放状态:true=播放中 @State isVideoPlaying: boolean = true; // 横竖屏模式:true=横屏 @State isLandscapeMode: boolean = false; // 视频容器高度 @State videoContainerHeight: ResourceStr | number = 211; // 视频总时长 @State totalVideoDuration: number = 0; // 当前播放时间 @State currentVideoTime: number = 0; // 控制栏显示状态 @State isVideoControlVisible: boolean = true; // 控制栏自动隐藏定时器 private videoControlHideTimer: number = 0; // 控制栏底部间距 @State videoControlBottomSpacing: number = 10; // 画中画激活状态 @State isPipModeActive: boolean = false; /** * 切换设备横竖屏模式 */ private switchScreenOrientation(targetLandscapeMode: boolean) { this.isLandscapeMode = targetLandscapeMode; const uiAbilityContext: common.UIAbilityContext = getContext(this) as common.UIAbilityContext; window.getLastWindow(uiAbilityContext).then((currentWindow) => { if (targetLandscapeMode) { // 横屏配置 this.videoContainerHeight = "100%"; this.videoControlBottomSpacing = 20; currentWindow.setPreferredOrientation(window.Orientation.LANDSCAPE); currentWindow.setWindowLayoutFullScreen(true); } else { // 竖屏配置 this.videoContainerHeight = 211; this.videoControlBottomSpacing = 10; currentWindow.setPreferredOrientation(window.Orientation.PORTRAIT); currentWindow.setWindowLayoutFullScreen(false); } }); } /** * 秒数格式化为"00:00"或"00:00:00" */ private formatSecondsToTime(seconds: number): string { let formattedTime = ""; // 处理小时 if (seconds > 3600) { const hour = ("0" + Math.floor(seconds / 3600)).slice(-2); formattedTime += `${hour}:`; } // 处理分钟 const minute = ("0" + Math.floor((seconds % 3600) / 60)).slice(-2); formattedTime += `${minute}:`; // 处理秒数 const second = ("0" + Math.floor(seconds % 60)).slice(-2); formattedTime += second; return formattedTime; } /** * 初始化画中画模式 */ private initializePipMode() { if (!canIUse("SystemCapability.Window.SessionManager") || this.videoSource === '') { return; } const pipConfig: PiPWindow.PiPConfiguration = { context: this.getUIContext().getHostContext() as Context, componentController: this.portraitXComponentController, templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY, contentWidth: 1000, contentHeight: 600, controlGroups: [PiPWindow.VideoPlayControlGroup.FAST_FORWARD_BACKWARD] }; // 横屏时先切回竖屏,避免资源冲突 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } // 延时初始化,减轻系统压力 const pipDelayTimer = setTimeout(() => { PipWindowUtil.startPip(pipConfig); clearTimeout(pipDelayTimer); }, 50); } /** * 创建控制栏自动隐藏定时器(4秒隐藏) */ private createControlHideTimer(): number { clearTimeout(this.videoControlHideTimer); return setTimeout(() => { this.isVideoControlVisible = false; clearTimeout(this.videoControlHideTimer); }, 4000); } /** * 配置App前后台/锁屏监听(后台/锁屏时暂停播放) */ private setupAppStateListener() { const appStateCallback: ApplicationStateChangeCallback = { onApplicationForeground() { console.log('[VideoPlayer] App进入前台'); }, onApplicationBackground() { console.log('[VideoPlayer] App进入后台'); // 非画中画模式暂停 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 锁屏状态监听 screenLock.isScreenLocked((err: BusinessError, isLocked: Boolean) => { if (err) { console.error(`[VideoPlayer] 获取锁屏状态失败: ${err.code}-${err.message}`); return; } if (isLocked) { AVPlayerUtil.pauseAVPlayer(); } }); } }; try { const uiAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; uiAbilityContext.getApplicationContext().on('applicationStateChange', appStateCallback); } catch (error) { const err = error as BusinessError; console.error(`[VideoPlayer] 配置状态监听失败: ${err.code}-${err.message}`); } } /** * 处理视频双击(切换播放/暂停) */ private handleVideoDoubleTap() { if (this.isVideoPlaying) { AVPlayerUtil.pauseAVPlayer(); } else { AVPlayerUtil.playAVPlayer(); } this.isVideoPlaying = !this.isVideoPlaying; } /** * 处理视频单击(切换控制栏显示) */ private handleVideoSingleTap() { this.isVideoControlVisible = !this.isVideoControlVisible; if (this.isVideoControlVisible) { this.videoControlHideTimer = this.createControlHideTimer(); } else { clearTimeout(this.videoControlHideTimer); } } /** * 构建视频XComponent渲染容器 */ @Builder private buildVideoXComponent(targetController: XComponentController) { Column() { XComponent({ type: XComponentType.SURFACE, controller: targetController }) .onLoad(async () => { try { AVPlayerUtil.surfaceID = targetController.getXComponentSurfaceId(); // 启动视频播放(仅字符串类型地址) if (typeof this.videoSource === "string") { AVPlayerUtil.avPlayerLiveDemo(this.videoSource); } } catch (err) { console.error(`[VideoPlayer] XComponent初始化失败: ${JSON.stringify(err)}`); } }) .onDestroy(() => { console.log('[VideoPlayer] 视频XComponent销毁'); }) .height(this.videoContainerHeight) .width("100%") } } /** * 构建视频控制面板(返回、画中画、播放/暂停、进度条、横竖屏) */ @Builder private buildVideoControlPanel() { Column() { // 顶部返回按钮 Row() { Image($r("app.media.videoBackIcon")) .height(35) .aspectRatio(1) .onClick(() => { // 横屏时先切回竖屏,再执行父组件回调 if (this.isLandscapeMode) { this.switchScreenOrientation(false); } this.onBack?.(); }) } .padding({ left: 10, top: this.videoControlBottomSpacing, right: 10, bottom: 5 }) .width("100%") .backgroundColor("rgba(0,0,0,0.2)") // 画中画控制按钮(系统支持时显示) if (canIUse("SystemCapability.Window.SessionManager")) { Row() { if (!this.isPipModeActive && PiPWindow.isPiPEnabled()) { // 开启画中画 Image($r("app.media.videoPipIcon")) .height(35) .aspectRatio(1) .backgroundColor("rgba(0,0,0,0.2)") .onClick(() => this.initializePipMode()) .margin({ right: this.isLandscapeMode ? 35 : 10 }) .padding(5) .borderRadius(10); } else { // 退出画中画 Image($r("app.media.video_pip_restoration")) .height(35) .aspectRatio(1) .backgroundColor("rgba(0,0,0,0.2)") .onClick(() => PipWindowUtil.stopPip()) .margin({ right: this.isLandscapeMode ? 35 : 10 }) .padding(5) .borderRadius(10); } } .width('100%') .justifyContent(FlexAlign.End); } // 底部控制栏(播放/暂停、进度条、横竖屏) Row({ space: 10 }) { // 播放/暂停按钮 Column() { Image(this.isVideoPlaying ? $r("app.media.videoPauseIcon") : $r("app.media.videoStartIcon") ) .height(35) .aspectRatio(1) .onClick(() => { this.isVideoPlaying ? AVPlayerUtil.pauseAVPlayer() : AVPlayerUtil.playAVPlayer(); this.isVideoPlaying = !this.isVideoPlaying; }); } // 进度条区域 Row() { Text(this.formatSecondsToTime(this.currentVideoTime)) .fontColor(Color.White); Slider({ value: this.currentVideoTime, min: 0, max: this.totalVideoDuration, step: 1 }) .layoutWeight(1) .trackColor(Color.Gray) .onChange((value: number, mode: SliderChangeMode) => { if (mode === SliderChangeMode.Begin || mode === SliderChangeMode.Click) { this.isVideoControlVisible = true; clearTimeout(this.videoControlHideTimer); } else if (mode === SliderChangeMode.End) { this.videoControlHideTimer = this.createControlHideTimer(); this.isVideoPlaying = true; AVPlayerUtil.playAVPlayer(); } AVPlayerUtil.changeVideoTime(value); }); Text(this.formatSecondsToTime(this.totalVideoDuration)) .fontColor(Color.White); } .layoutWeight(1); // 横竖屏切换按钮 Column() { Image(this.isLandscapeMode ? $r("app.media.videoSmallScreen") : $r("app.media.videoFullScreen") ) .height(30) .aspectRatio(1) .onClick(() => { // 竖屏切横屏前先关闭画中画 if (!this.isLandscapeMode && PipWindowUtil.pipController !== undefined) { PipWindowUtil.stopPip(); } this.switchScreenOrientation(!this.isLandscapeMode); }); } } .padding({ left: 10, top: 5, right: 10, bottom: this.videoControlBottomSpacing }) .width("100%") .backgroundColor("rgba(0,0,0,0.2)") } .width('100%') .height(this.videoContainerHeight) .justifyContent(FlexAlign.SpaceBetween); } // 组件初始化生命周期 aboutToAppear(): void { // 初始化基础配置 this.videoSource = this.videoUrl; this.setupAppStateListener(); this.switchScreenOrientation(false); // 默认竖屏 this.videoControlHideTimer = this.createControlHideTimer(); // 关闭已有画中画 if (PipWindowUtil.pipController !== undefined && canIUse("SystemCapability.Window.SessionManager")) { PipWindowUtil.stopPip(); } // 监听视频状态更新(播放/时间) emitter.on("changeVideoState", () => { this.isVideoPlaying = AVPlayerUtil.videoPlaying; this.currentVideoTime = AVPlayerUtil.videoCurrentTime; this.totalVideoDuration = AVPlayerUtil.videoDuration; }); // 监听画中画状态更新 emitter.on("changePipState", () => { this.isPipModeActive = PipWindowUtil.pipState; }); // 监听外部返回事件(如物理返回键) emitter.on("videoBackPress", () => { this.switchScreenOrientation(false); this.isLandscapeMode = false; }); } // 组件销毁生命周期 aboutToDisappear(): void { // 清理资源 clearTimeout(this.videoControlHideTimer); this.switchScreenOrientation(false); // 切回竖屏 // 非画中画模式暂停播放 if (PipWindowUtil.pipController === undefined) { AVPlayerUtil.pauseAVPlayer(); } // 取消事件监听 emitter.off("changeVideoState"); emitter.off("changePipState"); emitter.off("videoBackPress"); } // 组件UI渲染 build() { Column() { Stack({ alignContent: Alignment.BottomStart }) { // 视频渲染区域(根据横竖屏选择控制器) this.buildVideoXComponent( this.isLandscapeMode ? this.landscapeXComponentController : this.portraitXComponentController ); // 控制面板(按需显示) if (this.isVideoControlVisible) { this.buildVideoControlPanel(); } } // 手势绑定 .gesture(TapGesture({ count: 2 }).onAction(() => this.handleVideoDoubleTap())) .gesture(TapGesture({ count: 1 }).onAction(() => this.handleVideoSingleTap())) } .width('100%') .justifyContent(FlexAlign.Start); } } PipWindowUtil的代码:import { PiPWindow, router } from "@kit.ArkUI"; import { BusinessError, emitter } from "@kit.BasicServicesKit"; import { AVPlayerUtil } from "./AVPlayerUtil"; export class PipWindowUtil { // 画中画控制器 static pipController?: PiPWindow.PiPController = undefined; // 是否为还原操作 还原继续播放AVPlayer内容 static restore: boolean = false // 画中画状态 static pipState: boolean = false //画中画还原路径 static pipRestorePath: string = "pages/VideoPlayer/VideoPlayer" /** * 开启画中画事件 * @param config 传递画中画配置参数 PiPWindow.PiPConfiguration */ static startPip(config: PiPWindow.PiPConfiguration) { // 判断系统是否支持画中画能力集 if (canIUse("SystemCapability.Window.SessionManager")) { // 判断是否支持画中画 并且 画中画控制器未被使用 if (PiPWindow.isPiPEnabled() && PipWindowUtil.pipController === undefined) { //画中画配置参数 // 创建画中画控制器,通过create接口创建画中画控制器实例 let promise: Promise<PiPWindow.PiPController> = PiPWindow.create(config); promise.then((controller: PiPWindow.PiPController) => { PipWindowUtil.pipController = controller; // 初始化画中画控制器 PipWindowUtil.initPipController(); // 通过startPiP接口启动画中画 PipWindowUtil.pipController.startPiP() .then(() => { console.info(`Succeeded in starting pip.`); PipWindowUtil.pipState = true emitter.emit("changePipState") }) .catch((err: BusinessError) => { console.error(`Failed to start pip. Cause:${err.code}, message:${err.message}`); }); }).catch((err: BusinessError) => { console.error(`Failed to create pip controller. Cause:${err.code}, message:${err.message}`); }).finally(() => { //获取视频状态 改变小窗按钮状态 if (PipWindowUtil.pipController !== undefined) { if (AVPlayerUtil.videoPlaying) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY) } else { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } }); } } else { console.error(`picture in picture disabled for current OS`); return; } } /** * 注册画中画实例 */ static initPipController() { if (!PipWindowUtil.pipController) { return; } if (canIUse("SystemCapability.Window.SessionManager")) { // 通过setAutoStartEnabled接口设置是否需要在应用返回桌面时自动启动画中画,注册stateChange和controlPanelActionEvent回调 PipWindowUtil.pipController.setAutoStartEnabled(false); /*or true if necessary*/ // 默认为false //订阅画中画状态变化事件 打开 关闭 返回页面 停止 PipWindowUtil.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => { PipWindowUtil.onStateChange(state, reason); }); //订阅画中画控制面板事件 暂停/播放 快进 后退 PipWindowUtil.pipController.on('controlPanelActionEvent', (event: PiPWindow.PiPActionEventType, status?: number) => { PipWindowUtil.onActionEvent(event, status); }); } } /** * 画中画状态(生命周期)变化回调 */ static onStateChange(state: PiPWindow.PiPState, reason: string) { if (canIUse("SystemCapability.Window.SessionManager")) { let curState: string = ''; //设置变量是否是 直接关闭画中画 (RESTORE时 返回继续播放) switch (state) { //将要启动画中画 case PiPWindow.PiPState.ABOUT_TO_START: curState = "ABOUT_TO_START"; PipWindowUtil.pipRestorePath = router.getState().path + router.getState().name break; //画中画已启动 case PiPWindow.PiPState.STARTED: curState = "STARTED"; break; //将要停止画中画 case PiPWindow.PiPState.ABOUT_TO_STOP: curState = "ABOUT_TO_STOP"; if (!PipWindowUtil.restore) { AVPlayerUtil.pauseAVPlayer() } PipWindowUtil.restore = false break; //画中画已停止 case PiPWindow.PiPState.STOPPED: curState = "STOPPED"; PipWindowUtil.stopPip() break; //将要还原画中画 右上角还原按钮 //修改还原跳转任务页面 case PiPWindow.PiPState.ABOUT_TO_RESTORE: curState = "ABOUT_TO_RESTORE"; PipWindowUtil.restore = true router.pushUrl({ url: PipWindowUtil.pipRestorePath, params: { url: AVPlayerUtil.url } }, router.RouterMode.Single) break; //画中画启动时出现错误 case PiPWindow.PiPState.ERROR: curState = "ERROR"; break; default: break; } console.info('PipStateChange:' + curState + ' reason:' + reason); } } /** * 画中画控制面板按钮事件回调 */ static onActionEvent(event: PiPWindow.PiPActionEventType, status?: number) { switch (event) { case 'playbackStateChanged': // 开始或停止视频 if (status === 0) { // 停止视频 AVPlayerUtil.pauseAVPlayer() } else if (status === 1) { // 播放视频 AVPlayerUtil.playAVPlayer() } break; case 'nextVideo': // 播放上一个视频 未使用 需修改画中画控制组件 break; case 'previousVideo': // 播放下一个视频 未使用 需修改画中画控制组件 break; case 'fastForward': // 视频进度快进 AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime + 5) break; case 'fastBackward': // 视频进度后退 AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime - 5) break; default: break; } } /** * 当不再需要显示画中画时,通过stopPiP接口关闭画中画 */ static stopPip() { if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController) { let promise: Promise<void> = PipWindowUtil.pipController.stopPiP(); // 如果已注册stateChange回调,停止画中画时取消注册该回调 PipWindowUtil.pipController?.off('stateChange'); // 如果已注册controlPanelActionEvent回调,停止画中画时取消注册该回调 PipWindowUtil.pipController?.off('controlPanelActionEvent'); //执行停止画中画方法 promise.then(() => { console.info(`Succeeded in stopping pip.`); }).catch((err: BusinessError) => { console.error(`Failed to stop pip. Cause:${err.code}, message:${err.message}`); }).finally(() => { // 画中画关闭后,将画中画状态置为false Controller 置空 触发修改画中画按钮状态 PipWindowUtil.pipController = undefined PipWindowUtil.pipState = false emitter.emit("changePipState") }); } } } } AVPlayerUtil的代码:import { media } from "@kit.MediaKit"; import { BusinessError, emitter } from "@kit.BasicServicesKit"; import { PipWindowUtil } from "./PipWindowUtil"; import { PiPWindow } from "@kit.ArkUI"; //用于播放网络视频的 AVPlayer静态类 export class AVPlayerUtil { //AVPlayer实例 static avPlayer: media.AVPlayer // surfaceID用于播放画面显示,具体的值需要通过XComponent接口获取,相关文档链接见上面XComponent创建方法 static surfaceID: string = ''; // 用于区分模式是否支持seek操作 static isSeek: boolean = true; //续播变量 static continuePlaying: boolean = false // 当前播放的url static url: string = "" // 当前播放状态 static videoPlaying: boolean = false // 当前播放时间 static videoCurrentTime: number = 0 // 当前播放时长 static videoDuration: number = 0 // 是否为开启第一帧展示 static firstFrame: boolean = false // 视频原始宽度 static videoWidth: number = 0 // 视频原始高度 static videoHeight: number = 0 /** * 注册avplayer回调函数 */ static setAVPlayerCallback(avPlayer: media.AVPlayer) { // startRenderFrame首帧渲染回调函数 avPlayer.on('startRenderFrame', () => { console.info(`AVPlayer start render frame`); }) // seek操作结果回调函数 avPlayer.on('seekDone', (seekDoneTime: number) => { console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`); }) // error回调监听函数,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程 avPlayer.on('error', (err: BusinessError) => { console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`); avPlayer.reset(); // 调用reset重置资源,触发idle状态 }) //视频总时长 avPlayer.on('durationUpdate', (duration: number) => { console.info('durationUpdate called,and new duration is :' + duration) AVPlayerUtil.videoDuration = duration / 1000 emitter.emit("changeVideoState") }) //当前播放时间 avPlayer.on('timeUpdate', (time: number) => { console.info('timeUpdate called,and new time is :' + time) //如果处于续播状态第一时间不对当前播放时间进行修改 (避免进度条进行跳动) if (!AVPlayerUtil.continuePlaying) { //当播放完成后 将进度条重置为0 if (time / 1000 === AVPlayerUtil.videoDuration) { AVPlayerUtil.videoCurrentTime = 0 } else { //正常复制 更新 播放页面当前时间 AVPlayerUtil.videoCurrentTime = time / 1000 } } emitter.emit("changeVideoState") }) // 视频尺寸变化回调 avPlayer.on('videoSizeChange', (width: number, height: number) => { console.info(`Video size changed: ${width} x ${height}`); AVPlayerUtil.videoWidth = width; AVPlayerUtil.videoHeight = height; let eventData: emitter.EventData = { data: { "width": width, "height": height, } }; let options: emitter.Options = { priority: emitter.EventPriority.HIGH }; emitter.emit("videoSizeChange", options, eventData); }) //AVPlayer准备好到 播放开始之前 avPlayer.on("startRenderFrame", () => { console.log("AVPlayer", "首帧移除") }) // AVPlayer状态机变化回调函数 avPlayer.on('stateChange', async (state: string) => { switch (state) { // 注册 case 'idle': // 成功调用reset接口后触发该状态机上报 console.info('AVPlayer state idle called.'); avPlayer.release(); // 调用release接口销毁实例对象 break; // 初始化 case 'initialized': // avplayer 设置播放源后触发该状态上报 avPlayer.surfaceId = AVPlayerUtil.surfaceID; // 设置显示画面,当播放的资源为纯音频时无需设置 avPlayer.prepare(); console.info('AVPlayer state initialized called.'); break; // 准备 case 'prepared': // prepare调用成功后上报该状态机 console.info('AVPlayer state prepared called.'); if (AVPlayerUtil.avPlayer !== undefined && AVPlayerUtil.avPlayer.url !== undefined) { AVPlayerUtil.url = AVPlayerUtil.avPlayer.url } if (AVPlayerUtil.continuePlaying) { AVPlayerUtil.changeVideoTime(AVPlayerUtil.videoCurrentTime) AVPlayerUtil.continuePlaying = false } if (AVPlayerUtil.firstFrame) { let firstFrame = setTimeout(() => { avPlayer.pause() clearTimeout(firstFrame) }, 50) } avPlayer.play(); // 调用播放接口开始播放 卡帧 break; // 播放 case 'playing': // play成功调用后触发该状态机上报 console.info('AVPlayer state playing called.'); AVPlayerUtil.videoPlaying = true if (AVPlayerUtil.isSeek) { console.info('AVPlayer start to seek.'); avPlayer.seek(avPlayer.duration); //seek到视频末尾 } else { // 当播放模式不支持seek操作时继续播放到结尾 console.info('AVPlayer wait to play end.'); } // 当视频播放时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PLAY) } } break; // 暂停 case 'paused': // pause成功调用后触发该状态机上报 console.info('AVPlayer state paused called.'); AVPlayerUtil.videoPlaying = false // avPlayer.play(); // 再次播放接口开始播放 // 当视频暂停时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } break; // 完成 case 'completed': // 播放结束后触发该状态机上报 console.info('AVPlayer state completed called.'); // avPlayer.stop(); //调用播放结束接口 AVPlayerUtil.videoPlaying = false AVPlayerUtil.pauseAVPlayer() // 当视频播放结束时 更新Pip系统控件状态 if (canIUse("SystemCapability.Window.SessionManager")) { if (PipWindowUtil.pipController !== undefined) { PipWindowUtil.pipController.updatePiPControlStatus(PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE, PiPWindow.PiPControlStatus.PAUSE) } } break; // 停止 case 'stopped': // stop接口成功调用后触发该状态机上报 console.info('AVPlayer state stopped called.'); // avPlayer.reset(); // 调用reset接口初始化avplayer状态 break; // 回收 case 'released': console.info('AVPlayer state released called.'); break; default: console.info('AVPlayer state unknown called.'); break; } emitter.emit("changeVideoState") }) } /** * 通过url设置网络地址来实现播放直播码流 * */ static async avPlayerLiveDemo(url: string) { // 创建avPlayer实例对象 AVPlayerUtil.avPlayer = await media.createAVPlayer() // 创建状态机变化回调函数 AVPlayerUtil.isSeek = false; AVPlayerUtil.setAVPlayerCallback(AVPlayerUtil.avPlayer); AVPlayerUtil.avPlayer.url = url; // 播放hls网络直播码流 } /** * 播放音频/视频播放器 * 此方法用于启动AVPlayer的播放 */ static playAVPlayer() { AVPlayerUtil.avPlayer?.play() } /** * 暂停音频/视频播放器 * 此方法用于暂停当前AVPlayer的播放 */ static pauseAVPlayer() { AVPlayerUtil.avPlayer?.pause() } /** * 调整视频播放时间 * 此方法允许跳转到视频的特定时间点继续播放 * @param time 要跳转到的时间点,单位为秒如果提供的时间小于或等于0,则跳转到视频开始位置 */ static changeVideoTime(time: number) { // 将时间转换为毫秒,因为seek方法需要的时间单位是毫秒 if (time > 0) { time = time * 1000 } else { // 确保时间不会是负数或非法值 time = 0 } // 使用seek方法跳转到指定时间点,使用SEEK_CLOSEST模式找到最接近指定时间的关键帧 AVPlayerUtil.avPlayer?.seek(time, media.SeekMode.SEEK_CLOSEST) } } 5. 成果总结通过上述解决方案,我们成功实现了稳定可靠的鸿蒙视频播放画中画功能,取得了以下成果:(一)技术成果画中画启动成功率100%:通过合理的资源管理和初始化时机控制,彻底解决了黑屏问题横竖屏无缝切换:实现了横竖屏模式与画中画的完美兼容完整的生命周期管理:应用前后台切换、锁屏等场景下视频播放稳定自定义控制体验:根据业务需求定制了丰富的控制功能(二)性能优化资源占用降低:通过延迟初始化和及时资源释放,内存占用减少启动速度提升:画中画启动时间减少(三)用户体验操作流畅:控制响应及时,无卡顿现象状态一致:画中画与主界面状态实时同步交互友好:支持单击、双击等多种手势操作
-
1.1问题说明在鸿蒙应用开发中,浮动布局(如悬浮按钮)在多尺寸屏幕适配过程中出现显示问题,主要表现为:浮动元素在手机与平板设备上位置比例失调,平板上偏于角落或手机上超出屏幕范围;屏幕旋转时,浮动元素未跟随屏幕比例调整位置,导致与其他组件重叠;不同分辨率设备上,浮动元素尺寸与整体界面比例不协调,显得过大或过小;折叠屏设备在展开 / 折叠状态切换时,浮动元素位置未自动校准;这些问题导致浮动组件在跨设备场景下的用户体验一致性差,部分场景甚至影响核心功能使用。1.2原因分析通过对浮动布局适配问题的深入分析,确定核心原因如下:(一)定位方式不合理:定位方式不合理是导致浮动布局多尺寸屏幕适配问题的核心症结之一,具体表现为过度依赖基于固定像素坐标(如 px 单位的 x/y 值)的绝对定位机制,完全未考虑不同设备屏幕尺寸、分辨率及比例的天然差异,从而引发连锁性显示异常。这种定位方式忽略了鸿蒙系统 “多设备协同” 的设计理念 —— 同一应用可能运行在手机、折叠屏等多类设备上,屏幕尺寸从几英寸到十几英寸不等,固定像素坐标根本无法适配这种跨度极大的设备生态,最终导致浮动元素从 “辅助交互工具” 沦为 “视觉干扰项”,影响用户对应用的操作效率和使用体验。(二)缺乏动态计算机制:缺乏动态计算机制是浮动布局在多尺寸屏幕适配中出现系统性问题的另一核心诱因,其本质是未能建立浮动元素与屏幕实际物理属性(尺寸、分辨率、密度)之间的动态关联,导致元素的位置与大小始终处于 “静态预设” 状态,无法随设备特性自适应调整。(三)事件监听缺失:事件监听缺失是浮动布局在动态场景下适配失效的关键诱因,其核心问题在于应用未能建立与设备状态变化的 “感知 - 响应” 机制,导致浮动元素无法随屏幕环境改变做出即时调整,从而引发动态适配故障。用户在旋转屏幕或展开折叠屏时,期待界面元素能自然适配新形态,而浮动元素的 “固守原位” 会造成认知割裂 —— 如点击浮动按钮时发现其已被状态栏遮挡等问题影响操作体验。1.3解决思路(一)采用相对定位机制:采用相对定位机制是解决浮动布局多尺寸适配问题的核心技术路径,其核心逻辑是建立浮动元素与父容器(或参考组件)的动态关联,通过百分比、比例因子等相对数值定义位置关系,从而摆脱对固定像素坐标的依赖,实现跨设备的自适应布局。(二)实现动态适配计算:实现动态适配计算是通过实时获取屏幕尺寸、分辨率、像素密度及设备类型等参数,构建基于比例因子、设备系数和参考基准的尺寸与位置计算模型,动态推导浮动元素的最优宽高和坐标,并结合屏幕旋转、折叠 / 展开等事件监听,在设备状态变化时触发重新计算与实时更新,最终实现浮动元素在手机、平板、折叠屏等多设备及各类场景下的比例协调、位置合理与操作便捷,达成跨设备体验一致性。(三)建立事件响应体系:建立事件响应体系是通过注册鸿蒙系统的display.on(‘change’, () => {});、窗口尺寸监听器及折叠屏if (display.isFoldable()) { let callback: Callback<display.FoldStatus> = (data: display.FoldStatus) => {}; display.on(‘foldStatusChange’, callback);}等接口,实时捕获屏幕旋转(横竖屏切换)、窗口尺寸变化(分屏 / 多窗口调整)、折叠屏形态切换(折叠 / 展开)等设备状态事件,在事件触发后,于 UI 线程中刷新屏幕尺寸、比例、密度等参数,调用动态计算模型重新推导浮动元素的最优尺寸与位置,再通过平滑过渡动画应用调整,从而完成布局适配,解决设备状态变化导致的位置偏移、元素溢出、比例失调等问题,实现浮动布局随设备状态动态响应的无缝体验。1.4解决方案(一)相对定位机制:采用Stack容器作为布局基础,通过动态计算的margin值(marginLeft和marginTop)实现浮动元素的定位。浮动元素位置与父容器尺寸成比例,确保跨设备的相对位置一致性。代码示例:import { display } from '@kit.ArkUI'; import { FloatingStyle } from './AdaptationCalculator'; // 相对定位配置接口 export interface RelativePosition { // 相对父容器的水平比例(0-1) horizontalRatio: number; // 相对父容器的垂直比例(0-1) verticalRatio: number; // 相对参考组件的偏移量(可选) offsetX?: number; offsetY?: number; } export type DevicePositionsPlatform = Record<string, RelativePosition>; @Component export struct RelativeFloatingContainer { // 主内容区域 @BuilderParam content: () => void; // 浮动元素 @BuilderParam floatingElement: () => void; // 定位配置 @Prop defaultPosition: RelativePosition; // 设备类型差异化配置(可选) @Prop devicePositions?: DevicePositionsPlatform; @State containerSize: Size = { width: 0, height: 0 }; @State deviceType: 'phone' | 'tablet' | 'foldable' = 'phone'; @State floatingStyle: FloatingStyle = { marginLeft: 0, marginTop: 0 }; // 计算浮动元素位置(转换为margin值) private calculatePosition() { let positionConfig = this.defaultPosition; if (this.defaultPosition) { if (this.devicePositions) { let deviceConfig = this.devicePositions[this.deviceType]; if (deviceConfig) { positionConfig = deviceConfig; } } // 基于容器尺寸计算margin值实现相对定位 this.floatingStyle.marginLeft = this.containerSize.width * positionConfig.horizontalRatio + (positionConfig.offsetX || 0); this.floatingStyle.marginTop = this.containerSize.height * positionConfig.verticalRatio + (positionConfig.offsetY || 0); } } // 初始化设备类型 private initDeviceType() { let displayClass: display.Display | null = null; try { displayClass = display.getDefaultDisplaySync(); const density = displayClass.densityDPI / 160; const screenWidthVp = displayClass.width / density; this.deviceType = screenWidthVp >= 600 ? 'tablet' : (screenWidthVp < 360 ? 'foldable' : 'phone'); } catch (exception) { console.error(`Failed to get default display. Code: ${exception.code}, message: ${exception.message}`); } } aboutToAppear() { this.initDeviceType(); } build() { Stack() { // 主内容区域 Column() { this.content() } .onAreaChange((oldValue_: Area, newValue: Area) => { if (typeof newValue.width === 'number' && typeof newValue.height === 'number') { this.containerSize.width = newValue.width; this.containerSize.height = newValue.height; } this.calculatePosition(); }) // 浮动元素(使用margin实现定位) Column() { this.floatingElement() } .margin({ left: this.floatingStyle.marginLeft, top: this.floatingStyle.marginTop }) } .width('100%') .height('100%') } } 组件使用代码示例:import { RelativeFloatingContainer } from './RelativeFloatingContainer' @Entry @Component struct Index { @Builder contentBuilder() { Column() { Text("Hello World") .fontSize(30) .fontWeight(FontWeight.Bold) .margin(100) .fontColor('#333333') }.width('100%') .height('100%') } @Builder footerBuilder() { Button('+') .width(64) .height(64) .borderRadius(32) .fontSize(28) .backgroundColor('#007dff') } build() { Stack() { RelativeFloatingContainer({ defaultPosition: { verticalRatio: 0.8, horizontalRatio: 0.8, offsetX: 0, offsetY: 16 }, content: this.contentBuilder, floatingElement: this.footerBuilder }) } .height('100%') .width('100%') } } (二)动态适配计算:AdaptiveCalculator工具类实时获取屏幕参数,根据设备类型(手机 / 平板 / 折叠屏)动态调整尺寸比例和定位比例,同时计算安全边距避免系统 UI 遮挡,确保元素尺寸和位置在各类设备上均合理。代码示例:import { display } from '@kit.ArkUI'; export interface FloatingStyle { marginLeft: number, marginTop: number } /** * 设备类型枚举 */ export enum DeviceType { PHONE = 'phone', TABLET = 'tablet', FOLDABLE = 'foldable' } /** * 动态适配计算工具类 */ export class AdaptationCalculator { // 屏幕基础参数 private screenWidth: number = 0; private screenHeight: number = 0; private density: number = 1; private deviceType: DeviceType = DeviceType.PHONE; constructor() { this.updateScreenParams(); } /** * 更新屏幕参数 */ updateScreenParams(): void { let displayClass: display.Display | null = null; try { displayClass = display.getDefaultDisplaySync(); this.screenWidth = displayClass.width; this.screenHeight = displayClass.height; this.density = displayClass.densityDPI / 160; // 计算密度因子 // 判断设备类型(基于vp单位的屏幕宽度) const screenWidthVp = this.screenWidth / this.density; if (screenWidthVp >= 600) { this.deviceType = DeviceType.TABLET; } else if (screenWidthVp < 360) { this.deviceType = DeviceType.FOLDABLE; } else { this.deviceType = DeviceType.PHONE; } } catch (exception) { console.error(`Failed to get default display. Code: ${exception.code}, message: ${exception.message}`); } } /** * 计算浮动元素最优尺寸(vp) */ calculateElementSize(baseRatio: number): number { const screenWidthVp = this.screenWidth / this.density; let actualRatio = baseRatio; // 设备类型差异化调整 switch (this.deviceType) { case DeviceType.TABLET: actualRatio *= 0.9; break; case DeviceType.FOLDABLE: actualRatio *= 1.1; break; } return Math.max(Math.min(Math.round(screenWidthVp * actualRatio), 80), 40); } /** * 计算浮动元素定位的margin值(vp) */ calculatePositionMargins(containerSize: Size): FloatingStyle { const safeMargin = this.calculateSafeMargin(); let hRatio = 0.9, vRatio = 0.85; // 设备类型差异化定位比例 switch (this.deviceType) { case DeviceType.TABLET: hRatio = 0.92; vRatio = 0.8; break; case DeviceType.FOLDABLE: hRatio = 0.88; vRatio = 0.82; break; } return { marginLeft: containerSize.width * hRatio - safeMargin, marginTop: containerSize.height * vRatio - safeMargin }; } /** * 计算安全边距(避免系统UI遮挡) */ calculateSafeMargin(): number { return Math.round((this.screenHeight / this.density) * 0.05); } /** * 获取当前设备类型 */ getDeviceType(): DeviceType { return this.deviceType; } } (三)事件响应体系:通过监听屏幕变化、旋转和折叠状态事件,在设备状态改变时触发布局重新计算,更新margin值,实现无感知的布局调整,解决了位置偏移、比例失调等问题。代码示例:import { display, window } from '@kit.ArkUI'; import { FloatingStyle } from './AdaptationCalculator'; import { AdaptationCalculator, DeviceType } from './AdaptationCalculator'; const ORIENTATION: Array<string> = ['垂直', '平', '反向垂直', '反向水平']; @Entry @Component export struct Index { @State listData: Array<number> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; // 动态计算工具 private calculator: AdaptationCalculator = new AdaptationCalculator(); // 状态管理 @State floatingSize: number = 0; @State containerSize: Size = { width: 0, height: 0 }; @State rotation: number = 0; @State message: string = ORIENTATION[this.rotation]; // 是否横屏状态 @State @Watch('setWindowLandscape') isLandscape: boolean = false; @State floatingPosition: FloatingStyle = { marginLeft: 0, marginTop: 0 }; setWindowLandscape() { let context: Context = this.getUIContext().getHostContext() as Context; window.getLastWindow(context).then((windowClass) => { if (this.isLandscape) { // 设置横屏 windowClass.setPreferredOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE); } else { // 设置竖屏 windowClass.setPreferredOrientation(window.Orientation.AUTO_ROTATION_PORTRAIT); } }); } aboutToAppear() { // 初始化计算 this.calculateLayout(); // 注册事件监听器 this.registerEventListeners(); } /** * 计算布局参数 */ private calculateLayout() { // 更新屏幕参数 this.calculator.updateScreenParams(); // 计算浮动元素尺寸(基于屏幕宽度的12%) this.floatingSize = this.calculator.calculateElementSize(0.12); // 计算位置(基于容器比例) const safeMargin = this.calculator.calculateSafeMargin(); const deviceType = this.calculator.getDeviceType(); // 不同设备类型使用不同比例 let horizontalRatio = 0.9; let verticalRatio = 0.85; if (deviceType === DeviceType.TABLET) { horizontalRatio = 0.92; verticalRatio = 0.8; } else if (deviceType === DeviceType.FOLDABLE) { horizontalRatio = 0.88; verticalRatio = 0.82; } if (typeof this.containerSize.width === 'number' && typeof this.containerSize.height === 'number') { this.floatingPosition.marginLeft = this.containerSize.width * horizontalRatio - safeMargin; this.floatingPosition.marginTop = this.containerSize.height * verticalRatio - safeMargin; } } /** * 注册事件监听器 */ private registerEventListeners() { // 监听屏幕尺寸变化 display.on('change', () => { // 监听屏幕旋转(配置变化) this.rotation = display.getDefaultDisplaySync().rotation // *显示器旋转度的枚举值。 // *值0表示显示器屏幕顺时针旋转0°。 // *值1表示显示器屏幕顺时针旋转90°。 // *值2表示显示器屏幕顺时针旋转180°。 // *值3表示显示器屏幕顺时针旋转270°。 switch (this.rotation) { case 0: case 1: case 2: case 3: this.calculateLayout(); break; } }); // 监听折叠屏状态变化 if (display.isFoldable()) { let callback: Callback<display.FoldStatus> = (data: display.FoldStatus) => { this.calculateLayout(); }; display.on('foldStatusChange', callback); } } /** * 主内容区域构建 */ @Builder buildContent() { Scroll() { Column() { Text('自适应浮动布局示例') .fontSize(20) .margin(16) Button(this.isLandscape ? '点击切换竖屏' : '点击切换横屏') .fontSize(20) .margin(16) .width(180) .onClick(() => { this.isLandscape = !this.isLandscape; }) // 模拟内容列表 Column() { ForEach(this.listData, (index: number) => { Text(`内容项 ${index}`) .width('100%') .padding(16) .backgroundColor('#f0f0f0') .margin({ bottom: 8 }) }) } .padding(16) } } } build() { Stack() { // 主内容区域 Column() { this.buildContent() } .onAreaChange((oldValue_: Area, newValue: Area) => { if (typeof newValue.width === 'number' && typeof newValue.height === 'number') { this.containerSize.width = newValue.width; this.containerSize.height = newValue.height; } // 容器尺寸变化时重新计算 this.calculateLayout(); }) // 浮动按钮 Column() { Button('+') .width(this.floatingSize) .height(this.floatingSize) .borderRadius(this.floatingSize / 2) .fontSize(this.floatingSize * 0.5) .backgroundColor('#007dff') }.margin({ left: this.floatingPosition.marginLeft, top: this.floatingPosition.marginTop }) } .width('100%') .height('100%') } } 1.5方案成果总结本方案通过整合相对定位机制、动态适配计算与事件响应体系,在浮动布局多尺寸屏幕适配方面取得了显著成果,具体如下:跨设备一致性大幅提升:彻底解决了固定像素定位导致的位置失调问题,通过比例因子与父容器动态关联,使浮动元素在手机、平板、折叠屏等设备上的相对位置保持一致。动态场景响应无缝化:建立了完整的设备状态感知体系,对屏幕旋转、尺寸变化、折叠屏形态切换等场景,配合平滑过渡逻辑,实现了布局调整的 “无感知” 体验。场景覆盖全面化:方案覆盖了单窗口、分屏、多窗口、折叠 / 展开等全场景,确保浮动布局在任何运行环境下均能保持最优显示效果。综上,该方案构建了一套完整的浮动布局自适应体系,既满足了鸿蒙系统 “一次开发,多端部署” 的核心诉求,又为用户提供了跨设备一致的优质体验。
-
1、关键技术难点总结1.1 问题说明在APP开发中,需要实现一种创新的对角线滚动交互效果(梯形排列列表数据)。传统的滚动视图只支持水平或垂直单一方向滚动,而有些项目中需要实现:水平滚动的同时,卡片在垂直方向产生联动偏移创造出独特的对角线视觉层次感和交互体验保持良好的滚动性能和用户体验1.2 原因分析技术框架限制:ArkUI原生Scroll组件设计为单向滚动,scrollable属性只能设置为Horizontal或Vertical缺乏原生的对角线滚动API支持,无法通过简单配置实现复合方向的滚动效果Grid和List等布局组件的滚动机制都基于单一轴向设计,不支持多维度的联动效果现有的滚动监听事件(onWillScroll、onScrollEdge等)主要针对单向滚动优化动态计算复杂性:需要建立水平滚动偏移量与垂直位置变化的映射关系每个卡片元素的垂直偏移需要基于其索引位置和当前滚动状态实时计算滚动过程中需要保证所有元素位置变化的连续性和一致性,避免突变和闪烁性能优化挑战:滚动事件的高频触发可能导致大量的重复计算和UI更新多个卡片元素同时进行位置变换,增加了渲染引擎的负担动画效果与滚动监听的叠加可能造成主线程阻塞,影响用户体验需要在视觉效果的流畅度和系统资源消耗之间找到最佳平衡点算法设计难点:偏移算法需要考虑视觉美观性、交互自然性和计算效率的多重约束系数选择直接影响对角线的倾斜角度和视觉效果,需要经过大量测试优化2、解决思路采用"水平滚动 + 动态垂直偏移"的组合方案:利用Scroll组件的水平滚动作为主交互通过onWillScroll事件监听滚动状态基于滚动偏移量和元素索引计算垂直margin使用响应式状态管理实现实时UI更新3、解决方案步骤一:数据模型设计// CardItem.ets - 卡片数据模型 export class CardItem { id: number = 0 icon: Resource = $r('app.media.ic_default') title: string = '' desc: string = '' } 步骤二:状态管理实现// DiagonalScrollView.ets - 状态变量定义 @State isScrolling: boolean = false @State scrollOffset: number = 0 // 滚动控制器 controller: Scroller = new Scroller(); // 卡片数据初始化 private cardData: CardItem[] = [ { id: 1, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 2, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 3, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 4, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 5, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, // ... 更多数据 ] 步骤三:核心算法实现// 对角线滚动核心算法 .onWillScroll((xOffset: number, yOffset: number) => { this.scrollX = xOffset this.scrollY = yOffset this.isScrolling = true this.scrollOffset = this.controller.currentOffset().xOffset; }) .onScrollStop(() => { this.isScrolling = false }) // 动态偏移计算公式 .margin({bottom: index*60-this.scrollOffset/3}) 算法解析:index * 60:基础垂直间隔,每个卡片间隔60单位scrollOffset / 3:水平滚动联动系数,控制垂直偏移敏感度最终公式:垂直偏移 = index * 60 - scrollOffset / 3步骤四:滚动容器构建@Builder buildDiagonalScrollView() { Stack() { Scroll(this.controller) { Grid() { ForEach(this.cardData, (item: CardItem, index: number) => { GridItem() { this.buildCard(item, index) } .align(Alignment.Bottom) }, (item: CardItem) => item.id.toString()) } .rowsTemplate('1fr') .columnsGap(16) .rowsGap(16) .padding(20) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Auto) .scrollBarColor('#007DFF') .scrollBarWidth(6) .scrollSnap({snapAlign: ScrollSnapAlign.START}) .onWillScroll((xOffset: number, yOffset: number) => { this.isScrolling = true this.scrollOffset = this.controller.currentOffset().xOffset; }) .onScrollStop(() => { this.isScrolling = false }) .width('90%') .height(400) .backgroundColor(Color.White) .borderRadius(16) .transition({ type: TransitionType.All, opacity: 0, translate: { x: 0, y: 100 } }) } .width('100%') .margin({ bottom: 30 }) .alignContent(Alignment.Bottom) } 步骤五:动态卡片组件@Builder buildCard(item: CardItem, index: number) { Column() { // 图标区域 Row() { Image(item.icon) .width(40) .height(40) .objectFit(ImageFit.Contain) } .width(60) .height(60) .backgroundColor('#007DFF10') .borderRadius(12) .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) .margin({ bottom: 12 }) // 标题 Text(item.title) .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#1A1A1A') .margin({ bottom: 6 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 描述 Text(item.desc) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 调试信息显示 Text('' + (index*30-this.scrollOffset/6)) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .padding(16) .width(160) .height(180) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 8, color: '#0000000A', offsetX: 0, offsetY: 2 }) .border({ width: 1, color: '#E6E6E6' }) .onClick(() => { promptAction.openToast({ message: `点击了: ${item.title}` }) }) .scale({x: this.isScrolling ? 0.98 : 1}) // 滚动时轻微缩放效果 .transition({ type: TransitionType.All}) .margin({bottom: index*60-this.scrollOffset/3}) // 核心:动态垂直偏移 } 步骤六:完整页面结构// Index.ets - 完整页面实现 import { CardItem } from './CardItem' import { promptAction } from '@kit.ArkUI' @Entry @Component struct Index { // 状态变量 @State scrollX: number = 0 @State scrollY: number = 0 @State isScrolling: boolean = false @State scrollOffset: number = 0 context: DrawingRenderingContext = new DrawingRenderingContext(); controller: Scroller = new Scroller(); // 卡片数据 private cardData: CardItem[] = [ { id: 1, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 2, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 3, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 4, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 5, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, { id: 6, icon: $r('app.media.ic_default'), title: '云服务', desc: '全场景云端支持' }, { id: 7, icon: $r('app.media.ic_default'), title: '美学设计', desc: '简洁流畅视觉语言' }, { id: 8, icon: $r('app.media.ic_default'), title: '开放能力', desc: '全面开发者支持' }, { id: 9, icon: $r('app.media.ic_default'), title: '多设备', desc: '手机、平板、智慧屏' }, { id: 10, icon: $r('app.media.ic_default'), title: '智能连接', desc: '自动发现和连接' }, { id: 11, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 12, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 13, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 14, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 15, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, { id: 16, icon: $r('app.media.ic_default'), title: '云服务', desc: '全场景云端支持' }, { id: 17, icon: $r('app.media.ic_default'), title: '美学设计', desc: '简洁流畅视觉语言' }, { id: 18, icon: $r('app.media.ic_default'), title: '开放能力', desc: '全面开发者支持' }, { id: 19, icon: $r('app.media.ic_default'), title: '多设备', desc: '手机、平板、智慧屏' }, { id: 20, icon: $r('app.media.ic_default'), title: '智能连接', desc: '自动发现和连接' }, { id: 21, icon: $r('app.media.ic_distributed'), title: '分布式技术', desc: '跨终端无缝协同体验' }, { id: 22, icon: $r('app.media.ic_default'), title: '安全可靠', desc: '微内核架构提升安全性' }, { id: 23, icon: $r('app.media.ic_default'), title: '高性能', desc: '确定时延引擎提升响应' }, { id: 24, icon: $r('app.media.ic_default'), title: '统一生态', desc: '一次开发,多端部署' }, { id: 25, icon: $r('app.media.ic_default'), title: 'AI赋能', desc: '分布式AI智慧体验' }, { id: 26, icon: $r('app.media.ic_default'), title: '云服务', desc: '全场景云端支持' }, { id: 27, icon: $r('app.media.ic_default'), title: '美学设计', desc: '简洁流畅视觉语言' }, { id: 28, icon: $r('app.media.ic_default'), title: '开放能力', desc: '全面开发者支持' }, { id: 29, icon: $r('app.media.ic_default'), title: '多设备', desc: '手机、平板、智慧屏' }, { id: 30, icon: $r('app.media.ic_default'), title: '智能连接', desc: '自动发现和连接' } ] build() { Column() { // 标题区域 this.buildHeader() // 对角线滚动容器 this.buildDiagonalScrollView() } .width('100%') .height('100%') .backgroundColor('#F7F8FA') } // 其他Builder方法... } 4、方案成果总结创新算法实现:核心偏移算法:垂直偏移 = index * 60 - scrollOffset / 3动态响应式计算,实时更新UI基于onWillScroll事件的高效滚动监听机制性能优化策略:使用@State响应式状态管理滚动状态判断机制,优化动画计算性能合理的系数设计,平衡视觉效果与性能用户体验增强:滚动时卡片缩放效果平滑的过渡动画,创造独特的视觉效果和交互体验
-
1.问题说明:鸿蒙原生通讯录访问,需要搭建联系人页面2.原因分析:需要联系人访问权限(个人级别),需要在应用市场(AppGallery Connect)上申请3.解决思路:通讯录权限:应用市场(AppGallery Connect)上申请权限联系人页面:联系人数据需要分组(A-Z-#),每个联系人有多个联系方式,需要遍历全部取出4.解决方案:一、代码权限:项目中entry\src\main\module.json5中"requestPermissions": [ { "name": "ohos.permission.READ_CONTACTS", "reason": "$string:permission_reason_read_contact", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }],项目中entry\src\main\resources\base\element\string.json中{ "string": [ { "name": "permission_reason_read_contact", "value": "获取本地联系人列表" } ]}二、联系人页面import { BookInfo, BookInfoList } from "../models/BookInfoList"import { TelephoneViewModel } from "../viewmodels/TelephoneViewModel"@ComponentV2export struct TelephonePage { @Local viewModel: TelephoneViewModel = new TelephoneViewModel() aboutToAppear(): void { this.viewModel.initData() } build() { Stack() { List({ scroller: this.viewModel.scroller }) { LazyForEach(this.viewModel.telephoneDataSource, (groupItem: BookInfoList, groupIndex: number) => { ListItemGroup({ header: this.groupBuilder(groupItem) }) { ForEach(groupItem.infos, (rowItem: BookInfo, rowIndex: number) => { this.rowBuilder(rowItem) }) } }) } .scrollBar(BarState.Off) .sticky(StickyStyle.Header) .width('100%') .layoutWeight(1) .onScrollIndex((start: number, end: number, center: number) => { this.viewModel.tabIndex = start }) /* 可以与容器组件联动用于按逻辑结构快速定位容器显示区域的组件,arrayValue为字母索引字符串数组,selected为初始选中项索引值。 * 1. 当用户滑动List组件,list组件onScrollIndex监听到firstIndex的改变,绑定赋值给AlphabetIndexer的selected属性,从而定位到字母索引。 * 2. 当点击AlphabetIndexer的字母索引时,通过scrollToIndex触发list组件滑动并指定firstIndex,从而实现List列表与AlphabetIndexer组件 * 首字母联动吸顶展示。 */ AlphabetIndexer({ arrayValue: this.viewModel.indexers, selected: this.viewModel.tabIndex }) .color('#6E6D72') .font({ size: 10, weight: FontWeight.Medium }) .selectedColor('#6E6D72') // 选中项文本颜色 .selectedFont({ size: 10, weight: FontWeight.Medium }) // 选中项字体样式 .usingPopup(false) // 是否显示弹出框 .itemSize(16) // 每一项的尺寸大小 .width(16) .height('80%') .margin({ right: 0 }) .onSelect((tabIndex: number) => { this.viewModel.scrollToIndex(tabIndex) }) } .alignContent(Alignment.End) .width('100%') .height('100%') } @Builder rowBuilder(rowItem: BookInfo) { Row() { Text(rowItem.name) .textAlign(TextAlign.Start) .fontSize(15) .fontColor('#1A1A1A') .margin({ left: 16 }) Text(rowItem.phone) .textAlign(TextAlign.Start) .fontSize(15) .fontColor('#666666') .margin({ left: 12 }) Blank() Text('选择') .textAlign(TextAlign.Center) .fontSize(12) .fontColor('#5D55FF') .borderColor('#5D55FF') .backgroundColor('#FFFFFF') .borderWidth(1) .borderRadius(4) .width(40) .height(24) .margin({ right: 16 }) .onClick(() => { }) } .justifyContent(FlexAlign.Start) .alignItems(VerticalAlign.Center) .width('100%') .constraintSize({ minHeight: 52, }) } @Builder groupBuilder(groupItem: BookInfoList) { Row() { Text(groupItem.group) .textAlign(TextAlign.Start) .fontSize('#666666') .margin({ left: 16 }) } .justifyContent(FlexAlign.Start) .alignItems(VerticalAlign.Center) .width('100%') .height(30) .backgroundColor('#F7F7F7') }}三、ViewModel处理数据、联系人Modelimport { CustomDataSource } from "shkit"import { TelephoneUtil } from "../utils/TelephoneUtil"import { BookInfoList } from "../models/BookInfoList"@ObservedV2export class TelephoneViewModel { scroller: Scroller = new Scroller() // 通讯录数据源 @Trace telephoneDataSource: CustomDataSource<BookInfoList> = new CustomDataSource<BookInfoList>() // 列表右边索引 @Trace indexers: string[] = [] @Trace tabIndex: number = 0 initData() { this.loadData() } releaseData(): void { } // 加载联系人数据 async loadData() { this.telephoneDataSource.pushArray(await TelephoneUtil.queryContacts()) this.indexers.splice(0, this.indexers.length, ...TelephoneUtil.getTndexers()) } scrollToIndex(tabIndex: number) { this.scroller.scrollToIndex(tabIndex) }} @ObservedV2export class BookInfoList { group: string = '' infos: BookInfo[] = []}@ObservedV2export class BookInfo { name: string = '' phone: string = ''}四、获取联系人数据import { BookInfo, BookInfoList } from "../models/BookInfoList";import { Permissions } from "@kit.AbilityKit";import { AppUtil, FormatUtil, PermissionUtil, StrUtil } from "@pura/harmony-utils";import { contact } from "@kit.ContactsKit";export class TelephoneUtil { static indexers: string[] = [] // 获取右边索引集合 static getTndexers(): string[] { return TelephoneUtil.indexers } static async queryContacts(): Promise<BookInfoList[]> { let groups: BookInfoList[] = [] // 允许应用读取联系人数据,权限 let p: Permissions = 'ohos.permission.READ_CONTACTS'; let grantStatus: boolean = await PermissionUtil.checkPermissions(p) // 校验当前是否已经授权 if (!grantStatus) { // 未授权 grantStatus = await PermissionUtil.requestPermissionsEasy(p) // 申请授权 } if (!grantStatus) { // 未授权 return groups } let datas: contact.Contact[] = await contact.queryContacts(AppUtil.getContext()) // 移除索引 TelephoneUtil.indexers.splice(0, TelephoneUtil.indexers.length) if (datas.length > 0) { datas.forEach((data: contact.Contact) => { // 联系人全名 let fullName: string = data.name?.fullName ?? "" // 联系人电话列表 let phoneNumbers: contact.PhoneNumber[] = data.phoneNumbers ?? [] let firstStr: string = "#" if (StrUtil.isNotEmpty(fullName)) { firstStr = FormatUtil.transliterator(fullName).charAt(0).toUpperCase() } // 添加索引 TelephoneUtil.indexers.push(firstStr) // 判断是否已经创建了联系人组 let hasGroup: boolean = false if (groups.length > 0) { groups.forEach((groupModel: BookInfoList) => { if (groupModel.group == firstStr) { hasGroup = true // 添加组的联系人 TelephoneUtil.createContacts(groupModel, fullName, phoneNumbers) return } }) } // 没有联系人组就创建 if (!hasGroup) { let groupModel: BookInfoList = { group: firstStr, infos: [] } // 添加组的联系人 TelephoneUtil.createContacts(groupModel, fullName, phoneNumbers) groups.push(groupModel) } }) } return groups } // 添加组的联系人 private static createContacts(groupModel: BookInfoList, fullName: string, phoneNumbers: contact.PhoneNumber[]) { phoneNumbers.forEach((phoneItem: contact.PhoneNumber) => { let phoneNumber: string = phoneItem.phoneNumber let rowModel: BookInfo = { name: fullName, phone: phoneNumber } groupModel.infos.push(rowModel) }) }}五、目前基本可以实现访问联系人页面、数据,后续有修改再做跟进
-
1、关键技术难点总结1.1 问题说明一、单文件下载方式效率低在文件管理场景中,用户经常需要同时下载多个大文件(如软件安装包、系统镜像、数据备份等),传统的单文件下载方式效率低下。在HarmonyOS平台上实现多文件的并发下载管理,既要保证下载效率,又要避免因过多并发任务导致的系统资源耗尽。特别是在移动设备上,需要精确控制同时进行的下载任务数量,确保系统稳定性和用户体验。二、复杂状态管理与UI同步难题下载器需要管理多种不同的任务状态(如初始化、等待、下载中、已暂停、已完成、失败、已取消),每种状态的转换都需要触发相应的UI更新和用户反馈。在实际应用中,用户可能会频繁进行暂停、恢复、取消等操作,这要求系统能够实时响应用户操作并准确反映当前状态。传统的手动UI更新方式容易导致界面与实际状态不同步,影响用户体验和操作准确性。1.2 原因分析一、缺少多任务管理控制在多文件下载场景中,用户可能需要同时处理数十个大文件,但HarmonyOS的download API在设计上需要开发者精确管理任务队列。由于移动设备的CPU、内存和网络带宽资源有限,无限制的并发下载会导致系统资源竞争加剧,出现下载速度下降、应用卡顿甚至崩溃的问题。因此必须实现智能的队列管理机制,通过FIFO队列调度和最大并发数控制,既保证下载效率又维护系统稳定性。二、状态管理与UI实时同步在用户频繁操作的下载场景中,多种状态之间的转换极其复杂,每次状态变化都需要触发UI更新。传统的手动UI刷新方式存在时序问题:当用户快速点击暂停/恢复按钮时,下载任务的实际状态可能已经改变,但UI显示仍停留在上一个状态,导致用户误操作。这种状态不一致问题在多任务并发执行时更加突出,需要采用响应式编程模式和数据绑定机制来确保UI与业务逻辑的实时同步。2、解决思路采用队列管理模式 - 设计任务队列,实现FIFO队列管理和并发控制使用观察者模式 - 通过@Observed和@ObjectLink实现状态自动同步权限预申请策略 - 在应用启动时预先申请所需网络权限进度回调机制 - 利用HarmonyOS的progress事件实现实时进度更新3、解决方案3.1 核心下载任务类设计@Observed class DownloadTask { taskId: string | null = null; // 任务唯一ID url: string | null = null; // 下载地址 fileName: string | null = null; // 文件名 filePath: string | null = null; // 文件存储路径 status: DownloadStatus = DownloadStatus.INIT; // 下载状态 progress: number = 0; // 下载进度(0-100) totalSize: number = 0; // 文件总大小(字节) downloadedSize: number = 0; // 已下载大小(字节) msg?: string = ''; // 消息 isRunning?: boolean = false // 是否正在下载 } 关键技术点:使用@Observed装饰器实现数据响应式更新完整的任务状态属性定义支持进度追踪和文件信息管理3.2 下载状态枚举定义enum DownloadStatus { INIT = 'init', // 未开始 PENDING = 'pending', // 等待中 DOWNLOADING = 'downloading', // 下载中 PAUSED = 'paused', // 已暂停 COMPLETED = 'completed', // 已完成 FAILED = 'failed', // 失败 CANCELLED = 'cancelled' // 已取消 } 关键技术点:定义了7种下载状态的完整枚举确保状态转换的准确性和一致性为UI组件提供清晰的状态判断依据3.3 并发队列管理机制// 任务队列 let taskQueue: Array<DownloadTask> = []; // 最多可以同时下载几个文件 let max = 2 function addTask(downloadTask: DownloadTask, context: common.Context) { if (!downloadTask) { return } // 加入下载队列 let length = taskQueue.push(downloadTask) if (length <= max ) { startDownload(downloadTask, context) } else { downloadTask.status = DownloadStatus.PENDING } } function startDownload(downloadTask: DownloadTask, context: common.Context) { if (!downloadTask) { return } request.agent.getTask(context, downloadTask.taskId).then((task: request.agent.Task) => { if (downloadTask.isRunning) { // 恢复下载 task.resume().then(() => { promptAction.showToast({ message: "继续下载" }) }).catch((err: BusinessError) => { promptAction.showToast({ message: "继续下载失败" }) }) } else { // 开始下载 downloadTask.isRunning = true task.start().then(() => { promptAction.showToast({ message: "开始下载" }) }).catch((err: BusinessError) => { promptAction.showToast({ message: "下载失败" }) }) } }) downloadTask.status = DownloadStatus.DOWNLOADING } 关键技术点:队列管理模式实现并发下载控制最大并发数默认为2个,超出部分自动进入等待队列支持任务的暂停和恢复操作完善的异常处理和用户提示3.4 下载任务创建和监听// 添加示例下载任务 async addTestTasks() { const downloadInfoList: Array<DownloadInfo> = [ { name: "license.txt", url: "http://example/files/xxx.txt", size: "2 KB" }, { name: "gitlab-jh-17.9.7-jh.0.el8.x86_64.rpm", url: "http://example/files/xxx.exe", size: "1474.56 MB" } ]; for (let i = 0; i < downloadInfoList.length; i++) { let downloadTask: DownloadTask = new DownloadTask(); let filePath = this.context.cacheDir + "/" + "downloads" + i; // 创建下载任务 let task = await request.agent.create(this.context, { action: request.agent.Action.DOWNLOAD, url: downloadInfoList[i].url, overwrite: true, // 支持复写 gauge: true, // 通知 priority: i, // 优先级 saveas: filePath, // 保存文件目录 }); // 监听下载进度 task.on("progress", (progress: request.agent.Progress) => { downloadTask.msg = progress.processed + "/" + progress.sizes[0]; downloadTask.downloadedSize = progress.processed; downloadTask.totalSize = progress.sizes[0]; }); // 监听下载完成 task.on("completed", () => { promptAction.showToast({ message: "下载完成" }) downloadTask.status = DownloadStatus.COMPLETED // 唤醒下一个任务 startDownload(taskQueue[max], this.context) // 移除当前文件 taskQueue.splice(0, 1) }); downloadTask.taskId = task.tid; downloadTask.msg = downloadInfoList[i].size; downloadTask.fileName = downloadInfoList[i].name; downloadTask.filePath = `${filePath}/${downloadTask.fileName}`; downloadTask.url = downloadInfoList[i].url; downloadTask.isRunning = false this.taskList.push(downloadTask) } } 关键技术点:使用HarmonyOS的request.agent API创建下载任务通过progress事件监听实现实时进度更新通过completed事件处理下载完成后的队列调度支持文件覆盖、优先级设置和通知显示3.5 任务控制逻辑function taskClick(task: DownloadTask, context: common.Context) { switch (task.status) { case DownloadStatus.INIT: addTask(task, context); break; case DownloadStatus.DOWNLOADING: // 暂停下载 request.agent.getTask(context, task.taskId).then((task: request.agent.Task) => { task.pause().then(() => { promptAction.showToast({ message: "暂停下载" }) }).catch((err: BusinessError) => { promptAction.showToast({ message: "暂停失败" }) }) }); // 修改状态为已暂停 task.status = DownloadStatus.PAUSED; // 唤醒新的任务 startDownload(taskQueue[max], context); // 从下载队列中移除 delQueueItem(task); break; case DownloadStatus.PENDING: delQueueItem(task); // 状态修改为暂停下载 task.status = DownloadStatus.PAUSED; break; case DownloadStatus.PAUSED: // 开始下载 addTask(task, context); break; } } function delQueueItem(task: DownloadTask) { let index = findQueueIndex(task) taskQueue.splice(index, 1) } function findQueueIndex(task: DownloadTask) { for (let i = 0; i < taskQueue.length; i++) { if (taskQueue[i].taskId === task.taskId) { return i } } return -1 } 关键技术点:基于状态机模式的任务控制逻辑支持下载、暂停、恢复等操作的无缝切换智能的队列调度,暂停任务后自动启动等待中的任务完善的队列操作函数,支持任务的查找和删除3.6 UI组件设计@Component struct TaskItem { @ObjectLink downloadTask: DownloadTask; private context: common.Context = this.getUIContext().getHostContext() as Context; build() { Column() { // 文件名和状态显示 Row() { Text(this.downloadTask.fileName).padding({left: 5}) Text(this.downloadTask.status).padding({left: 5}) } .width("100%") .height(50) // 进度信息显示 Row() { Text('进度:' + this.downloadTask.msg).margin({right: 20}) Text(`${(this.downloadTask.totalSize == 0 ? 0 : ((this.downloadTask.downloadedSize / this.downloadTask.totalSize) * 100).toFixed(2))}%`) .margin({right: 10}) } .width("100%") .height(50) // 进度条和控制按钮 Row() { Progress({ value: this.downloadTask.downloadedSize, total: this.downloadTask.totalSize, type: ProgressType.Capsule }).width('50%').height(40).margin({right: 10}) Button(this.downloadTask.status === DownloadStatus.COMPLETED ? '已完成' : this.downloadTask.status === DownloadStatus.DOWNLOADING ? "停止" : "下载") .onClick(() => { taskClick(this.downloadTask, this.context) }) .enabled(this.downloadTask.filePath != "" && this.downloadTask.status != DownloadStatus.COMPLETED) .width(100) .fontSize(14) .height(40) } .width("100%") .height(50) } .width("100%") } } 关键技术点:使用@ObjectLink实现下载状态与UI组件的自动同步实时显示下载进度百分比和进度条动态按钮文本,根据下载状态显示不同操作智能按钮启用状态控制,已完成任务不可操作4、方案成果总结智能队列管理 - 支持最大并发数控制,超出部分自动进入等待队列完善的状态管理 - 通过枚举定义了多种下载状态,确保状态转换的准确性实时进度显示 - 通过progress事件监听实现下载进度的实时更新和UI刷新用户交互优化 - 支持下载、暂停、恢复、取消等操作,提供良好的用户体验响应式UI设计 - 使用@Observed和@ObjectLink实现数据驱动的UI更新
-
一、 关键技术难点总结1.问题说明在鸿蒙应用开发中,文件预览是一个常见的功能需求。开发者在实现文件预览功能时面临以下主要问题:API使用复杂:需要同时处理文件信息(PreviewInfo)和窗口配置(DisplayInfo)路径格式不统一:需要处理普通路径与URI格式的转换上下文依赖:预览功能强依赖正确的Context传递错误处理繁琐:需要处理文件不存在、格式不支持等多种异常情况配置管理困难:窗口位置、大小等参数需要合理设置2.原因分析API设计粒度较细:需要分别配置文件属性和窗口属性路径处理复杂:系统要求使用URI格式,但应用内文件通常使用普通路径生命周期管理:预览窗口状态需要手动管理配置参数众多:x、y、width、height等参数都需要合理设置3.解决思路针对上述问题,采用以下解决策略:封装统一接口:将复杂的API调用封装成简单的工具类方法,降低使用门槛。路径自动转换:实现智能路径识别和转换机制,支持多种路径格式。配置合并管理:提供默认配置并支持自定义覆盖,简化配置过程。状态统一管理:封装预览窗口的状态检查和生命周期管理。4.解决方案(一)配置管理/\*\* \* 合并预览配置 - 智能填充默认值 \*/ private mergePreviewConfig(config?: PreviewConfig): PreviewConfig { const defaultConfig: PreviewConfig = { title: \'文件预览\', x: 40, y: 100, width: 300, height: 500 }; if (!config) { return defaultConfig; } // 手动合并配置,确保类型安全 const mergedConfig: PreviewConfig = { title: config.title !== undefined ? config.title : defaultConfig.title, x: config.x !== undefined ? config.x : defaultConfig.x, y: config.y !== undefined ? config.y : defaultConfig.y, width: config.width !== undefined ? config.width : defaultConfig.width, height: config.height !== undefined ? config.height : defaultConfig.height }; return mergedConfig; } (二)路径智能处理/\*\* \* 智能路径处理 - 支持多种路径格式 \*/ private processFilePath(filePath: string): string { // 判断是否是URI格式 let uri: string; let isUri = filePath.startsWith(\'file://\') \|\| filePath.startsWith(\'content://\'); if (isUri) { uri = filePath; // 已经是URI,直接使用 } else { // 检查文件是否存在(仅对普通路径检查) try { const isExist = fs.accessSync(filePath); if (!isExist) { throw new Error(\`文件不存在: \${filePath}\`); } } catch (fsError) { throw new Error(\`文件访问失败: \${filePath}\`); } uri = fileUri.getUriFromPath(filePath); // 普通路径,转换为URI } return uri; } (三)预览核心方法/\*\* \* 打开文件预览 - 统一入口方法 \*/ async openPreview(filePath: string, mimeType: string, config?: PreviewConfig): Promise\<void\> { if (!this.context) { throw new Error(\'Context未设置,请先调用setContext方法\'); } try { // 1. 路径处理 const uri = this.processFilePath(filePath); // 2. 配置合并 const previewConfig = this.mergePreviewConfig(config); // 3. 构建预览参数 const previewFile: filePreview.PreviewInfo = { title: previewConfig.title!, uri: uri, mimeType: mimeType }; const previewWindowConfig: filePreview.DisplayInfo = { x: previewConfig.x!, y: previewConfig.y!, width: previewConfig.width!, height: previewConfig.height! }; // 4. 检查支持性 const isCanPreview = await this.canPreview(filePath); if (!isCanPreview) { throw new Error(\'文件不支持预览(格式不支持或文件不存在)\'); } // 5. 打开预览 await filePreview.openPreview(this.context, previewFile, previewWindowConfig); } catch (error) { const businessError = error as BusinessError; console.error(\`FilePreviewUtil openPreview error: \${JSON.stringify(businessError)}\`); throw new Error(\`预览失败:\${businessError.message \|\| businessError.code \|\| \'未知错误\'}\`); } } (四)演示页面集成/\*\* \* 演示页面 - 简化使用示例 \*/ \@Entry \@Component struct FilePreviewDemo { \@State previewStatus: string = \'点击下方按钮预览文件\'; private previewUtil: FilePreviewUtil = filePreviewUtil; // 初始化上下文 aboutToAppear(): void { const uiContext: UIContext = this.getUIContext(); const context = uiContext.getHostContext() as Context; this.previewUtil.setContext(context); } // 简化后的预览调用 async openFilePreview() { try { const testFilePath = await this.previewUtil.createTestFile(\'demo.txt\', \'测试内容\'); const previewConfig: PreviewConfig = { title: \'演示文档\', x: 50, y: 150, width: 350, height: 550 }; await this.previewUtil.openPreview(testFilePath, \'text/plain\', previewConfig); this.previewStatus = \'预览成功!\'; } catch (error) { this.previewStatus = error.message; } } } (五)功能时序图5.成果总结通过封装文件预览工具类,取得了以下成果:(一)使用简化代码量减少:从复杂的多参数调用简化为单一方法调用配置智能化:自动填充默认值,支持部分自定义错误统一处理:统一的异常捕获和用户友好提示(二)功能完善多格式支持:支持文本、图片等多种文件格式路径自适应:自动识别和处理不同路径格式状态管理:完整的预览窗口生命周期管理(三)健壮性提升上下文安全:强制上下文检查,避免空指针异常文件验证:自动检查文件存在性和支持性(四)开发效率快速集成预览功能提供一致的用户体验降低维护成本
-
1. 问题说明(一)模态页切换无过渡,体验生硬点击 “其他登录方式” 切换页面时,无动画过渡,页面跳转突兀,用户感知割裂,不符合流畅交互预期,影响使用体验。(二)多登录方式返回键逻辑复杂一键登录与其他登录方式需分别实现返回功能,未统一管理,易出现返回逻辑冲突(如误关闭模态页),增加开发调试成本。(三)协议未勾选仍可触发登录未校验 “服务协议” 勾选状态,用户未同意协议时点击 “一键登录”,无提示直接执行登录逻辑,不符合合规要求与交互逻辑。(四)手机号未达标,按钮状态不变手机号输入框未输入 11 位数字时,“发送验证码” 按钮仍为灰色不可用状态,但无明确反馈,用户不知需输入完整手机号。2. 原因分析(一)过渡效果缺失未为登录方式切换的组件设置transition属性,组件显隐时无系统动画支持,导致切换过程生硬,缺乏视觉连贯性。(二)返回键未统一布局未使用Stack等容器组件统一包裹不同登录页面,返回键需在每个页面单独实现,无法复用逻辑,易出现跳转逻辑混乱。(三)协议校验逻辑缺失登录按钮的onClick事件未添加 “协议勾选状态” 判断条件,未区分isAgree为false的场景,直接执行登录操作,无异常提示。(四)输入监听未绑定未为手机号输入框添加内容长度监听,未将输入长度与按钮enabled状态关联,无法动态激活按钮,缺乏用户引导。3. 解决思路(一)模态页绑定与过渡优化用bindContentCover绑定全屏模态页,通过transition设置滑入滑出效果,让模态页显隐与登录方式切换更流畅。(二)统一返回键布局用Stack组件包裹所有登录页面,将返回键置于顶层,复用返回逻辑,避免重复实现,解决跳转冲突问题。(三)协议勾选校验在登录按钮点击事件中,先判断协议勾选状态,未勾选时提示用户,勾选后再执行登录逻辑,确保合规与交互正确。(四)输入框与按钮联动监听手机号输入框内容变化,当输入长度达 11 位时,动态激活 “发送验证码” 按钮,同时提示用户,提升操作引导性。4. 解决方案(一)模态页绑定与显隐控制通过bindContentCover绑定模态页,控制显隐状态,实现从下方滑出的全屏效果: import { LoginModel } from './LoginModel'; @Builder export function PageThreeBuilder() { ModalWindowComponent() } @Component export struct ModalWindowComponent { // 是否显示全屏模态页面 @State isPresent: boolean = false; pathStack: NavPathStack = new NavPathStack(); @Builder loginBuilder() { Column() { // 通过@State和@Link使isPresentInLoginView和isPresent产生关联 LoginModel({ isPresentInLoginView: this.isPresent }) } } build() { NavDestination() { Column() { // TODO:需求:增加其他登录方式,如半模态窗口 Button('点击跳转到全屏登录页') .fontColor(Color.White) .borderRadius(8) .type(ButtonType.Normal) .backgroundColor('#222222') .width('100%') .bindContentCover($$this.isPresent, this.loginBuilder) .onClick(() => { this.isPresent = true; // 当isPresent为true时显示模态页面,反之不显示 }) } .size({ width:'100%', height: '100%' }) .padding(12) .justifyContent(FlexAlign.Center) } .title('Login_Model') .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack }) } } (二)登录方式切换(带过渡效果)用if-else条件渲染登录方式,通过transition添加滑入过渡:import promptAction from '@ohos.promptAction'; import { OtherWaysToLogin, ReadAgreement } from './OtherWaysToLogin'; const EFFECT_DURATION = 800; const EFFECT_OPACITY = 0.4; const SPACE_TEN = 10; @Component export struct LoginModel { @Link isPresentInLoginView: boolean; // 是否是默认一键登录方式 @State isDefaultLogin: boolean = true; // 用户名 userName: string = '18888888888'; // 判断是否同意协议 isConfirmed: boolean = false; private effect: TransitionEffect = TransitionEffect.OPACITY .animation({ duration: EFFECT_DURATION }) .combine(TransitionEffect.opacity(EFFECT_OPACITY)) // 默认一键登录方式 @Builder DefaultLoginPage() { Column({ space: SPACE_TEN }) { Row({ space: SPACE_TEN }) { Image('') .width(40) .height(40) Column({ space: SPACE_TEN }) { Text('Hi, 欢迎回来') .fontWeight(FontWeight.Bold) .fontSize(20) .fontColor(Color.Black) Text('登录后更精彩,美好生活即将开始') .fontColor('#333333') } .alignItems(HorizontalAlign.Start) } .alignItems(VerticalAlign.Center) .width('100%') Text(this.userName) .fontColor('#333333') .fontWeight(FontWeight.Bold) .padding({ left: 12 }) .height(40) .width('100%') .borderRadius(8) .backgroundColor('#eeeeeee') Text('认证服务由xxxx提供') .fontColor('#666666') .width('100%') .textAlign(TextAlign.Start) Row() { Checkbox({ name: 'checkbox1' }) .id('default_agreement') .select(this.isConfirmed) .onChange((value: boolean) => { this.isConfirmed = value }) ReadAgreement() } .width('100%') .alignItems(VerticalAlign.Center) Button('手机号码一键登录') .fontColor(Color.White) .borderRadius(8) .type(ButtonType.Normal) .backgroundColor('#222222') .onClick(() => { if (this.isConfirmed) { // 调用Toast显示登录成功提示 promptAction.showToast({ message: '登录成功' }); } else { // 调用Toast显示请先阅读并同意协议提示 promptAction.showToast({ message: '请先阅读并同意协议' }); } }) .width('100%') .height(50) Row() { Text('其他登录方式') .fontColor('#777777') .backgroundColor('#007777') .onClick(() => { this.isDefaultLogin = false; }) // 在容器主轴方向上自动填充容器空余部分 Blank() Text('遇到问题') .fontColor('#777777') .backgroundColor('#007777') .onClick(() => { // 调用Toast显示遇到问题提示 promptAction.showToast({ message:'遇到问题' }); }) } .width('100%') } .width('100%') .height('100%') .backgroundColor(Color.White) .justifyContent(FlexAlign.Center) } build() { Stack({ alignContent: Alignment.TopStart }) { // 登录方式有两种(默认一键登录方式和其他方式登录),需要在一个模态窗口中切换,使用if进行条件渲染 if (this.isDefaultLogin) { // 默认一键登录方式 this.DefaultLoginPage() } else { // 其他登录方式 OtherWaysToLogin() .transition(this.effect) // 此处涉及到组件的显示和消失,所以使用transition属性设置出现/消失转场 } Image($r('app.media.arrow_back'))// 通过Stack组件,两个页面只实现一个back .id('login_back') .width(25).height(25) .margin({ top: 20 }) .onClick(() => { if (this.isDefaultLogin) { this.isPresentInLoginView = false; } else { this.isDefaultLogin = true } }) } .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .size({ width:'100%', height: '100%' }) .padding({ top: 12, left:12, right:12 }) .backgroundColor(Color.White) // 将模态页面背景设置为白色,以避免模态页面内组件发生显隐变化时露出下层页面 } } (三)统一返回键布局(Stack 组件)用Stack包裹登录内容,复用返回键逻辑(完整代码在上方Step2): Stack({ alignContent: Alignment.TopStart }) { // 登录方式有两种(默认一键登录方式和其他方式登录),需要在一个模态窗口中切换,使用if进行条件渲染 if (this.isDefaultLogin) { this.DefaultLoginPage() // 默认一键登录方式 } else { OtherWaysToLogin()// 其他登录方式 .transition(this.effect) // 此处涉及到组件的显示和消失,所以使用transition属性设置出现/消失转场 } Image($r('app.media.arrow_back'))// 通过Stack组件,两个页面只实现一个back .id('login_back') .width(25).height(25) .margin({ top: 20 }) .onClick(() => { if (this.isDefaultLogin) { this.isPresentInLoginView = false; } else { this.isDefaultLogin = true } }) } .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .size({ width:'100%', height: '100%' }) .padding({ top: 12, left:12, right:12 }) .backgroundColor(Color.White) // 将模态页面背景设置为白色,以避免模态页面内组件发生显隐变化时露出下层页面 (四)协议勾选校验与登录控制添加协议勾选状态判断,未勾选时提示:@Component export struct ReadAgreement { build() { Text() { Span('阅读并同意') .fontColor('#999999') Span('服务协议及个人信息处理规则') .fontColor(Color.Orange) .onClick(() => { // 调用Toast显示用户点击服务协议及个人信息处理规则的提示 promptAction.showToast({ message: '服务协议及个人信息处理规则' }); }) } .textAlign(TextAlign.Start) } } (五)手机号输入与验证码按钮控制监听手机号输入长度,动态激活按钮:import { Prompt } from '@kit.ArkUI'; const PHONE_NUMBER_LENGTH = 11; const SPACE_TWENTY = 20; const SPACE_TEN = 10; const COUNTDOWN_SECONDS = 30; // 倒计时总秒数 const SEND_AGAIN_IN_SECONDS = "s后可再次发送"; @Component struct OtherLoginView { @State phoneNum: string = ''; // 手机号输入值 controller: TextInputController = new TextInputController(); // 发送验证码按钮的颜色 @State buttonColor: ResourceColor = Color.Grey; // 发送验证码按钮的内容 @State buttonContent: ResourceStr = '发送短信验证码'; // 手机号是否可用 phoneNumberAvailable: boolean = false; // 可发送验证码的倒计时秒数 build() { Column() { // 手机号输入框 TextInput({ placeholder:'请输入手机号' }) // 正则表达式,输入的是数字0-9则允许显示,不是则被过滤 .inputFilter('[0-9]') .backgroundColor(Color.Transparent) .caretColor(Color.Grey) .width('100%') .maxLength(PHONE_NUMBER_LENGTH)// 设置最大输入字符数 // 当输入字符数为11位时,发送验证码按钮变为蓝色,否则置灰 .onChange((value: string) => { if (value.length === PHONE_NUMBER_LENGTH) { this.phoneNumberAvailable = true; this.buttonColor = Color.Blue; } else { this.phoneNumberAvailable = false; this.buttonColor = Color.Grey; } }) } Button(this.buttonContent) .type(ButtonType.Normal) .border({ radius: 8 }) .width('100%') .backgroundColor(this.buttonColor) .id('send_button_id') .onClick(() => { if (this.countdownSeconds > 0) { // 处于可再次发送的读秒倒计时状态下,点击按钮不响应 return; } // 输入输入字符数为11位,并同意服务协议及个人信息处理规则,才能发送验证码 if (!this.phoneNumberAvailable) { promptAction.showToast({ message:'请输入正确的手机号' }); } else if (!this.isAgree) { promptAction.showToast({ message: '请先阅读并同意服务协议及个人信息处理规则' }); } else { // 点击发送短信验证码按钮后,按钮置灰,开始读秒倒计时,按钮内容改变 promptAction.showToast({ message: '验证码已发送') }); this.buttonColor = Color.Grey; this.countdownSeconds = COUNTDOWN_SECONDS; const timerId = setInterval(() => { this.countdownSeconds--; if (this.countdownSeconds <= 0) { // 计时结束,根据手机号位数是否正确,重置按钮状态 this.buttonContent = '发送短信验证码'; clearInterval(timerId); this.buttonColor = this.phoneNumberAvailable ? Color.Blue : Color.Grey; return; } this.buttonContent = this.countdownSeconds + SEND_AGAIN_IN_SECONDS; }, 1000) } }) } .padding(20) .width('100%') } } 5.方案成果总结 主页面点击触发下方滑出的全屏模态登录页,支持一键登录与其他登录方式切换。技术上通过 bindContentCover 绑定模态页,Stack 组件统一返回键逻辑避免跳转冲突,transition 添加页面切换过渡效果;还实现协议勾选校验(未勾选提示 “请先同意协议”)、手机号输入 11 位后激活 “发送验证码” 按钮等交互。案例复用组件减少冗余,功能合规且操作引导清晰,大幅提升登录交互流畅度,满足登录场景的使用需求与用户体验要求。
-
开发者技术支持-软键盘展开时显示自定义Toast导致键盘关闭问题问题说明:自定义样式 Toast 通常使用 getUIContext().getPromptAction().openCustomDialog() 封装(通过自定义弹窗实现自定义样式的Toast),在软键盘展开情况下显示显示自定义 Toast 时会出现软键盘收起的问题。原因分析:使用 openCustomDialog 打开自定义弹窗时使得当前 TextInput、TextArea 失去焦点,软键盘关闭。解决思路:使用 Overlay 方式实现自定义 Toast1、使用 OverlayManager 方式实现, 经验证在 Toast 关闭时软键盘也会被关闭,Pass。2、使用 CommonMethod<T>.overlay(value: string | CustomBuilder | ComponentContent, options?: OverlayOptions): T 通过控制 CustomBuilder 显示/隐藏 实现 自定义 Toast。 /** * Add mask text to the current component. The layout is the same as that of the current component. * * @param { string | CustomBuilder | ComponentContent } value * @param { OverlayOptions } options * @returns { T } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @form * @atomicservice * @since 12 */ overlay(value: string | CustomBuilder | ComponentContent, options?: OverlayOptions): T; 解决方案:定义 ToastOptions 参数interface ToastOptions { message: ResourceStr icon?: Resource margin: Margin | Length | LocalizedMargin } 实现 Toast 控制器 ToastController@ObservedV2 export class ToastController { @Trace isShow: boolean = false @Trace options: ToastOptions = { message: "", margin: { bottom: "20%" } } showToast( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, margin: margin }, duration) } showSuccess( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, icon: $r('app.media.ic_toast_success'), margin: margin }, duration) } showFailed( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, icon: $r('app.media.ic_toast_error'), margin: margin }, duration) } showWarning( message: ResourceStr, duration: number = 3000, margin: Margin | Length | LocalizedMargin = { bottom: "20%" } ) { this.show({ message: message, icon: $r('app.media.ic_toast_warning'), margin: margin }, duration) } private show(options: ToastOptions, duration: number) { this.options = options this.isShow = true setTimeout(() => { this.isShow = false }, duration) } } 实现 CustomBuilder@Builder export function ToastBuilder(controller: ToastController) { Stack() { Text() { if (controller.options.icon) { ImageSpan(controller.options.icon) .width(20) .height(20) .margin({ right: 8 }) } Span(controller.options.message) .fontWeight(400) .fontColor($r("app.color.White_09")) .fontSize(13) } .padding({ top: 10, left: 24, bottom: 10, right: 24, }) .textAlign(TextAlign.Center) .border({ color: $r('app.color.White_008'), radius: 24, width: 0.5 }) .backgroundColor($r('app.color.grey_a_95')) .margin(controller.options.margin) } .alignContent(Alignment.Center) .zIndex(4) .width('100%') .height('100%') .visibility(controller.isShow ? Visibility.Visible : Visibility.None) } 使用@ComponentV2 export struct Component { @Local toastController: ToastController = new ToastController() build() { Column() { Button('toast') .onClick(() => { this.toastController.showWarning('我是Toast内容')) }) } .width('100%') .height('100%') .overlay(ToastBuilder(this.toastController!!)) } }
上滑加载中
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签