-
1、关键技术难点总结1.1 问题说明(一)Tabs与List嵌套布局存在滚动冲突,影响用户流畅浏览体验在Tabs组件内嵌套List组件实现多页签内容展示时,由于Tabs组件和List组件均拥有独立的滚动机制,在滚动操作过程中容易产生手势识别冲突,导致滚动行为中断或响应不连贯。(二)下拉刷新与上拉加载状态管理复杂,难以维护多状态同步下拉刷新和上拉加载功能涉及多种状态(如刷新中、加载更多、空闲状态等),且每个Tab页签需要独立管理自身状态,状态数量多、变化频繁,导致状态管理逻辑复杂;(三)多Tab页数据隔离与同步机制不完善,难以保证数据一致性多个Tab页签需要展示不同类别的数据,每个Tab页签的数据源相互独立,但在某些场景下又需要共享数据更新逻辑(如全局刷新);数据隔离与同步机制不完善,容易造成数据更新遗漏或重复,难以保证多个Tab页签数据的一致性。1.2 原因分析嵌套布局存在滚动冲突:Tabs与List组件各自维护独立的滚动控制器和手势识别器,嵌套滚动层级关系不明确,导致手势事件分发机制混乱,缺乏统一的滚动协调机制来处理父子组件间的滚动优先级下拉刷新与上拉加载状态管理复杂:状态分散在各个Tab页面,缺乏统一的状态管理策略,刷新状态、数据状态、分页状态等多维度状态交织,异步操作与状态更新时序难以保证一致性多Tab页数据隔离与同步机制不完善:数据模型设计未充分考虑多Tab场景下的隔离需求,缺乏统一的数据加载和更新策略2、解决思路模块化设计:采用分层架构设计,将状态管理、数据加载、UI渲染分离,通过统一的多Tab间的交互逻辑,确保各层职责清晰、耦合度低。状态隔离:为每个Tab页创建独立的状态管理对象,避免状态混淆,采用响应式状态管理方案,实现状态的精准更新和高效同步数据驱动UI:充分利用ArkUI的响应式特性,让UI自动响应数据变化,提供清晰的操作反馈和状态提示,确保下拉刷新、上拉加载等操作的流畅性和可感知性。3、解决方案3.1 核心架构设计采用面向对象的设计思想,创建两个核心数据模型类:TabData:管理单个Tab的所有状态和数据ListItemData:表示列表中的单项数据3.2 关键实现要点使用@ObservedV2装饰器:实现深度监听,确保嵌套对象属性变化能被正确检测防抖处理:// 防止重复刷新 if (this.tabData[tabIndex].isRefreshing) { return } 3.3 UI组件设计下拉刷新组件:使用原生Refresh组件,提供流畅的刷新体验上拉加载组件:结合List的onReachEnd事件实现自动加载状态提示组件:加载中、无更多数据等状态的友好提示3.4 代码示例@Entry @Component struct TabRefreshDemo { @State currentTabIndex: number = 0 @State tabData: TabData[] = [ new TabData('推荐', []), new TabData('热点', []), new TabData('科技', []) ] build() { Column() { // Tab栏 - 自定义TabBar this.BuildTabBar() // 使用Tabs容器包裹TabContent Tabs({ index: this.currentTabIndex }) { ForEach(this.tabData, (item: TabData, index: number) => { TabContent() { this.BuildRefreshList(index) } }) } .onChange((index: number) => { this.currentTabIndex = index }) .width('100%') .layoutWeight(1) // 占据剩余空间 } .width('100%') .height('100%') } @Builder BuildTabBar() { Row() { ForEach(this.tabData, (item: TabData, index: number) => { Column() { Text(item.title) .fontSize(18) .fontColor(this.currentTabIndex === index ? '#007DFF' : '#666666') .padding(16) // 选中指示器 if (this.currentTabIndex === index) { Divider() .color('#007DFF') .strokeWidth(2) .width(20) .margin({ top: 4 }) } } .onClick(() => { this.currentTabIndex = index }) }) } .justifyContent(FlexAlign.SpaceAround) .width('100%') .backgroundColor('#F5F5F5') } @Builder BuildRefreshList(tabIndex: number) { Column() { Refresh({ refreshing: this.tabData[tabIndex].isRefreshing, offset: 80, friction: 80 }) { List({ space: 12 }) { ForEach(this.tabData[tabIndex].dataList, (item: ListItemData) => { ListItem() { this.BuildListItem(item) } }, (item: ListItemData) => item.id.toString()) // 加载更多 if (this.tabData[tabIndex].hasMore) { ListItem() { this.BuildLoadMoreItem(tabIndex) } } } .onReachEnd(() => { this.onLoadMore(tabIndex) }) .width('100%') .layoutWeight(1) } .onRefreshing(() => { this.onRefresh(tabIndex) }) } .width('100%') .height('100%') } @Builder BuildListItem(item: ListItemData) { Column() { Text(item.title) .fontSize(16) .fontColor('#333333') .textAlign(TextAlign.Start) .width('100%') .margin({ bottom: 8 }) Text(item.content) .fontSize(14) .fontColor('#666666') .textAlign(TextAlign.Start) .width('100%') Divider() .margin({ top: 12 }) } .padding(16) .width('100%') } @Builder BuildLoadMoreItem(tabIndex: number) { Column() { if (this.tabData[tabIndex].isLoadingMore) { Row() { LoadingProgress() .width(20) .height(20) Text('加载中...') .fontSize(14) .fontColor('#999999') .margin({ left: 8 }) } } else { Text('上拉加载更多') .fontSize(14) .fontColor('#999999') } } .padding(20) .width('100%') .justifyContent(FlexAlign.Center) } // 下拉刷新 onRefresh(tabIndex: number) { if (this.tabData[tabIndex].isRefreshing) { return } this.tabData[tabIndex].isRefreshing = true // 模拟网络请求 setTimeout(() => { const newData = this.generateMockData(10, tabIndex) this.tabData[tabIndex].dataList = newData this.tabData[tabIndex].isRefreshing = false this.tabData[tabIndex].pageIndex = 1 this.tabData[tabIndex].hasMore = true }, 1500) } // 上拉加载更多 onLoadMore(tabIndex: number) { if (this.tabData[tabIndex].isLoadingMore || !this.tabData[tabIndex].hasMore) { return } this.tabData[tabIndex].isLoadingMore = true // 模拟网络请求 setTimeout(() => { const moreData = this.generateMockData(5, tabIndex) // 使用concat创建新数组 this.tabData[tabIndex].dataList = this.tabData[tabIndex].dataList.concat(moreData) this.tabData[tabIndex].isLoadingMore = false this.tabData[tabIndex].pageIndex += 1 // 模拟没有更多数据的情况 if (this.tabData[tabIndex].pageIndex >= 3) { this.tabData[tabIndex].hasMore = false } }, 1000) } // 生成模拟数据 generateMockData(count: number, tabIndex: number): ListItemData[] { const titles: string[] = ['鸿蒙开发实战', 'ArkTS进阶指南', '性能优化技巧', 'UI组件详解'] const tabTitles: string[] = ['推荐', '热点', '科技'] const result: ListItemData[] = [] for (let i = 0; i < count; i++) { const titleIndex: number = i % titles.length result.push(new ListItemData( Date.now() + i, `${titles[titleIndex]} - ${tabTitles[tabIndex]}`, `这是${tabTitles[tabIndex]}页面的第${i + 1}条内容,展示了鸿蒙开发的强大功能。` )) } return result } } // 数据模型类 - 使用ObservedV2装饰器 @ObservedV2 class TabData { @Trace title: string @Trace dataList: ListItemData[] @Trace isRefreshing: boolean = false @Trace isLoadingMore: boolean = false @Trace hasMore: boolean = true @Trace pageIndex: number = 1 constructor(title: string, dataList: ListItemData[]) { this.title = title this.dataList = dataList } } @ObservedV2 class ListItemData { @Trace id: number @Trace title: string @Trace content: string constructor(id: number, title: string, content: string) { this.id = id this.title = title this.content = content } } 4、方案成果总结(一)支持多Tab页面的流畅切换,每个Tab都具备独立的下拉刷新和上拉加载功能,用户可以通过直观的手势操作更新内容。采用分页数据管理机制,确保内容加载的有序性,并提供加载状态、无更多数据等清晰提示,形成了完整的用户体验闭环,满足各类内容浏览需求。(二)流畅的下拉刷新和上拉加载动画效果,通过高效的渲染机制和组件复用策略,能快速处理大量列表项展示,内存管理合理无泄漏。Tab切换响应迅速,内容加载流畅。
-
1.1问题说明在鸿蒙应用开发中,需要做出一个能自己调整样式、实时显示时间的时钟功能,主要遇到这些问题:在鸿蒙应用里做出包含表盘刻度、指针、中心圆点和时间文字的完整时钟界面;让时钟每秒自动更新,同时不浪费手机资源;让时钟的颜色、大小等样式能灵活调整,满足不同使用场景;规范时钟功能的使用流程,避免不用的时候还占用手机资源。1.2原因分析(一)时钟界面包含多个部分,绘制时需要精确调整位置和角度,且鸿蒙应用的绘图工具需要按特定规则使用;(二)时钟每秒更新需要用到定时刷新功能,若不跟着时钟的使用状态开关,不用时还会继续运行,造成资源浪费;(三)不同场景下需要不同样式的时钟,得让用户能直接设置颜色、大小等,还要让设置好的样式及时生效;(四)鸿蒙应用的功能模块有明确的创建、显示、关闭流程,若定时刷新功能的开关时机不对,会导致时钟显示异常或浪费资源。1.3解决思路(一)把时钟绘图的相关操作整理成独立的工具模块,让界面和绘图逻辑分开,更易维护;(二)跟着时钟的使用流程管理定时刷新功能,打开时钟时启动刷新,关闭时钟时停止刷新,避免资源浪费;(三)设计外部配置项,让用户能直接设置颜色、大小等样式,在时钟启动时应用这些配置;(四)把时钟的默认大小、刷新间隔等固定参数统一管理,后续要修改时更方便。1.4解决方案(一)统一管理固定基础参数:将时钟默认大小、刷新间隔、时间计数规则等常用固定信息集中记录,方便后续统一调整,无需改动核心功能。(二)拆分绘图流程并支持样式配置:把时钟表盘刻度、指针、中心圆点、时间文字等部分拆分绘制,同时开放颜色、尺寸等样式设置入口,确保绘制清晰且样式可灵活调整。(三)封装独立功能模块,规范使用流程:将时钟功能打包成鸿蒙应用可直接集成的模块,按 “启动初始化 — 打开后刷新 — 关闭后停止” 的流程管理,同时提供重置时间、设置特定时间等可控操作。(四)优化显示效果与资源利用效率:刷新时先清空旧画面避免重叠,简化指针绘制逻辑确保位置准确,让时间文字大小与时钟整体比例协调,同时避免无用时的资源浪费。组件代码示例:export class Constants { /** * Number 2. */ static readonly NUMBER_TWO: number = 2; /** * Number 10. */ static readonly NUMBER_TEN: number = 10; /** * Number 60. */ static readonly NUMBER_SIXTY: number = 60; /** * Default size of the watch face. */ static readonly DEFAULT_WATCH_SIZE: number = 280; /** * Default size of the watch face. */ static readonly DEFAULT_WATCH_RADIUS: number = 150; /** * Full percentage. */ static readonly FULL_PERCENTAGE: string = '100%'; /** * Interval time. */ static readonly INTERVAL_TIME: number = 1000; /** * Canvas height add. */ static readonly HEIGHT_ADD: number = 150; /** * Conversion rate. */ static readonly CONVERSION_RATE: number = 0.6; } export class DrawClock { private minute: number = 0; private second: number = 0; private intervalId: number = 0; // 默认颜色配置 private largeScaleColor: string = '#425C5A'; private smallScaleColor: string = '#A2BFBD'; private handColor: string = '#425C5A'; private textColor: string = '#425C5A'; private dotColor: string = '#425C5A'; private canvasWidth: number = Constants.DEFAULT_WATCH_SIZE; // 设置颜色配置 setColors(largeScaleColor: string, smallScaleColor: string, handColor: string, textColor: string, dotColor: string) { this.largeScaleColor = largeScaleColor; this.smallScaleColor = smallScaleColor; this.handColor = handColor; this.textColor = textColor; this.dotColor = dotColor; } // 开始计时 startTimer(context: CanvasRenderingContext2D, radius: number, canvasWidth: number) { this.canvasWidth = canvasWidth; this.updateTime(context, radius, canvasWidth); this.intervalId = setInterval(() => { this.updateTime(context, radius, canvasWidth); }, Constants.INTERVAL_TIME); } // 停止计时 stopTimer() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = 0; } } // 更新时间 private updateTime(context: CanvasRenderingContext2D, radius: number, canvasWidth: number) { this.second++; console.log("updateTime=" + `${this.minute}:${this.second}`); if (this.second >= Constants.NUMBER_SIXTY) { this.second = 0; this.minute++; } context.clearRect(0, 0, canvasWidth, canvasWidth + Constants.HEIGHT_ADD); let time = `${this.fillTime(this.minute)}:${this.fillTime(this.second)}`; this.drawClock(context, radius, this.minute, this.second, time); context.translate(-radius, -radius); } // 时间格式化 private fillTime(time: number) { return time < Constants.NUMBER_TEN ? `0${time}` : `${time}`; } // 重置时间 resetTime() { this.minute = 0; this.second = 0; } // 设置时间 setTime(minute: number, second: number) { this.minute = minute; this.second = second; } drawClock(context: CanvasRenderingContext2D, radius: number, minute: number, second: number, time: string) { this.drawBackGround(context, radius); this.drawMinute(context, radius, minute); this.drawSecond(context, radius, second); this.drawDot(context); this.drawTime(context, radius, time); } drawBackGround(context: CanvasRenderingContext2D, radius: number) { context.save(); context.translate(radius, radius); context.save(); // Draw the scale for (let i = 0; i < 60; i++) { let rad = 2 * Math.PI / 60 * i; let x = Math.cos(rad) * (radius - 12); let y = Math.sin(rad) * (radius - 12); context.beginPath(); context.moveTo(x, y); if (i % 5 == 0) { let x1 = Math.cos(rad) * (radius - 20); let y1 = Math.sin(rad) * (radius - 20); context.strokeStyle = this.largeScaleColor; context.lineWidth = 2; context.lineTo(x1, y1); } else { let x1 = Math.cos(rad) * (radius - 18); let y1 = Math.sin(rad) * (radius - 18); context.strokeStyle = this.smallScaleColor; context.lineWidth = 1; context.lineTo(x1, y1); } context.stroke(); } } // Draw the minute hand drawMinute(context: CanvasRenderingContext2D, radius: number, minute: number) { context.save(); context.beginPath(); context.lineWidth = 3; context.lineCap = 'round'; let rad = 2 * Math.PI / 60 * minute; context.rotate(rad); context.moveTo(0, 10); context.strokeStyle = this.handColor; context.lineTo(0, -radius + 40); context.stroke(); context.restore(); } // Draw the second hand drawSecond(context: CanvasRenderingContext2D, radius: number, second: number) { context.save(); context.beginPath(); context.lineWidth = 2; context.lineCap = 'round'; let rad = 2 * Math.PI / 60 * second; context.rotate(rad); context.moveTo(0, 10); context.strokeStyle = this.handColor; context.lineTo(0, -radius + 21); context.stroke(); context.restore(); } // Draw the center point drawDot(context: CanvasRenderingContext2D) { context.save(); context.beginPath(); context.fillStyle = this.dotColor; context.arc(0, 0, 4, 0, 2 * Math.PI, false); context.fill(); context.restore(); } // Draw the time text below the dial drawTime(context: CanvasRenderingContext2D, radius: number, time: string) { context.save(); context.beginPath(); let fontSize = this.canvasWidth / 3; context.font = fontSize + 'px'; context.textAlign = "center"; context.textBaseline = "middle"; context.fillStyle = this.textColor; context.fillText(time, 0, radius / 3); context.restore(); } } @Component export struct DrawClockComponent { private drawClock: DrawClock = new DrawClock(); private settings: RenderingContextSettings = new RenderingContextSettings(true); private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); @State canvasWidth: number = Constants.DEFAULT_WATCH_SIZE; @State radius: number = Constants.DEFAULT_WATCH_RADIUS; // 可配置参数 @Prop largeScaleColor: string = '#425C5A'; @Prop smallScaleColor: string = '#A2BFBD'; @Prop handColor: string = '#425C5A'; @Prop textColor: string = '#425C5A'; @Prop dotColor: string = '#425C5A'; @Prop clockSize: number = Constants.DEFAULT_WATCH_SIZE; aboutToAppear() { this.drawClock.setColors(this.largeScaleColor, this.smallScaleColor, this.handColor, this.textColor, this.dotColor); this.radius = this.clockSize / Constants.NUMBER_TWO; this.canvasWidth = this.clockSize; } aboutToDisappear(): void { this.drawClock.stopTimer(); } // 重置时间 resetTime(): void { this.drawClock.resetTime(); } // 设置时间 setTime(minute: number, second: number): void { this.drawClock.setTime(minute, second); } build() { Stack({ alignContent: Alignment.Center }) { Canvas(this.context) .padding({ top: 76 }) .width(this.canvasWidth) .height(this.canvasWidth + Constants.HEIGHT_ADD) .onReady(() => { this.drawClock.startTimer(this.context, this.radius, this.canvasWidth); }) } .width(Constants.FULL_PERCENTAGE) .height(Constants.FULL_PERCENTAGE) } } 演示代码示例:import { DrawClockComponent, Constants } from '../utils/DrawClock'; @Entry @Component struct Clock { build() { Stack({ alignContent: Alignment.Center }) { // 使用DrawClock组件,可以自定义颜色和大小 // DrawClockComponent({ // largeScaleColor: '#FF6B6B', // smallScaleColor: '#4ECDC4', // handColor: '#556270', // textColor: '#C44D58', // dotColor: '#FF6B6B' // }) DrawClockComponent() } .width(Constants.FULL_PERCENTAGE) .height(Constants.FULL_PERCENTAGE) } } 1.5方案成果总结通过时钟在鸿蒙系统上的动画体验得到显著提升,具体成果如下:功能齐全:成功做出能实时更新的时钟,包含表盘刻度、指针、中心圆点和时间显示,满足日常计时需求;灵活适配:支持调整颜色、大小,还能重置或设置特定时间,不用修改核心功能就能适配不同使用场景;稳定高效:跟着时钟的使用流程管理刷新功能,避免资源浪费,且各项操作逻辑清晰,后续维护更方便;兼容性好:完全按照鸿蒙应用的使用规则开发,能直接集成到鸿蒙应用中使用,适配效果良好。
-
1、关键技术难点总结1.1 问题说明(一)裁剪区域边界控制不精确,无法满足精准裁剪需求鸿蒙原生图片处理组件仅提供基础的图片显示功能,无法精确控制裁剪区域边界,裁剪区域容易超出图片显示范围;且初始化时裁剪区域未与图片实际显示尺寸一致并居中显示,无法满足用户对精准裁剪的需求。(二)手势操作体验不佳,交互方式不匹配实际使用场景在图片裁剪场景中,用户需要通过手势实现平滑的拖拽、缩放等操作来调整裁剪区域,需要频繁切换操作方式(如先拖拽再缩放),操作繁琐;且无法直观区分不同操作的视觉反馈,用户难以快速感知操作效果。1.2 原因分析坐标系复杂性:显示容器、图片显示区域、裁剪区域等多个坐标系需要正确映射,增加了边界控制的复杂度手势识别冲突:图片平移与裁剪框操作手势需要正确区分和处理,缺乏统一的手势管理机制图片处理API限制:HarmonyOS图片处理API在裁剪区域计算上需要精确控制参数流畅性优化需求:频繁的手势操作需要保证界面流畅性,避免卡顿,对算法优化提出更高要求2、解决思路(一)建立坐标转换机制,实现精准边界控制通过建立显示坐标与图片像素坐标之间的映射关系,精确计算裁剪区域边界,确保裁剪区域不超出图片显示范围,并在初始化时与图片实际显示尺寸一致并居中显示,满足用户精准裁剪需求。(二)优化手势处理机制,提升交互体验使用GestureGroup并行处理多种手势操作,通过状态管理机制确保UI与数据同步,实现流畅的拖拽、缩放等操作;提供直观的视觉反馈,使用户能够快速感知操作效果,降低误操作率。(三)封装图片裁剪组件通过封装组件,提供快速使用的组件,减少开发复杂度,提升开发效率。3、解决方案3.1主页面Index.etsimport { promptAction } from '@kit.ArkUI' import { image } from '@kit.ImageKit'; import { ResourceUtils } from '../utils/ResourceUtils'; import { ImageCropPage } from './ImageCropPage'; @Entry @Component struct Index { @State message: string = '图片裁剪应用' @State isShowCropPage: boolean = false @State selectedImage: string = '' @State pixelMap: image.PixelMap | null = null; // 加载后的图片PixelMap @State isLoading: boolean = false; // 图片加载状态 // https://data.znds.com/attachment/forum/201503/26/132039qb98se8bkjex29rx.jpg @State pictureUri: string = 'https://ts4.tc.mm.bing.net/th/id/OIP-C.xtGCrrFmixnPrsaP5zbwvgHaKX?cb=12&rs=1&pid=ImgDetMain&o=7&rm=3'; private context: Context = this.getUIContext().getHostContext() as Context build() { // 使用Stack作为唯一根节点,同时容纳主页面和裁剪页面 Stack() { // 主页面内容 Column() { Text(this.message) .fontSize(30) .fontWeight(FontWeight.Bold) .margin({ top: 40, bottom: 50 }) // 选择网络图片 TextInput({placeholder: '请输入图片地址' }) .width('88%') .height(50) .margin({ bottom: 20 }) .onChange((v) => { this.pictureUri = v }) Button('加载网络图片') .width('88%') .height(50) .fontSize(18) .backgroundColor('#007DFF') .fontColor(Color.White) .margin({ bottom: 40, left: 10 }) .onClick(() => { this.isLoading = true this.selectFromNetwork() }) // 显示选中的图片 if (this.pixelMap) { Image(this.pixelMap) .width(200) .height(200) .objectFit(ImageFit.Cover) .border({ width: 1, color: '#CCCCCC' }) .margin({ bottom: 20 }) Button('开始裁剪') .width('60%') .height(45) .fontSize(16) .backgroundColor('#FF6A00') .fontColor(Color.White) .onClick(() => { this.isShowCropPage = true }) } else { if (this.isLoading) { // 加载中:显示进度条 LoadingProgress() .color('#ffcf2b2b') .size({ width: 40, height: 40 }) } } } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .padding(20) // 裁剪页面 - 使用条件渲染和绝对定位 if (this.isShowCropPage) { ImageCropPage({ imageSrc: $selectedImage, pixMap: $pixelMap, onSave: (croppedImage: string) => { this.isShowCropPage = false this.selectedImage = croppedImage promptAction.showToast({ message: '图片保存成功!', duration: 2000 }) }, onCancel: () => { this.isShowCropPage = false } }) .width('100%') .height('100%') .position({ x: 0, y: 0 }) } } .width('100%') .height('100%') } // 模拟选择相册图片 private selectFromAlbum() { // 实际开发中应使用系统相册选择器 // 这里使用模拟数据 this.selectedImage = '/resources/base/media/sample_image.jpg' } // 模拟选择网络图片 private async selectFromNetwork() { // 实际开发中应使用网络图片URL https://data.znds.com/attachment/forum/201503/26/132039qb98se8bkjex29rx.jpg // 这里使用模拟数据 let filePath: string | null = await ResourceUtils.downloadImage(this.context, this.pictureUri); if (!filePath) { promptAction.openToast({ message: '图片下载失败', duration: 2000 }) } else { this.selectedImage = filePath this.pixelMap = await ResourceUtils.loadFromCacheFilePath(filePath); } } } 3.2图片裁剪页面ImageCropPage.ets(核心代码片段)// 初始化裁剪区域为图片大小 private initCropRect() { if (this.pixMap) { try { this.pixMap.getImageInfo().then((imgInfo) => { this.imageInfo = { width: imgInfo.size.width, height: imgInfo.size.height }; // 计算图片在容器中的显示尺寸和位置 const containerRatio: number = this.CONTAINER_SIZE / this.CONTAINER_SIZE; const imageRatio: number = this.imageInfo.width / this.imageInfo.height; let displayWidth: number = 0; let displayHeight: number = 0; let displayX: number = 0; let displayY: number = 0; if (imageRatio > containerRatio) { displayWidth = this.CONTAINER_SIZE; displayHeight = this.CONTAINER_SIZE / imageRatio; displayX = 0; displayY = (this.CONTAINER_SIZE - displayHeight) / 2; } else { displayHeight = this.CONTAINER_SIZE; displayWidth = this.CONTAINER_SIZE * imageRatio; displayX = (this.CONTAINER_SIZE - displayWidth) / 2; displayY = 0; } // 设置裁剪区域为图片显示区域 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; this.cropRect = { x: Math.max(controlPointMargin, displayX), y: Math.max(controlPointMargin, displayY), width: Math.max(this.MIN_CROP_SIZE, displayWidth - controlPointMargin * 2), height: Math.max(this.MIN_CROP_SIZE, displayHeight - controlPointMargin * 2) }; }); } catch (error) { console.error('获取图片信息失败: ' + error); } } } 手势操作处理// 图片显示区域手势 Image(this.pixMap) .width('100%') .height(400) .objectFit(ImageFit.Contain) .scale({ x: this.imageTransform.scale, y: this.imageTransform.scale }) .translate({ x: this.imageTransform.offsetX, y: this.imageTransform.offsetY }) .gesture( GestureGroup(GestureMode.Parallel, // 缩放手势 PinchGesture() .onActionStart(() => {}) .onActionUpdate((event: PinchGestureEvent) => { const newScale: number = this.imageTransform.scale * event.scale // 限制缩放范围 this.imageTransform.scale = Math.max(0.5, Math.min(3, newScale)) }), // 平移手势 PanGesture() .onActionStart((event: GestureEvent) => { this.isImageMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isImageMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y this.imageTransform.offsetX += deltaX this.imageTransform.offsetY += deltaY this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isImageMoving = false }) .onActionCancel(() => { this.isImageMoving = false }) ) ) 裁剪框拖拽处理// 裁剪框 - 支持整体拖拽 Rect() .width(this.cropRect.width) .height(this.cropRect.height) .position({ x: this.cropRect.x, y: this.cropRect.y }) .fill(Color.Transparent) .strokeWidth(2) .stroke('#FF6A00') .shadow({ radius: 10, color: '#000000', offsetX: 0, offsetY: 0 }) .gesture( PanGesture() .onActionStart((event: GestureEvent) => { this.isCropMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isCropMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y // 更新裁剪框位置,限制在图片显示范围内 const newRect: CropRect = this.copyCropRect() newRect.x += deltaX newRect.y += deltaY // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isCropMoving = false }) .onActionCancel(() => { this.isCropMoving = false }) ) 边界约束算法// 应用裁剪框约束 - 确保不超出图片范围 private applyCropConstraints(rect: CropRect) { // 获取图片显示区域 const imageDisplayRect: CropRect = this.getImageDisplayRect() // 确保最小尺寸 rect.width = Math.max(this.MIN_CROP_SIZE, rect.width) rect.height = Math.max(this.MIN_CROP_SIZE, rect.height) // 确保裁剪框在图片显示区域内 rect.x = Math.max(imageDisplayRect.x, Math.min(imageDisplayRect.x + imageDisplayRect.width - rect.width, rect.x)) rect.y = Math.max(imageDisplayRect.y, Math.min(imageDisplayRect.y + imageDisplayRect.height - rect.height, rect.y)) // 确保裁剪框在容器范围内 rect.x = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.width, rect.x)) rect.y = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.height, rect.y)) // 确保裁剪框尺寸不超过图片显示区域 rect.width = Math.min(rect.width, imageDisplayRect.width) rect.height = Math.min(rect.height, imageDisplayRect.height) // 确保控制点可见 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; rect.x = Math.max(controlPointMargin, rect.x) rect.y = Math.max(controlPointMargin, rect.y) rect.width = Math.max(this.MIN_CROP_SIZE, Math.min(rect.width, this.CONTAINER_SIZE - rect.x - controlPointMargin)) rect.height = Math.max(this.MIN_CROP_SIZE, Math.min(rect.height, this.CONTAINER_SIZE - rect.y - controlPointMargin)) } 图片保存功能// 实现图片保存功能 private async saveCroppedImage() { this.isSaving = true; try { // 1. 创建裁剪后的图片 const croppedImageUri: string = await this.createCroppedImage(); // 2. 回调通知保存成功 this.onSave(croppedImageUri); promptAction.showToast({ message: '图片已保存到相册', duration: 2000 }); } catch (error) { console.error('保存图片失败: ' + error); promptAction.showToast({ message: '保存失败,请重试', duration: 2000 }); } finally { this.isSaving = false; } } 图片裁剪完整组件代码import photoAccessHelper from '@ohos.file.photoAccessHelper'; import { promptAction } from '@kit.ArkUI'; import { image } from '@kit.ImageKit'; import fs from '@ohos.file.fs'; // 定义接口 interface CropRect { x: number; y: number; width: number; height: number; } interface ImageTransform { scale: number; offsetX: number; offsetY: number; } interface Point { x: number; y: number; } interface ImageInfo { width: number; height: number; } interface ControlParam { x: number; y: number; type: string; } @Component export struct ImageCropPage { @Link imageSrc: string @Link pixMap: image.PixelMap private onSave: (croppedImage: string) => void = () => {} private onCancel: () => void = () => {} // 使用接口定义状态类型 @State cropRect: CropRect = { x: 50, y: 50, width: 200, height: 200 } // 图片变换状态 @State imageTransform: ImageTransform = { scale: 1.0, offsetX: 0, offsetY: 0 } @State selectedAspectRatio: string = 'free' @State isDragging: boolean = false @State activeControlPoint: string = '' @State isSaving: boolean = false // 手势状态 @State lastPanPoint: Point = { x: 0, y: 0 } @State isImageMoving: boolean = false @State isCropMoving: boolean = false // 图片信息 @State imageInfo: ImageInfo = { width: 0, height: 0 } // 常量 private readonly MIN_CROP_SIZE: number = 50 private readonly CONTAINER_SIZE: number = 400 private readonly CONTROL_POINT_SIZE: number = 24 private readonly EDGE_CONTROL_WIDTH: number = 8 private readonly EDGE_CONTROL_HEIGHT: number = 24 // 获取图片访问助手 private phAccessHelper: photoAccessHelper.PhotoAccessHelper | null = null; private context: Context = this.getUIContext().getHostContext() as Context aboutToAppear() { this.initPhotoAccessHelper(); this.initCropRect(); } // 初始化裁剪区域为图片大小 private initCropRect() { // 获取图片信息 if (this.pixMap) { try { this.pixMap.getImageInfo().then((imgInfo) => { this.imageInfo = { width: imgInfo.size.width, height: imgInfo.size.height }; // 计算图片在容器中的显示尺寸和位置 const containerRatio: number = this.CONTAINER_SIZE / this.CONTAINER_SIZE; // 1:1 const imageRatio: number = this.imageInfo.width / this.imageInfo.height; let displayWidth: number = 0; let displayHeight: number = 0; let displayX: number = 0; let displayY: number = 0; if (imageRatio > containerRatio) { // 图片更宽,宽度填满容器 displayWidth = this.CONTAINER_SIZE; displayHeight = this.CONTAINER_SIZE / imageRatio; displayX = 0; displayY = (this.CONTAINER_SIZE - displayHeight) / 2; } else { // 图片更高,高度填满容器 displayHeight = this.CONTAINER_SIZE; displayWidth = this.CONTAINER_SIZE * imageRatio; displayX = (this.CONTAINER_SIZE - displayWidth) / 2; displayY = 0; } // 设置裁剪区域为图片显示区域,并确保控制点可见 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; this.cropRect = { x: Math.max(controlPointMargin, displayX), y: Math.max(controlPointMargin, displayY), width: Math.max(this.MIN_CROP_SIZE, displayWidth - controlPointMargin * 2), height: Math.max(this.MIN_CROP_SIZE, displayHeight - controlPointMargin * 2) }; }); } catch (error) { console.error('获取图片信息失败: ' + error); } } } // 初始化图片访问助手 private async initPhotoAccessHelper() { try { this.phAccessHelper = await photoAccessHelper.getPhotoAccessHelper(this.context); } catch (error) { console.error('Failed to get photo access helper: ' + error); } } // 复制裁剪矩形对象 private copyCropRect(): CropRect { return { x: this.cropRect.x, y: this.cropRect.y, width: this.cropRect.width, height: this.cropRect.height }; } // 复制图片变换对象 private copyImageTransform(): ImageTransform { return { scale: this.imageTransform.scale, offsetX: this.imageTransform.offsetX, offsetY: this.imageTransform.offsetY }; } // 复制点对象 private copyPoint(point: Point): Point { return { x: point.x, y: point.y }; } // 复制图片信息对象 private copyImageInfo(): ImageInfo { return { width: this.imageInfo.width, height: this.imageInfo.height }; } // 获取图片在容器中的显示区域 private getImageDisplayRect(): CropRect { if (!this.pixMap || this.imageInfo.width === 0 || this.imageInfo.height === 0) { return { x: 0, y: 0, width: this.CONTAINER_SIZE, height: this.CONTAINER_SIZE }; } const containerRatio: number = this.CONTAINER_SIZE / this.CONTAINER_SIZE; // 1:1 const imageRatio: number = this.imageInfo.width / this.imageInfo.height; let displayWidth: number = 0; let displayHeight: number = 0; let displayX: number = 0; let displayY: number = 0; if (imageRatio > containerRatio) { // 图片更宽,宽度填满容器 displayWidth = this.CONTAINER_SIZE; displayHeight = this.CONTAINER_SIZE / imageRatio; displayX = 0; displayY = (this.CONTAINER_SIZE - displayHeight) / 2; } else { // 图片更高,高度填满容器 displayHeight = this.CONTAINER_SIZE; displayWidth = this.CONTAINER_SIZE * imageRatio; displayX = (this.CONTAINER_SIZE - displayWidth) / 2; displayY = 0; } // 考虑图片变换(缩放和平移) const scaledWidth: number = displayWidth * this.imageTransform.scale; const scaledHeight: number = displayHeight * this.imageTransform.scale; const scaledX: number = displayX * this.imageTransform.scale + this.imageTransform.offsetX; const scaledY: number = displayY * this.imageTransform.scale + this.imageTransform.offsetY; return { x: Math.max(0, scaledX), y: Math.max(0, scaledY), width: Math.min(this.CONTAINER_SIZE, scaledWidth), height: Math.min(this.CONTAINER_SIZE, scaledHeight) }; } build() { Column() { // 标题栏 Row() { Button('取消') .fontSize(16) .fontColor('#007DFF') .backgroundColor(Color.Transparent) .onClick(() => { this.onCancel() }) Text('图片裁剪') .fontSize(18) .fontWeight(FontWeight.Bold) .layoutWeight(1) .textAlign(TextAlign.Center) // 保存按钮 - 添加加载状态 if (this.isSaving) { LoadingProgress() .width(20) .height(20) .color('#007DFF') } else { SaveButton({text: SaveDescription.SAVE_IMAGE, buttonType: ButtonType.Capsule}) .fontSize(16) .fontColor(Color.White) .backgroundColor('#007DFF') .onClick(() => { this.saveCroppedImage() }) } } .width('100%') .height(50) .padding({ left: 15, right: 15 }) // 裁剪区域 Stack() { // 图片显示区域 手势 Image(this.pixMap) .width('100%') .height(400) .objectFit(ImageFit.Contain) .scale({ x: this.imageTransform.scale, y: this.imageTransform.scale }) .translate({ x: this.imageTransform.offsetX, y: this.imageTransform.offsetY }) .gesture( GestureGroup(GestureMode.Parallel, // 缩放手势 PinchGesture() .onActionStart(() => {}) .onActionUpdate((event: PinchGestureEvent) => { const newScale: number = this.imageTransform.scale * event.scale // 限制缩放范围 this.imageTransform.scale = Math.max(0.5, Math.min(3, newScale)) }), // 平移手势 PanGesture() .onActionStart((event: GestureEvent) => { this.isImageMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isImageMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y this.imageTransform.offsetX += deltaX this.imageTransform.offsetY += deltaY this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isImageMoving = false }) .onActionCancel(() => { this.isImageMoving = false }) ) ) // 裁剪框 - 支持整体拖拽 Rect() .width(this.cropRect.width) .height(this.cropRect.height) .position({ x: this.cropRect.x, y: this.cropRect.y }) .fill(Color.Transparent) .strokeWidth(2) .stroke('#FF6A00') .shadow({ radius: 10, color: '#000000', offsetX: 0, offsetY: 0 }) .gesture( PanGesture() .onActionStart((event: GestureEvent) => { this.isCropMoving = true this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) }) .onActionUpdate((event: GestureEvent) => { if (this.isCropMoving) { const deltaX: number = event.offsetX - this.lastPanPoint.x const deltaY: number = event.offsetY - this.lastPanPoint.y // 更新裁剪框位置,限制在图片显示范围内 const newRect: CropRect = this.copyCropRect() newRect.x += deltaX newRect.y += deltaY // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect this.lastPanPoint = this.copyPoint({ x: event.offsetX, y: event.offsetY }) } }) .onActionEnd(() => { this.isCropMoving = false }) .onActionCancel(() => { this.isCropMoving = false }) ) // 控制点 - 使用计算属性确保实时同步 // 四个角控制点 this.BuildControlPoint({x: this.cropRect.x, y: this.cropRect.y, type:'left-top'}) this.BuildControlPoint({x: this.cropRect.x + this.cropRect.width, y: this.cropRect.y, type: 'right-top'}) this.BuildControlPoint({x: this.cropRect.x, y: this.cropRect.y + this.cropRect.height, type: 'left-bottom'}) this.BuildControlPoint({x: this.cropRect.x + this.cropRect.width, y: this.cropRect.y + this.cropRect.height, type: 'right-bottom'}) // 四个边控制点 this.BuildEdgeControlPoint({x: this.cropRect.x + this.cropRect.width / 2, y: this.cropRect.y, type: 'top'}) this.BuildEdgeControlPoint({x: this.cropRect.x + this.cropRect.width, y: this.cropRect.y + this.cropRect.height / 2, type: 'right'}) this.BuildEdgeControlPoint({x: this.cropRect.x + this.cropRect.width / 2, y: this.cropRect.y + this.cropRect.height, type: 'bottom'}) this.BuildEdgeControlPoint({x: this.cropRect.x, y: this.cropRect.y + this.cropRect.height / 2, type: 'left'}) } .width('100%') .height(400) .backgroundColor('#F5F5F5') .clip(true) .onClick((event: ClickEvent) => { // 点击空白区域取消选中 this.activeControlPoint = '' }) // 控制面板 Column() { // Text('宽高比') // .fontSize(16) // .fontWeight(FontWeight.Medium) // .margin({ bottom: 10 }) // // Row() { // this.BuildRatioButton('自由', 'free') // this.BuildRatioButton('1:1', '1:1') // this.BuildRatioButton('4:3', '4:3') // this.BuildRatioButton('16:9', '16:9') // } // .width('100%') // .justifyContent(FlexAlign.SpaceAround) // .margin({ bottom: 20 }) // // Text('裁剪尺寸') // .fontSize(16) // .fontWeight(FontWeight.Medium) // .margin({ bottom: 10 }) Row() { Column() { Text('宽度') TextInput({ text: Math.round(this.cropRect.width).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue) && numValue >= this.MIN_CROP_SIZE) { const newRect: CropRect = this.copyCropRect() newRect.width = numValue this.applyAspectRatioToRect(newRect) this.applyCropConstraints(newRect) this.cropRect = newRect } }) } Column() { Text('高度') TextInput({ text: Math.round(this.cropRect.height).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue) && numValue >= this.MIN_CROP_SIZE) { const newRect: CropRect = this.copyCropRect() newRect.height = numValue this.applyAspectRatioToRect(newRect) this.applyCropConstraints(newRect) this.cropRect = newRect } }) } } .width('100%') .justifyContent(FlexAlign.SpaceAround) Row() { Column() { Text('X位置') TextInput({ text: Math.round(this.cropRect.x).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue)) { const newRect: CropRect = this.copyCropRect() newRect.x = numValue this.applyCropConstraints(newRect) this.cropRect = newRect } }) } Column() { Text('Y位置') TextInput({ text: Math.round(this.cropRect.y).toString() }) .width(80) .height(35) .type(InputType.Number) .onChange((value: string) => { const numValue: number = parseInt(value) if (!isNaN(numValue)) { const newRect: CropRect = this.copyCropRect() newRect.y = numValue this.applyCropConstraints(newRect) this.cropRect = newRect } }) } } .width('100%') .justifyContent(FlexAlign.SpaceAround) .margin({ top: 15 }) // 重置按钮 Button('重置裁剪区域') .width('80%') .height(40) .fontSize(16) .backgroundColor('#E5E5E5') .fontColor('#333333') .margin({ top: 20 }) .onClick(() => { this.initCropRect() }) } .width('100%') .padding(15) .backgroundColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#F8F8F8') } // 角控制点 - 优化显示和交互 @Builder BuildControlPoint(param: ControlParam) { Rect() .width(this.CONTROL_POINT_SIZE) .height(this.CONTROL_POINT_SIZE) .radius(this.CONTROL_POINT_SIZE / 2) .fill(this.activeControlPoint === param.type ? '#FFA500' : '#FF6A00') .position({ x: param.x - this.CONTROL_POINT_SIZE / 2, y: param.y - this.CONTROL_POINT_SIZE / 2 }) .shadow({ radius: 3, color: '#000000', offsetX: 1, offsetY: 1 }) .border({ width: 2, color: Color.White }) .gesture( PanGesture() .onActionStart(() => { this.activeControlPoint = param.type this.isDragging = true }) .onActionUpdate((event: GestureEvent) => { if (this.isDragging && this.activeControlPoint === param.type) { this.handleControlPointDrag(param.type, event.offsetX, event.offsetY) } }) .onActionEnd(() => { this.isDragging = false this.activeControlPoint = '' }) .onActionCancel(() => { this.isDragging = false this.activeControlPoint = '' }) ) } // 边控制点 - 优化显示和交互 @Builder BuildEdgeControlPoint(param: ControlParam) { Rect() .width(param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_HEIGHT : this.EDGE_CONTROL_WIDTH) .height(param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_WIDTH : this.EDGE_CONTROL_HEIGHT) .fill(this.activeControlPoint === param.type ? '#FFA500' : '#FF6A00') .position({ x: param.x - (param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_HEIGHT / 2 : this.EDGE_CONTROL_WIDTH / 2), y: param.y - (param.type === 'top' || param.type === 'bottom' ? this.EDGE_CONTROL_WIDTH / 2 : this.EDGE_CONTROL_HEIGHT / 2) }) .border({ width: 1, color: Color.White }) .gesture( PanGesture() .onActionStart(() => { this.activeControlPoint = param.type this.isDragging = true }) .onActionUpdate((event: GestureEvent) => { if (this.isDragging && this.activeControlPoint === param.type) { this.handleEdgeControlPointDrag(param.type, event.offsetX, event.offsetY) } }) .onActionEnd(() => { this.isDragging = false this.activeControlPoint = '' }) .onActionCancel(() => { this.isDragging = false this.activeControlPoint = '' }) ) } @Builder BuildRatioButton(text: string, ratio: string) { Button(text) .width(70) .height(35) .fontSize(14) .backgroundColor(this.selectedAspectRatio === ratio ? '#007DFF' : '#E5E5E5') .fontColor(this.selectedAspectRatio === ratio ? Color.White : '#333333') .onClick(() => { this.selectedAspectRatio = ratio const newRect: CropRect = this.copyCropRect() this.applyAspectRatioToRect(newRect) this.applyCropConstraints(newRect) this.cropRect = newRect }) } // 处理角控制点拖拽 - 优化实时同步 private handleControlPointDrag(type: string, offsetX: number, offsetY: number) { console.info(`apply crop offsetX: ${offsetX}, offsetY: ${offsetY}`) console.info("apply crop copy: " + JSON.stringify(this.cropRect)) const newRect: CropRect = this.copyCropRect() switch (type) { case 'left-top': // 计算新的宽度和高度(基于原位置和当前位置的差值) const newWidthLT: number = newRect.width + (newRect.x - offsetX) const newHeightLT: number = newRect.height + (newRect.y - offsetY) // 只有当新尺寸大于最小尺寸时才更新 if (newWidthLT >= this.MIN_CROP_SIZE && newHeightLT >= this.MIN_CROP_SIZE) { newRect.width = newWidthLT newRect.height = newHeightLT newRect.x = offsetX newRect.y = offsetY } else { // 如果小于最小尺寸,则调整到最小尺寸 if (newWidthLT < this.MIN_CROP_SIZE) { newRect.x = newRect.x + newRect.width - this.MIN_CROP_SIZE newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthLT newRect.x = offsetX } if (newHeightLT < this.MIN_CROP_SIZE) { newRect.y = newRect.y + newRect.height - this.MIN_CROP_SIZE newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightLT newRect.y = offsetY } } break case 'right-top': const newWidthRT: number = offsetX - newRect.x const newHeightRT: number = newRect.height + (newRect.y - offsetY) if (newWidthRT >= this.MIN_CROP_SIZE && newHeightRT >= this.MIN_CROP_SIZE) { newRect.width = newWidthRT newRect.height = newHeightRT newRect.y = offsetY } else { if (newWidthRT < this.MIN_CROP_SIZE) { newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthRT } if (newHeightRT < this.MIN_CROP_SIZE) { newRect.y = newRect.y + newRect.height - this.MIN_CROP_SIZE newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightRT newRect.y = offsetY } } break case 'left-bottom': const newWidthLB: number = newRect.width + (newRect.x - offsetX) const newHeightLB: number = offsetY - newRect.y if (newWidthLB >= this.MIN_CROP_SIZE && newHeightLB >= this.MIN_CROP_SIZE) { newRect.width = newWidthLB newRect.height = newHeightLB newRect.x = offsetX } else { if (newWidthLB < this.MIN_CROP_SIZE) { newRect.x = newRect.x + newRect.width - this.MIN_CROP_SIZE newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthLB newRect.x = offsetX } if (newHeightLB < this.MIN_CROP_SIZE) { newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightLB } } break case 'right-bottom': const newWidthRB: number = offsetX - newRect.x const newHeightRB: number = offsetY - newRect.y if (newWidthRB >= this.MIN_CROP_SIZE && newHeightRB >= this.MIN_CROP_SIZE) { newRect.width = newWidthRB newRect.height = newHeightRB } else { if (newWidthRB < this.MIN_CROP_SIZE) { newRect.width = this.MIN_CROP_SIZE } else { newRect.width = newWidthRB } if (newHeightRB < this.MIN_CROP_SIZE) { newRect.height = this.MIN_CROP_SIZE } else { newRect.height = newHeightRB } } break } // 应用宽高比约束 if (this.selectedAspectRatio !== 'free') { this.applyAspectRatioToRect(newRect) } // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect } // 处理边控制点拖拽 - 优化实时同步 private handleEdgeControlPointDrag(type: string, offsetX: number, offsetY: number) { const newRect: CropRect = this.copyCropRect() switch (type) { case 'top': newRect.height += newRect.y - offsetY newRect.y = offsetY break case 'right': newRect.width = offsetX - newRect.x break case 'bottom': newRect.height = offsetY - newRect.y break case 'left': newRect.width += newRect.x - offsetX newRect.x = offsetX break } // 应用约束并一次性更新状态 this.applyCropConstraints(newRect) this.cropRect = newRect } // 应用裁剪框约束 - 确保不超出图片范围 private applyCropConstraints(rect: CropRect) { console.info("apply crop before: " + JSON.stringify(rect)) // 获取图片显示区域 const imageDisplayRect: CropRect = this.getImageDisplayRect() // 确保最小尺寸 rect.width = Math.max(this.MIN_CROP_SIZE, rect.width) rect.height = Math.max(this.MIN_CROP_SIZE, rect.height) // 确保裁剪框在图片显示区域内 rect.x = Math.max(imageDisplayRect.x, Math.min(imageDisplayRect.x + imageDisplayRect.width - rect.width, rect.x)) rect.y = Math.max(imageDisplayRect.y, Math.min(imageDisplayRect.y + imageDisplayRect.height - rect.height, rect.y)) // 确保裁剪框在容器范围内 rect.x = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.width, rect.x)) rect.y = Math.max(0, Math.min(this.CONTAINER_SIZE - rect.height, rect.y)) // 确保裁剪框尺寸不超过图片显示区域 rect.width = Math.min(rect.width, imageDisplayRect.width) rect.height = Math.min(rect.height, imageDisplayRect.height) // 确保控制点可见 const controlPointMargin = this.CONTROL_POINT_SIZE / 2; rect.x = Math.max(controlPointMargin, rect.x) rect.y = Math.max(controlPointMargin, rect.y) rect.width = Math.max(this.MIN_CROP_SIZE, Math.min(rect.width, this.CONTAINER_SIZE - rect.x - controlPointMargin)) rect.height = Math.max(this.MIN_CROP_SIZE, Math.min(rect.height, this.CONTAINER_SIZE - rect.y - controlPointMargin)) console.info("apply crop end: " + JSON.stringify(rect)) } // 应用宽高比到矩形 private applyAspectRatioToRect(rect: CropRect) { if (this.selectedAspectRatio !== 'free') { const ratioParts: string[] = this.selectedAspectRatio.split(':') const widthRatio: number = parseInt(ratioParts[0]) const heightRatio: number = parseInt(ratioParts[1]) const targetRatio: number = widthRatio / heightRatio const currentRatio: number = rect.width / rect.height if (currentRatio > targetRatio) { rect.height = rect.width / targetRatio } else { rect.width = rect.height * targetRatio } } } // 实现图片保存功能 private async saveCroppedImage() { this.isSaving = true; try { // 1. 创建裁剪后的图片 const croppedImageUri: string = await this.createCroppedImage(); // 2. 回调通知保存成功 this.onSave(croppedImageUri); promptAction.showToast({ message: '图片已保存到相册', duration: 2000 }); } catch (error) { console.error('保存图片失败: ' + error); promptAction.showToast({ message: '保存失败,请重试', duration: 2000 }); } finally { this.isSaving = false; } } // 创建裁剪后的图片 private async createCroppedImage(): Promise<string> { return new Promise(async (resolve, reject) => { try { // 1. 创建图片源 const sourceFile: fs.File = await fs.open(this.imageSrc, fs.OpenMode.READ_ONLY); const imageSource: image.ImageSource = image.createImageSource(sourceFile.fd); // 2. 获取图片信息 const imageInfo: image.ImageInfo = await imageSource.getImageInfo(); console.log('原始图片信息: ' + JSON.stringify(imageInfo)); // 3. 计算实际裁剪区域 const imageDisplayRect: CropRect = this.getImageDisplayRect() // 计算裁剪区域相对于图片显示区域的比例 const relativeX: number = (this.cropRect.x - imageDisplayRect.x) / imageDisplayRect.width const relativeY: number = (this.cropRect.y - imageDisplayRect.y) / imageDisplayRect.height const relativeWidth: number = this.cropRect.width / imageDisplayRect.width const relativeHeight: number = this.cropRect.height / imageDisplayRect.height // 转换为实际图片坐标 const actualCropX: number = Math.max(0, relativeX * imageInfo.size.width) const actualCropY: number = Math.max(0, relativeY * imageInfo.size.height) const actualCropWidth: number = Math.min( relativeWidth * imageInfo.size.width, imageInfo.size.width - actualCropX ) const actualCropHeight: number = Math.min( relativeHeight * imageInfo.size.height, imageInfo.size.height - actualCropY ) console.log(`实际裁剪区域: x=${actualCropX}, y=${actualCropY}, width=${actualCropWidth}, height=${actualCropHeight}`); // 4. 创建解码选项,设置裁剪区域 const decodingOptions: image.DecodingOptions = { desiredSize: { width: actualCropWidth, height: actualCropHeight }, desiredRegion: { size: { width: actualCropWidth, height: actualCropHeight }, x: actualCropX, y: actualCropY }, rotate: 0 }; // 5. 创建图片打包器 const imagePacker: image.ImagePacker = image.createImagePacker(); // 6. 解码并打包图片 const pixelMap: image.PixelMap = await imageSource.createPixelMap(decodingOptions); const packOpts: image.PackingOption = { format: "image/jpeg", quality: 100 }; const arrayBuffer: ArrayBuffer = await imagePacker.packing(pixelMap, packOpts); // 7. 保存到沙箱 const sandboxPath: string = this.context.filesDir; const timestamp: number = new Date().getTime(); const outputPath: string = `${sandboxPath}/cropped_${timestamp}.jpg`; const file: fs.File = await fs.open(outputPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); await fs.write(file.fd, arrayBuffer); await fs.close(file.fd); // 8. 保存到相册 await this.saveToPhotoAlbum(arrayBuffer); console.log('图片已保存到沙箱: ' + outputPath); // 9. 释放资源 imageSource.release(); pixelMap.release(); imagePacker.release(); resolve(outputPath); } catch (error) { console.error('创建裁剪图片失败: ' + JSON.stringify(error)); reject(error); } }); } // 保存图片到相册 private async saveToPhotoAlbum(buffer: ArrayBuffer): Promise<void> { if (!this.phAccessHelper) { throw new Error('Photo access helper not initialized'); } try { // 创建相册文件 let helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context); let uri: string = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png'); let file: fs.File = await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); // Write to file await fs.write(file.fd, buffer); // Close the file await fs.close(file.fd); console.log('保存到相册成功: ' + uri); } catch (error) { console.error('保存到相册失败: ' + error); } } } 4、方案成果总结(一)功能完备性裁剪区域精准控制:支持以图片实际显示尺寸为基准的任意区域裁剪,裁剪区域范围可通过边界约束算法灵活配置,适配头像裁剪、图片编辑等多场景;手势操作丰富多样:通过拖拽等多种手势分别控制裁剪区域位置与大小,用户可快速完成复杂裁剪操作,减少误操作。(二)交互与体验优化原生交互复用:基于鸿蒙原生手势识别逻辑,操作流畅度与系统组件一致;实时反馈清晰:裁剪区域展示与手势操作同步更新,操作结束实时显示裁剪结果,用户可实时掌握裁剪状态;边界控制严谨:通过边界约束算法限制裁剪区域范围,避免超出图片显示范围,减少异常场景。(三)代码可维护性模块化封装:基于鸿蒙原生组件封装,组件内部处理手势识别与裁剪区域同步,对外仅暴露参数与回调,与业务逻辑解耦,可直接复用于不同场景;扩展性强:新增场景(如固定比例裁剪)仅需修改相关参数,无需重构核心代码;组件复用性高:图片裁剪组件可独立使用,便于集成到其他应用模块中。
-
1、关键技术难点总结1.1 问题说明在鸿蒙应用开发中,页面路由是连接不同功能模块的核心机制。特别是在开发包含多个功能模块的复杂应用时,如何实现高效、灵活的页面跳转成为了一个关键问题。在开发一个包含多个功能模块的鸿蒙应用时,大多情况下会遇到如下问题:如何在主模块(entry)与子模块之间进行页面跳转如何实现模块间的解耦,避免硬编码依赖如何统一管理静态路由配置和动态路由注册1.2 原因分析鸿蒙传统的页面跳转方式无法直接跨越模块边界各个功能模块独立开发,缺乏统一的路由协调机制静态路由配置文件分散在各个模块中,难以集中管理和维护2、解决思路支持静态路由配置和动态路由注册,满足不同场景的需求提供统一的页面跳转接口,统一路由管理实现跨模块的页面跳转能力,打破模块间壁垒支持参数传递和生命周期管理,确保数据正确流转3、解决方案3.1 方案设计路由配置层:负责定义和管理路由映射关系路由管理层:提供路由注册、查找和跳转功能页面跳转层:封装具体的页面跳转逻辑3.2 功能实现主要代码1. 静态路由配置router_map.json{ "routerMap": [ { "name": "settings", "pageSourceFile": "src/main/ets/pages/Settings.ets", "buildFunction": "SettingsBuilder", "data": { "description": "this is settings" } }, { "name": "LanguageSettings", "pageSourceFile": "src/main/ets/pages/LanguageSettings.ets", "buildFunction": "LanguageSettingsBuilder", "data": { "description": "this is LanguageSettings" } } ] } module.json5{ "module": { "name": "entry", "type": "entry", "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ "phone" ], ... "routerMap": "$profile:router_map", ... } } 2. 主页面实现Index.etsimport { RouterUtils } from "router/src/main/ets/utils/RouterUtils"; import { RouterConstant } from "router/src/main/ets/utils/RouterConstant"; interface MailParam { name: string; address: string } @Entry @Component struct Index { pathStack: NavPathStack = new NavPathStack(); // 静态路由 childPathStack: NavPathStack = new NavPathStack(); // 二级静态路由 @State hapARouter: NavPathStack = new NavPathStack(); // 动态路由 controller: TabsController = new TabsController(); aboutToAppear() { if (!this.hapARouter) { this.hapARouter = new NavPathStack(); }; RouterUtils.createRouter(RouterConstant.ROUTER_ENTRY, this.hapARouter); }; @Builder routerMap(builderName: string, param: object) { RouterUtils.getBuilder(builderName).builder(param); }; build() { Navigation(this.pathStack) { Column() { Row() { Image($r('app.media.Settings')) .width(40) .height(40) .margin({ right: 8 }) .onClick(() => { this.pathStack.pushPathByName('settings', null) }) Text('设置') .fontSize(24) .fontWeight(700) .lineHeight(32) .onClick(() => { this.pathStack.pushPathByName('settings', null) }) Blank() Image($r('app.media.icon_add')) .width(40) .height(40) } .padding({ left: 16, right: 16, top: 20 }) .backgroundColor('#FFFFFF') .width('100%') .alignItems(VerticalAlign.Center) .justifyContent(FlexAlign.SpaceAround) Tabs({controller: this.controller, barPosition: BarPosition.End}) { TabContent() { Column() { Text('动态路由') .fontSize(30) Navigation(this.hapARouter) { Column() { Button("to_harTest_mainPage", { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { RouterUtils.push("harTest", RouterConstant.ROUTER_ENTRY, "./src/main/ets/components/MainPage", "HarTest_PageMain_Builder"); }) } .height('100%') .width('100%') } .navDestination(this.routerMap); } }.tabBar('消息') TabContent() { Column() { Text('系统路由') .fontSize(30) Navigation(this.childPathStack) { Column() { Button('邮件设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. const mailParam: MailParam = { name: 'test', address: 'mail.com' } this.childPathStack.pushPathByName('MailIndex', mailParam ) }) } .height('100%') .width('100%') } //.hideTitleBar(true) //.title('邮件设置') // .titleMode(NavigationTitleMode.Mini) // .hideBackButton(true) } }.tabBar('邮件') TabContent() { Column() { Text('我的') .fontSize(30) Button('设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. //this.pageInfos.clear(); this.pathStack.pushPathByName('settings', null) }) } }.tabBar('我的') } .width('100%') .height('92%') .scrollable(false) } .height('100%') .width('100%') } .title($r('app.string.app_name')) .hideTitleBar(true) } } Settings.ets@Builder export function SettingsBuilder(name: string, param: Object) { Settings() } const COLUMN_SPACE: number = 12; @Component export struct Settings { pageInfos: NavPathStack = new NavPathStack(); build() { NavDestination() { Column({ space: COLUMN_SPACE }) { Text('系统设置') .fontSize(30) Button('语言设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. this.pageInfos.pushPathByName('LanguageSettings', null) }) Button('邮件设置', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { //Clear all pages in the stack. this.pageInfos.pushPathByName('MailIndex', null) }) } .justifyContent(FlexAlign.End) } //.title('entry-pageOne') .onReady((context: NavDestinationContext) => { this.pageInfos = context.pathStack; console.info("current page config info is " + JSON.stringify(context.getConfigInRouteMap())); }) } } 3. 动态路由管理路由常量定义 (RouterConstant.ets)export class RouterConstant{ static ROUTER_ENTRY = "HapEntry_Router" } 路由工具类 (RouterUtils.ets)export class RouterUtils { static builderMap: Map<string, WrappedBuilder<[object]>> = new Map<string, WrappedBuilder<[object]>>(); static routerMap: Map<string, NavPathStack> = new Map<string, NavPathStack>(); // 通过名称注册builder public static registerBuilder(builderName: string, builder: WrappedBuilder<[object]>): void{ RouterUtils.builderMap.set(builderName, builder); } // 通过名称获取builder public static getBuilder(builderName: string): WrappedBuilder<[object]>{ let builder = RouterUtils.builderMap.get(builderName); return builder as WrappedBuilder<[object]>; } // 通过名称注册router public static createRouter(routerName: string, router: NavPathStack): void{ RouterUtils.routerMap.set(routerName, router); } // 通过名称获取router public static getRouter(routerName: string): NavPathStack{ let router = RouterUtils.routerMap.get(routerName); return router as NavPathStack; } // 通过获取页面栈跳转到指定页面 public static async push(harName: string, routerName: string, path: string, builderName: string): Promise<void>{ // 动态引入要跳转的页面 try { let ns: ESObject = await import(harName); console.info('ns object: ', JSON.stringify(ns)) ns.harInit(path); RouterUtils.getRouter(routerName).pushPathByName(builderName, null); } catch (e) { console.error(JSON.stringify(e)) } } // 通过获取页面栈并将其清空 public static clear(routerName: string): void { // 查找到对应的路由栈进行pop RouterUtils.getRouter(routerName).clear(); } } 4. 动态路由页面MainPage.etsimport { RouterUtils } from "router/src/main/ets/utils/RouterUtils"; import { RouterConstant } from "router/src/main/ets/utils/RouterConstant"; @Builder export function harTestBuilder(value: object) { NavDestination() { Row() { Column() { Text('HarTest') .fontSize($r('app.float.page_text_font_size')) .fontWeight(FontWeight.Bold) Button('返回首页', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .onClick(() => { RouterUtils.clear(RouterConstant.ROUTER_ENTRY) }) Button('to_harTest_page1', { stateEffect: true, type: ButtonType.Capsule }) .width(200) .height(50) .margin(20) .onClick(() => { RouterUtils.push("harTest", RouterConstant.ROUTER_ENTRY, "./src/main/ets/pages/page1", "HarTest_Page1_Builder"); }) } .width('100%') } .height('100%') } //.hideBackButton(true) //.title('harB-pageOne') .onReady((context: NavDestinationContext) => { console.info('current page config info is ' + JSON.stringify(context.getConfigInRouteMap())); }) } let builderName = 'HarTest_PageMain_Builder'; if (!RouterUtils.getBuilder(builderName)) { let builder: WrappedBuilder<[object]> = wrapBuilder(harTestBuilder); RouterUtils.registerBuilder(builderName, builder); } page1.etsimport { RouterUtils } from "router/src/main/ets/utils/RouterUtils"; import { RouterConstant } from "router/src/main/ets/utils/RouterConstant"; @Builder export function harBuilder(value: object) { NavDestination(){ Column(){ Button('返回首页', { stateEffect: true, type: ButtonType.Capsule }) .width('80%') .height(40) .margin(20) .onClick(() => { RouterUtils.clear(RouterConstant.ROUTER_ENTRY) }) Button("to_harTest_mainPage", { stateEffect: true, type: ButtonType.Capsule }) .width('80%') .height(40) .margin(20) .onClick(() => { RouterUtils.push("harTest", RouterConstant.ROUTER_ENTRY, "./src/main/ets/components/MainPage", "HarTest_PageMain_Builder"); }) }.width('100%').height('100%') } .title('HarB_Page1') } let builderName = 'HarTest_Page1_Builder'; if (!RouterUtils.getBuilder(builderName)) { let builder: WrappedBuilder<[object]> = wrapBuilder(harBuilder); RouterUtils.registerBuilder(builderName, builder); } Index.etsexport function harInit(path: string): void { // 动态引入要跳转的页面 switch (path) { case "./src/main/ets/components/MainPage": import("./src/main/ets/components/MainPage"); break; case "./src/main/ets/pages/page1": import("./src/main/ets/pages/page1"); break; default: break; } } 5. 动态import变量表达式配置(在主模块中添加)build-profile.json5{ ... "buildOption": { "arkOptions": { "runtimeOnly": { "sources": [ ], "packages": [ "harTest" ] } } } } 4、方案成果总结统一管理:通过RouterUtils工具类,实现了静态路由和动态路由的统一管理,无论是在entry主模块内部跳转还是跨模块跳转,都使用相同的API接口模块解耦:利用动态import机制和Builder注册机制,实现了模块间的解耦,避免了硬编码依赖,使得各功能模块可以独立开发和维护灵活扩展:支持运行时动态注册路由,新功能模块只需按照规范实现页面和Builder,即可无缝集成到现有路由体系中参数传递:完善了参数传递机制,支持复杂对象在页面间传递,满足了实际业务场景中数据交互的需求
-
1.问题说明:Flutter 原生列表实现下拉、上拉回调加载很费劲,想封装一个基础刷新组件全局使用2.原因分析:目前Flutter使用最多、最流行的网络请求框架是pull_to_refresh: ^2.0.03.解决思路:在Flutter项目中的pubspec.yaml文件中,导入 pull_to_refresh: ^2.0.0,封装刷新组件4.解决方案:一、导入pull_to_refresh: ^2.0.0dependencies: flutter: sdk: flutter pull_to_refresh: ^2.0.0二、封装刷新组件RefreshWidgetimport 'package:flutter/material.dart';import 'package:pull_to_refresh/pull_to_refresh.dart';class RefreshWidget extends StatelessWidget { final Widget child; final Future<void> Function()? onRefresh; final Future<void> Function()? onLoadMore; final bool enablePullDown; final bool enablePullUp; final RefreshController? controller; const RefreshWidget({ Key? key, required this.child, this.onRefresh, this.onLoadMore, this.enablePullDown = true, this.enablePullUp = false, this.controller, }) : super(key: key); @override Widget build(BuildContext context) { return SmartRefresher( enablePullDown: enablePullDown, enablePullUp: enablePullUp, header: const ClassicHeader( idleText: '下拉可以刷新', releaseText: '松开立即刷新', refreshingText: '正在刷新...', completeText: '刷新完成', failedText: '刷新失败', canTwoLevelText: '释放进入二楼', textStyle: TextStyle(color: Colors.grey, fontSize: 14), refreshingIcon: SizedBox( width: 25.0, height: 25.0, child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation<Color>(Colors.grey), strokeWidth: 2.0), ), ), footer: const ClassicFooter( idleText: '上拉加载更多', loadingText: '正在加载...', noDataText: '没有更多数据了', canLoadingText: '松开加载更多', failedText: '加载失败', textStyle: TextStyle(color: Colors.grey, fontSize: 14), ), controller: controller ?? RefreshController(), onRefresh: onRefresh, onLoading: onLoadMore, child: child, ); }}三、使用样例,此为部分代码1.RefreshWidget组件的使用RefreshWidget( controller: _viewModel.refreshController, onRefresh: () => _viewModel.onRefresh(), onLoadMore: () => _viewModel.onLoadMore(), enablePullDown: true, enablePullUp: true, child: CustomScrollView( physics: AlwaysScrollableScrollPhysics(), shrinkWrap: true, slivers: _viewModel.groups.asMap().entries.map((e) { return SliverMainAxisGroup( slivers: [ SliverPersistentHeader( delegate: HelpCategoryHeaderDelegate(_viewModel, e.value, e.key), ), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return HelpCategoryItem(_viewModel, e.value, index); }, childCount: e.value.helpContentList?.length, ), ), ], ); }).toList(), ),)2.ViewModel的部门代码import 'package:get/get.dart';import 'package:pull_to_refresh/pull_to_refresh.dart';import '../models/help_category_model.dart';class HelpCategoryViewModel extends GetxController { late RefreshController refreshController = RefreshController(); int pageNum = 1; int pageSize = 10; RxList<HelpRecordModel> groups = <HelpRecordModel>[].obs; // 下拉刷新 onRefresh() { pageNum = 1; loadData(); } // 上拉加载更多 onLoadMore() { pageNum += 1; loadData(); } // 加载网络数据 Future<void> loadData() async { }}四、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1.问题说明:Flutter 原生网络请求很费劲,想封装一个基础网络请求单例全局使用2.原因分析:目前Flutter使用最多、最流行的网络请求框架是dio: ^5.9.03.解决思路:在Flutter项目中的pubspec.yaml文件中,导入 dio: ^5.9.0,创建网络请求单例使用dio,封装请求方法4.解决方案:一、导入dio: ^5.9.0,在Flutter项目中的pubspec.yaml文件中dependencies: flutter: sdk: flutter dio: ^5.9.0二、创建网络请求单例,BaseRequestimport 'dart:convert';import 'package:dio/dio.dart';import 'package:flutter/cupertino.dart';import 'base_response.dart';void LogDebug(String message) { debugPrint("[DEBUG] $message");}class BaseRequest { static final BaseRequest _instance = BaseRequest._internal(); factory BaseRequest() => _instance; BaseRequest._internal() { _initDio(); } late Dio _dio; static const String baseUrl = "https://www.baidu.com/"; void _initDio() { _dio = Dio(); _dio.options.baseUrl = baseUrl; _dio.options.connectTimeout = const Duration(seconds: 10); _dio.options.receiveTimeout = const Duration(seconds: 10); _dio.options.headers["Content-Type"] = "application/json"; _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { // 用户登录接口返回的token final token = ''; if (token != null) { options.headers["Authorization"] = "Bearer $token"; } // 判断是否是上传文件接口,修改请求头 final isUpload = options.headers.containsKey("isUpload") ? options.headers["isUpload"] as bool : false; if (isUpload) { options.headers["Content-Type"] = 'multipart/form-data'; } return handler.next(options); }, onResponse: (response, handler) { LogDebug("requestUrl: ${response.requestOptions.uri}"); LogDebug( "requestHeaders: ${json.encode(response.requestOptions.headers)}"); LogDebug("requestBody: ${json.encode(response.requestOptions.data)}"); LogDebug( "requestParams: ${json.encode(response.requestOptions.queryParameters)}"); LogDebug("responseData: ${json.encode(response.data)}"); // 相应数据转全局基础BaseResponse对象(Model) final baseResponse = BaseResponse.fromJson(response.data as Map<String, dynamic>); response.data = baseResponse; return handler.next(response); }, onError: (DioException e, handler) { LogDebug("requestError: ${e.message}"); return handler.next(e); }, )); } // PUT 请求 Future<BaseResponse?> putRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.put( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("PUT 失败: $e"); return null; } } // GET 请求 Future<BaseResponse?> getRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.get( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("GET 失败: $e"); return null; } } // POST 请求 Future<BaseResponse?> postRequest( String path, { dynamic data, Map<String, dynamic>? queryParams, }) async { try { final response = await _dio.post( path, data: data, queryParameters: queryParams, ); return response.data as BaseResponse; } catch (e) { LogDebug("POST 失败: $e"); return null; } } // 文件上传 Future<BaseResponse?> postFileUpload( String path, { required FormData formData, }) async { try { final response = await _dio.post( path, data: formData, options: Options(headers: {"isUpload": true}), ); return response.data as BaseResponse; } catch (e) { LogDebug("POST Upload 失败: $e"); return null; } }}三、全局基础相应Model,BaseResponseclass BaseResponse<T> { int? timestamp; int? code; String? msg; T? data; Map<String, dynamic>? errorData; bool? success; BaseResponse({ this.timestamp, this.code, this.msg, this.data, this.errorData, this.success, }); factory BaseResponse.fromJson(Map<String, dynamic> json) { return BaseResponse( timestamp: json['timestamp'] as int?, code: json['code'] as int?, msg: json['msg'] as String?, data: json['data'] as T?, errorData: json['errorData'] as Map<String, dynamic>?, success: json['success'] as bool?, ); } Map<String, dynamic> toJson() { final data = <String, dynamic>{}; data['timestamp'] = timestamp; data['code'] = code; data['msg'] = msg; data['data'] = this.data; data['errorData'] = errorData; data['success'] = success; return data; }}四,请求类Api:RequestApiimport 'dart:io';import 'package:dio/dio.dart';import '../base/base_request.dart';import '../base/base_response.dart';class RequestApi { static final BaseRequest _baseRequest = BaseRequest(); // put请求传requestBody static Future<BaseResponse?> putBody( Map<String, dynamic> body, ) async { return await _baseRequest.putRequest( "", // 接口相对路径 data: body, ); } // put请求传queryParams static Future<BaseResponse?> putParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.putRequest( "", // 接口相对路径 queryParams: queryParams, ); } // get请求传requestBody static Future<BaseResponse?> getBody( Map<String, dynamic> body, ) async { return await _baseRequest.getRequest( "", // 接口相对路径 data: body, ); } // get请求传queryParams static Future<BaseResponse?> getParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.getRequest( "", // 接口相对路径 queryParams: queryParams, ); } // post请求传requestBody static Future<BaseResponse?> postBody( Map<String, dynamic> body, ) async { return await _baseRequest.postRequest( "", // 接口相对路径 data: body, ); } // post请求传queryParams static Future<BaseResponse?> postParams( Map<String, dynamic> queryParams, ) async { return await _baseRequest.postRequest( "", // 接口相对路径 queryParams: queryParams, ); } // post上传文件传formData static Future<BaseResponse?> postFileUpload( String filePath, ) async { File fileClass = File(filePath); String fileName = fileClass.path.split('/').last; MultipartFile multipartFile = await MultipartFile.fromFile( filePath, filename: fileName, contentType: DioMediaType.parse("application/octet-stream"), ) as MultipartFile; // 具体的文件类型,请根据自己公司要求来 FormData formData = FormData.fromMap({ 'file': multipartFile, 'type': '0', }); return await _baseRequest.postFileUpload( "", // 接口相对路径 formData: formData, ); }}五、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1.1问题说明在鸿蒙应用开发中,实现图片水印功能时面临以下核心问题:如何在鸿蒙生态下高效加载图片资源并转换为可编辑的像素地图(PixelMap),确保图片处理的基础能力;如何在图片上绘制文字水印,并支持水印位置的实时拖拽调整,兼顾交互性与显示准确性;如何处理不同设备的分辨率差异,保证水印在各类设备上的显示效果一致;如何安全、合规地将带水印的图片保存到系统媒体库,同时避免资源泄露。1.2原因分析(一)图片处理依赖鸿蒙自带工具:加载图片、创建可编辑的图片源、生成能修改的图片格式等操作,都要用到鸿蒙自带的工具。如果对这些工具的加载顺序(比如没等图片加载完就操作)或参数设置(比如图片尺寸配置)处理不好,就可能导致图片加载失败,或者没法添加水印。(二)拖动水印时需要频繁重绘图片:当拖动水印时,程序会实时更新水印的位置,并重新生成带水印的图片。每次拖动都要重新画一遍原图和水印,如果设备性能一般,或者画图的逻辑不够高效,就可能让界面变卡,拖动起来不顺畅。(三)不同设备适配要靠单位转换:给图片加水印时,需要通过系统工具把图片尺寸、水印位置转换成适合当前设备的单位,还要获取屏幕宽度来计算缩放比例。如果这些转换工具没准备好(比如工具未正确设置就强制使用)、转换逻辑出错,或者拿不到屏幕信息,水印就会出现位置偏斜、大小不对的问题。1.3解决思路(一)图片资源处理:借助鸿蒙的图片处理工具,将原始图片资源转换成可编辑的格式(包含图片本身及尺寸信息)。具体来说,先获取图片的原始数据,再生成可编辑的图片源,同时记录图片的宽高,为后续添加水印打好基础,避免因格式不兼容或信息缺失导致水印无法添加。(二)水印绘制与交互:采用分层绘制的方式,先画原始图片作为背景,再在上面绘制水印文字。通过监听触摸动作,实时更新水印的位置坐标,并用状态管理工具记录这些坐标变化,确保每次位置变动后能及时重新绘制带水印的图片,让拖拽调整的操作更流畅。(三)设备适配处理:利用系统自带的单位转换工具(如将物理像素转为虚拟像素),结合当前设备的屏幕信息(如屏幕宽度),计算出合适的缩放比例。通过这种方式,让水印的大小和位置在不同屏幕的设备上都能保持一致,避免出现偏移或失真。1.4解决方案(一)加载图片并转换为可编辑格式,通过专门的工具函数处理图片资源:先借助系统资源管理器,获取原始图片的二进制数据;用图片处理工具将这些数据转换成 “可编辑图片源”,并读取图片的宽度和高度;最终生成包含可编辑图片、宽高信息的结构化数据,存在组件中备用,为后续添加水印提供基础素材。(二)绘制水印并支持拖动调整,用分层绘制的方式添加水印,并通过交互逻辑实现位置调整:用后台绘图工具先画原始图片作为底层,再在上面画水印文字;提前设置水印的样式(比如文字大小、颜色、对齐方式),并结合设备屏幕信息,自动适配不同设备的显示比例;在组件中用状态管理工具记录水印的位置,监听触摸拖动动作:每次拖动时,实时更新水印的 X、Y 坐标,同时重新调用绘图工具生成新的带水印图片,让界面及时刷新,实现 “拖到哪,水印就显示在哪” 的效果。(三)保存带水印的图片到相册,通过系统工具完成图片保存,同时做好资源管理:先用相册管理工具在手机相册里创建一个新的图片文件,拿到保存路径;用图片打包工具将带水印的可编辑图片,转换成适合保存的格式(比如 PNG);用文件操作工具打开刚创建的文件,把转换好的图片数据写进去,写完后不管有没有出错,都强制关闭文件,避免占用设备资源。(四)统一管理功能和状态,通过控制器封装核心功能,确保组件运行稳定:专门设计一个 “水印控制器”,把添加水印、重置位置、获取带水印图片等功能集中起来,对外提供简单的操作接口;在组件初始化时,就把控制器和界面上下文(比如设备显示信息)绑定好,确保原始图片加载、水印位置等状态能实时同步;用状态管理工具统一记录图片、水印位置等关键数据,避免因数据没准备好就操作而出现错误。图片水印组件代码示例:import { image } from '@kit.ImageKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { addWatermark, ImagePixelMap, imageSource2PixelMap } from './Utils'; const TAG = 'WatermarkComponent'; export class WatermarkController { setUIContext = (uiContext: UIContext) => { } addWatermark = () => { } resetWatermarkPosition = () => { } getWatermarkedPixelMap = (): image.PixelMap | null => { return null; } } @Component export struct WatermarkComponent { watermarkController: WatermarkController = new WatermarkController(); // 组件入参 @Prop bgImage: Resource; // 背景图片资源 @Prop watermarkText: string; // 水印文字 @Prop watermarkSize: number = 16; // 水印文字大小 @Prop watermarkColor: string = '#A2ffffff'; // 水印颜色 @Prop initialX: number = 0; // 初始X坐标 @Prop initialY: number = 50; // 初始Y坐标 // 内部状态 @State addedWatermarkPixelMap: image.PixelMap | null = null; @State watermarkX: number = 0; @State watermarkY: number = 0; @State isDragging: boolean = false; private originalImagePixelMap: ImagePixelMap | null = null; private uiContext: UIContext | null = null; aboutToAppear() { this.watermarkX = this.initialX; this.watermarkY = this.initialY; this.watermarkController.setUIContext = (uiContext: UIContext) => { this.setUIContext(uiContext); }; this.watermarkController.addWatermark = () => { this.addWatermark(); }; this.watermarkController.resetWatermarkPosition = () => { this.resetWatermarkPosition(); }; this.watermarkController.getWatermarkedPixelMap = (): image.PixelMap | null => { return this.getWatermarkedPixelMap(); }; } /** * 设置UIContext */ setUIContext(uiContext: UIContext) { this.uiContext = uiContext; } /** * 获取水印文字 */ private getWatermarkText(): string { return this.watermarkText; } /** * 从资源获取图片PixelMap */ async getImagePixelMap(): Promise<ImagePixelMap | undefined> { let result: ImagePixelMap | undefined = undefined; try { const data: Uint8Array = await this.uiContext?.getHostContext()?.resourceManager.getMediaContent(this.bgImage.id) as Uint8Array; const arrayBuffer: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteLength + data.byteOffset); const imageSource: image.ImageSource = image.createImageSource(arrayBuffer); result = await imageSource2PixelMap(imageSource); if (result) { this.originalImagePixelMap = result; } } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `getImagePixelMap failed code=${err.code}, message=${err.message}`); } return result; } /** * 处理触摸移动事件 */ private handleTouchMove(event: TouchEvent) { if (this.originalImagePixelMap && event.touches.length > 0) { const touch = event.touches[0]; this.watermarkX = touch.x; this.watermarkY = touch.y; // 重新生成带水印的图片 this.addedWatermarkPixelMap = addWatermark( this.originalImagePixelMap, this.getWatermarkText(), this.uiContext!, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } /** * 添加水印 */ async addWatermark(): Promise<void> { const imagePixelMap = await this.getImagePixelMap(); if (imagePixelMap && this.uiContext) { this.addedWatermarkPixelMap = addWatermark( imagePixelMap, this.getWatermarkText(), this.uiContext, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } /** * 获取带水印的PixelMap */ getWatermarkedPixelMap(): image.PixelMap | null { return this.addedWatermarkPixelMap; } /** * 重置水印位置 */ resetWatermarkPosition(): void { this.watermarkX = this.initialX; this.watermarkY = this.initialY; if (this.originalImagePixelMap) { this.addedWatermarkPixelMap = addWatermark( this.originalImagePixelMap, this.getWatermarkText(), this.uiContext!, this.watermarkX, this.watermarkY, this.watermarkSize, this.watermarkColor ); } } build() { Column() { // 显示带水印的图片 Image(this.addedWatermarkPixelMap || this.bgImage) .width('100%') .onTouch((event: TouchEvent) => { if (event.type === TouchType.Move && this.addedWatermarkPixelMap) { this.handleTouchMove(event); } }) } .width('100%') } } 图片水印工具类代码示例:import { image } from '@kit.ImageKit'; import { fileIo } from '@kit.CoreFileKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { display } from '@kit.ArkUI'; import { Context } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'Utils'; let fd: number | null = null; export async function saveToFile(pixelMap: image.PixelMap, context: Context): Promise<void> { try { const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context); const filePath = await phAccessHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png'); const imagePacker = image.createImagePacker(); const imageBuffer = await imagePacker.packToData(pixelMap, { format: 'image/png', quality: 100 }); const mode = fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE; fd = (await fileIo.open(filePath, mode)).fd; await fileIo.truncate(fd); await fileIo.write(fd, imageBuffer); } catch (err) { hilog.error(0x0000, TAG, 'saveToFile error:', JSON.stringify(err) ?? ''); } finally { try { if (fd) { fileIo.close(fd); } } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `close failed code=${err.code}, message=${err.message}`); } } } export interface ImagePixelMap { pixelMap: image.PixelMap width: number height: number } export async function imageSource2PixelMap(imageSource: image.ImageSource): Promise<ImagePixelMap> { const imageInfo: image.ImageInfo = await imageSource.getImageInfo(); const height = imageInfo.size.height; const width = imageInfo.size.width; const options: image.DecodingOptions = { editable: true, desiredSize: { height, width } }; const pixelMap: image.PixelMap = await imageSource.createPixelMap(options); const result: ImagePixelMap = { pixelMap, width, height }; return result; } export function addWatermark( imagePixelMap: ImagePixelMap, text: string = 'watermark', uiContext: UIContext, x: number = 20, y: number = 20, fontSize: number = 16, color: string = '#A2ffffff' ): image.PixelMap { const height = uiContext.px2vp(imagePixelMap.height) as number; const width = uiContext.px2vp(imagePixelMap.width) as number; const offScreenCanvas = new OffscreenCanvas(width, height); const offScreenContext = offScreenCanvas.getContext('2d'); // 先绘制原始图片 offScreenContext.drawImage(imagePixelMap.pixelMap, 0, 0, width, height); // 设置水印样式 let displayWidth: number = 0; try { displayWidth = display.getDefaultDisplaySync().width; } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `failed code=${err.code}, message=${err.message}`); } const vpWidth = uiContext?.px2vp(displayWidth) ?? displayWidth; const imageScale = width / vpWidth; offScreenContext.textBaseline = 'top' offScreenContext.textAlign = 'left'; offScreenContext.fillStyle = color; offScreenContext.font = fontSize * imageScale + 'vp'; // 使用传入的坐标绘制水印 offScreenContext.fillText(text, x, y); return offScreenContext.getPixelMap(0, 0, width, height); } 演示代码示例:import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { saveToFile } from './Utils'; import { WatermarkComponent, WatermarkController } from './WatermarkComponent'; const TAG = 'Index'; @Entry @Component struct Index { static readonly TOAST_DURATION: number | undefined = 3000; watermarkController: WatermarkController = new WatermarkController(); showSuccess() { try { this.getUIContext().getPromptAction().showToast({ message: $r('app.string.message_save_success'), duration: Index.TOAST_DURATION }); } catch (e) { let err = e as BusinessError; hilog.error(0x0000, TAG, `showToast failed code=${err.code}, message=${err.message}`); } } build() { Column() { // 水印组件 WatermarkComponent({ watermarkController: this.watermarkController, bgImage: $r('app.media.img1'), watermarkText: '水印的文案内容', watermarkSize: 22, watermarkColor: '#FFFFFFFF', initialX: 0, initialY: 0 }) .width('100%') .id("waterMark") .margin({ top: 16 }) .onAppear(() => { // 设置UIContext this.watermarkController.setUIContext(this.getUIContext()); }) // 操作按钮区域 Row() { Button('添加水印') .height(40) .width('45%') .margin({ right: 10 }) .onClick(async () => { await this.watermarkController.addWatermark(); }) Button('重置位置') .height(40) .width('45%') .onClick(() => { this.watermarkController.resetWatermarkPosition(); }) } .width('100%') .justifyContent(FlexAlign.Center) .margin({ top: 16 }) // 保存按钮 Row() { SaveButton() .height(40) .width('100%') .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => { if (result === SaveButtonOnClickResult.SUCCESS) { try { const watermarkedPixelMap = this.watermarkController.getWatermarkedPixelMap(); if (watermarkedPixelMap) { await saveToFile(watermarkedPixelMap, this.getUIContext().getHostContext() as common.UIAbilityContext); this.showSuccess(); } else { hilog.error(0x0000, TAG, 'No watermarked image to save'); } } catch (err) { hilog.error(0x0000, TAG, 'createAsset failed, error:', err); } } else { hilog.error(0x0000, TAG, 'SaveButtonOnClickResult createAsset failed'); } }) } .padding({ left: 16, right: 16, bottom: 16 }) .width('100%') } .width('100%') .height('100%') .justifyContent(FlexAlign.SpaceBetween) } } 1.5方案成果总结这个方案通过适配鸿蒙系统的特点,成功实现了图片水印的完整功能,效果如下:功能齐全:支持加载图片、添加水印、拖动调整位置、保存到相册等所有核心操作,满足用户对图片加水印的需求;适配各种设备:通过处理屏幕差异和单位转换,让水印在不同大小的鸿蒙设备上都显示准确,不会偏位或大小失真;操作流畅且安全:用后台绘图工具高效绘制图片,结合状态管理减少不必要的重复绘制;通过规范的资源释放流程,避免手机资源浪费和图片损坏;方便复用:打包的工具和控制器可以在其他功能模块里直接使用,降低开发成本。
-
1.1问题说明在做鸿蒙系统的应用时,很多场景需要用到签名功能(比如签电子合同、确认表单信息),但鸿蒙自带的开发工具里没有专门的签名工具,自己做的时候会遇到不少麻烦:一是手指 touch 屏幕时,画出来的线经常对不上位置,还容易断;二是画多笔后,想擦除或撤销最后一笔经常出问题;三是导出签名图片时,有的格式用不了,或者导出慢、画面卡;四是想改签名的样式(比如线的粗细、颜色、背景色)特别麻烦,得改很多代码;五是不知道签名什么时候开始、什么时候画完,没法跟其他功能(比如 “签名完才能提交”)联动。1.2原因分析(一)自带工具功能不够:鸿蒙的开发工具里只有基础的画图板功能,没有封装好的签名相关工具,比如怎么处理笔画、怎么导出图片,都得自己从零做,难度大。(二)触摸位置算不准:手机、平板的屏幕大小不一样,签名区域的实际显示大小和我们设置的大小可能不一样。如果没先弄清楚签名区域的真实位置(在哪块地方)和尺寸(宽高),直接用手指 touch 的原始位置来画,线就会偏到别的地方。(三)画面处理太卡:如果直接在正在显示的签名板上导出图片,容易让界面卡住;而且没找对画面处理的方法,导出的图片可能是空白的,或者显示不正常。(四)笔画数据没管好:画多笔后,这些笔画的保存、删除、恢复没有统一的记录方式,比如想擦除所有笔画却擦不干净,想撤销最后一笔却撤不了,操作起来乱糟糟的。1.3解决思路核心就是基于鸿蒙的基础画图板,做一个 “能随便调样式、在不同设备都能用、用着不卡” 的签名工具,具体办法如下:(一)统一记录方式:规定好 “每一笔的位置”“签名区域的大小”“导出图片的格式” 这些信息怎么记,让数据处理有条理,解决笔画管理乱的问题;(二)精准算位置:等签名区域显示出来后,先弄清楚它的真实位置和大小,再统一计算手指 touch 的位置,确保画的线和手指动的轨迹一致;(三)分开处理画面:用 “正在显示的画板实时画 + 临时画板专门导出图片” 的方式,不让界面卡住,导出图片也更快;(四)功能拆分开:把 “画所有笔画”“画正在画的笔画”“擦除笔画”“导出图片” 这些功能分开做,互不影响,出问题好调整;(五)灵活可调:开放一些设置项(比如签名区域大小、背景色、线的粗细),也能让人知道签名的状态(开始 / 画画中 / 结束),方便适配不同场景。1.4解决方案该自定义签名板组件以 “统一规范、灵活适配、高效稳定” 为核心,通过结构化设计与模块化实现满足鸿蒙应用的签名需求,具体如下:(一)整体设计规范:明确数据记录规则,统一笔画坐标(x/y 位置)、签名区域信息(位置、尺寸、就绪状态)的存储格式,限定 PNG、JPEG、WEBP 三种图片导出格式;同时开放灵活配置项,支持自定义签名区域大小、背景色(含透明)、线条粗细与颜色,默认参数保障基础适配性;并提供签名板就绪、签名开始 / 进行中 / 结束的状态监听机制,确保与外部业务的联动兼容性。(二)核心功能落地:先初始化平滑画线工具,用双列表分别管理已完成笔画和当前绘制笔画;绘制时采用 “全量绘制(统一样式后批量渲染历史笔画)+ 实时绘制(仅更新最新线段)” 结合的方式,兼顾显示效果与流畅度;笔画管理支持全量擦除、撤销最后一笔操作,同时可查询笔画数量和签名板空状态;图片导出通过临时画板离线处理,避免阻塞主界面,异常时及时反馈失败信息;触摸处理上,首次触摸获取签名区域真实参数,按 “按下(开始记录)、移动(实时绘制)、抬起(保存有效笔画)” 的逻辑响应动作,排除误触干扰,确保笔画与触摸轨迹精准匹配。1、组件化代码示例:interface DrawingPoint { xPos: number; yPos: number; } interface CanvasDimensions extends Area { hasBeenSetup: boolean; } type ImageOutputType = "image/png" | "image/jpeg" | "image/webp"; @Component export struct CustomSignaturePad { @Prop panelWidth: Length = "100%"; @Prop panelHeight: Length = "100%"; @Prop backgroundStyle: string = "#ffffff"; @Prop lineThickness: number = 2; @Prop inkColor: string = "#1a1a1a"; @Prop imageType: ImageOutputType = "image/png"; onPadReady: (signaturePad: CustomSignaturePad) => void = () => {}; onSignStart: () => void = () => {}; onSigning: () => void = () => {}; onSignFinish: () => void = () => {}; private graphicsConfig: RenderingContextSettings = new RenderingContextSettings(true); private drawingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.graphicsConfig); private savedStrokes: DrawingPoint[][] = []; private currentStroke: DrawingPoint[] = []; private canvasProps: CanvasDimensions = { position: { x: 0, y: 0 }, globalPosition: { x: 0, y: 0 }, width: 3200, height: 2400, hasBeenSetup: false }; private drawAllStrokes(ctx2D: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D = this.drawingContext) { let canvasWidth = Number(this.canvasProps.width); let canvasHeight = Number(this.canvasProps.height); ctx2D.lineCap = "round"; ctx2D.lineWidth = this.lineThickness; ctx2D.strokeStyle = this.inkColor; ctx2D.lineJoin = "round"; ctx2D.clearRect(0, 0, canvasWidth, canvasHeight); if (this.backgroundStyle !== "transparent") { ctx2D.fillStyle = this.backgroundStyle; ctx2D.fillRect(0, 0, canvasWidth, canvasHeight); } if (this.savedStrokes.length === 0) { return; } let signaturePath = new Path2D(); this.savedStrokes.forEach((stroke) => { if (stroke.length === 0) { return; } let firstPoint = stroke[0]; signaturePath.moveTo(firstPoint.xPos, firstPoint.yPos); stroke.forEach((point, idx) => { if (idx === 0) { return; } signaturePath.lineTo(point.xPos, point.yPos); }); }); ctx2D.stroke(signaturePath); } private drawCurrentStroke() { if (this.currentStroke.length === 0) { return; } let drawingContext = this.drawingContext; let strokeLength = this.currentStroke.length; let latestPoint = this.currentStroke[strokeLength - 1]; let previousPoint = this.currentStroke[strokeLength - 2] || latestPoint; drawingContext.beginPath(); drawingContext.moveTo(previousPoint.xPos, previousPoint.yPos); drawingContext.lineTo(latestPoint.xPos, latestPoint.yPos); drawingContext.stroke(); } public eraseAll() { this.savedStrokes = []; this.drawAllStrokes(); } public removeLastLine() { this.savedStrokes.pop(); this.drawAllStrokes(); } public exportSignature(callback: (imageData?: string) => void) { if (this.savedStrokes.length === 0) { callback(); return; } let canvasWidth = Number(this.canvasProps.width); let canvasHeight = Number(this.canvasProps.height); let tempCanvas = new OffscreenCanvas(canvasWidth, canvasHeight); let tempCtx = tempCanvas.getContext("2d", this.graphicsConfig); if (tempCtx) { this.drawAllStrokes(tempCtx); let resultImage = tempCtx.toDataURL(this.imageType); callback(resultImage); } else { callback(); } } public getStrokeCount(): number { return this.savedStrokes.length; } public isEmpty(): boolean { return this.savedStrokes.length === 0; } build() { Canvas(this.drawingContext) .width(this.panelWidth) .height(this.panelHeight) .backgroundColor("#f8f9fa") .borderRadius(8) .border({ width: 1, color: "#e9ecef" }) .onReady(() => { this.drawAllStrokes(); this.onPadReady(this); }) .onTouch((touchEvent) => { if (!this.canvasProps.hasBeenSetup) { let canvasArea = touchEvent.target.area; this.canvasProps.position = canvasArea.position; this.canvasProps.globalPosition = canvasArea.globalPosition; this.canvasProps.width = canvasArea.width; this.canvasProps.height = canvasArea.height; this.canvasProps.hasBeenSetup = true; } let touchData = touchEvent.touches[0]; switch (touchEvent.type) { case TouchType.Down: this.currentStroke = [{ xPos: touchData.x, yPos: touchData.y }]; this.drawCurrentStroke(); this.onSignStart(); break; case TouchType.Move: this.currentStroke.push({ xPos: touchData.x, yPos: touchData.y }); this.drawCurrentStroke(); this.onSigning(); break; case TouchType.Up: if (this.currentStroke.length > 1) { this.savedStrokes.push(this.currentStroke); } this.currentStroke = []; this.onSignFinish(); break; } }); } } 2、演示代码示例:import { promptAction } from '@kit.ArkUI'; import { CustomSignaturePad } from "./CustomSignaturePad"; @Entry @Component struct SignatureExample { @State mySignaturePad: CustomSignaturePad | null = null; @State signaturePreview: string = ''; @State strokeCount: number = 0; build() { Column() { Scroll() { Column() { // 标题区域 Row() { Text("电子签名板") .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor("#1a1a1a") } .width("100%") .justifyContent(FlexAlign.Center) .padding(10) // 签名区域 Row() { CustomSignaturePad({ panelWidth: "95%", panelHeight: 280, backgroundStyle: "#fefefe", lineThickness: 2.5, inkColor: "#2c3e50", onPadReady: (signaturePad: CustomSignaturePad) => { this.mySignaturePad = signaturePad; }, onSignFinish: () => { this.strokeCount = this.mySignaturePad?.getStrokeCount() || 0; } }) } .justifyContent(FlexAlign.Center) .margin({ bottom: 15 }) // 状态显示 Row() { Text(`已绘制笔划: ${this.strokeCount}`) .fontSize(14) .fontColor("#666") } .justifyContent(FlexAlign.Start) .width("95%") .margin({ bottom: 10 }) // 操作按钮区域 Row() { Button("撤销最后一笔") .fontSize(14) .backgroundColor("#6c757d") .fontColor("#ffffff") .borderRadius(6) .onClick(() => { this.mySignaturePad?.removeLastLine(); this.strokeCount = this.mySignaturePad?.getStrokeCount() || 0; }) Button("清空签名") .fontSize(14) .backgroundColor("#dc3545") .fontColor("#ffffff") .borderRadius(6) .margin({ left: 8 }) .onClick(() => { this.mySignaturePad?.eraseAll(); this.strokeCount = 0; this.signaturePreview = ''; }) Button("生成签名图") .fontSize(14) .backgroundColor("#28a745") .fontColor("#ffffff") .borderRadius(6) .margin({ left: 8 }) .onClick(() => { this.mySignaturePad?.exportSignature((imgData) => { if (!imgData) { promptAction.showToast({ message: "请先绘制签名", duration: 2000 }); return; } this.signaturePreview = imgData; promptAction.showToast({ message: "签名图片已生成", duration: 1500 }); }); }) } .justifyContent(FlexAlign.Center) .width("95%") .margin({ bottom: 20 }) // 预览区域 if (this.signaturePreview) { Column() { Text("签名预览") .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor("#333") .margin({ bottom: 8 }) Image(this.signaturePreview) .backgroundColor("#ffffff") .border({ width: 1, color: "#dee2e6" }) .borderRadius(4) .shadow({ radius: 2, color: "#00000016", offsetX: 1, offsetY: 1 }) } .width("95%") .alignItems(HorizontalAlign.Center) .padding(12) .backgroundColor("#f8f9fa") .borderRadius(8) .margin({ bottom: 15 }) } // 使用说明 Column() { Text("使用说明") .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor("#495057") .margin({ bottom: 6 }) Text("1. 请在上方区域用手指或手写笔签名") .fontSize(12) .fontColor("#6c757d") Text("2. 点击'撤销最后一笔'可删除最近一笔") .fontSize(12) .fontColor("#6c757d") Text("3. 点击'生成签名图'可保存签名") .fontSize(12) .fontColor("#6c757d") } .width("95%") .alignItems(HorizontalAlign.Start) .padding(10) .backgroundColor("#e9ecef") .borderRadius(6) } .width("100%") .alignItems(HorizontalAlign.Center) } .backgroundColor("#ffffff") } .width("100%") .height("100%") .backgroundColor("#f5f5f5"); } } 1.5方案成果总结该自定义签名板组件通过针对性设计,实现了功能、性能、易用性与稳定性的全面保障,具体成果如下:(一)功能完备适配广:覆盖签名全流程需求,支持样式定制(背景、线条属性)、状态监听、笔画管理(擦除、撤销)及多格式图片导出,可满足电子合同、表单确认等各类业务场景。(二)性能流畅兼容性强:采用临时画板离线导出方案,导出速度大幅提升,避免界面卡顿;适配手机、平板等多设备,笔画与触摸轨迹精准匹配,同时支持透明背景及 PNG、JPEG、WEBP 格式,适配不同业务需求。(三)易用高效可复用:通过配置项和状态回调实现灵活定制,无需修改组件内部代码即可适配不同场景;组件可直接复用至多个鸿蒙应用,减少重复开发成本。(四)稳定可靠容错性高:依托统一的笔画数据管理和异常处理机制,擦除、撤销无残留,导出图片无空白异常,正常使用故障概率低,保障业务稳定运行。
-
1. 问题说明(一)输入地址格式多样难解析用户输入的外链地址格式混乱,包含带协议(http:// || https://)、带路径(如https://example.com/path)、纯域名(如example.co.uk)等形式,直接提取域名后缀易出错,导致后续检测失效。(二)非合规后缀存在安全风险未检测的非合规后缀(如.invalid、.malicious)可能指向钓鱼网站、恶意程序下载页,直接跳转会泄露用户信息或导致设备受损,缺乏安全过滤机制。(三)无效地址导致体验不佳用户输入错误后缀(如.con而非.com)时,未提前检测直接跳转,会显示 “无法访问” 页面;无明确错误提示,用户需反复修改输入,操作效率低。2. 原因分析(一)安全防护机制缺失未建立合规域名后缀过滤规则,无法识别 ICANN 未认可的 TLD(顶级域名),导致恶意地址绕过检测;缺乏对多级后缀(如.co.uk)的识别能力,易误判合规地址。(二)地址解析逻辑不足未标准化地址处理流程,无法自动去除协议(http/https)、路径、查询参数等无关信息,提取的域名含冗余内容(如www.example.com/path),导致后缀匹配失败。(三)用户反馈机制断层未针对 “格式错误”“后缀不合规” 等场景设计差异化提示,仅返回通用错误信息,用户无法快速定位问题(如分不清是格式错还是后缀错),增加操作成本。3. 解决思路(一)构建动态合规 TLD 列表基于 ICANN 官方数据源(如 IANA TLD 列表),整理通用顶级域名(gTLD,如.com)、国家顶级域名(ccTLD,如.cn)及多级后缀(如.co.uk),定期通过脚本更新,确保列表时效性。(二)标准化地址解析流程设计 “去协议→去路径→去前缀(www.)” 的解析步骤,将各类输入格式(如带协议、纯域名)统一转换为 “纯域名”(如example.co.uk),为后缀检测提供统一输入。(三)多级后缀优先匹配采用 “最长后缀优先” 策略,如解析example.co.uk时,先匹配.co.uk再匹配.uk,避免多级合规后缀被误判为不合规,提升检测准确率。(四)结果分层处理合规地址自动补全协议(默认 https)并执行跳转;不合规地址返回明确提示(如 “后缀.invalid未在 ICANN 合规列表内”);格式错误地址引导用户修正(如 “请输入正确的网络地址格式”)。4. 解决方案(一)合规 TLD 列表配置整理 ICANN 认可的顶级域名及多级后缀,支持动态更新,核心代码如下:import { BusinessError } from '@kit.BasicServicesKit'; /** * 合规顶级域名(TLD)列表(示例,实际需从ICANN官方数据源定期更新) * 包含:通用顶级域名(gTLD)、国家顶级域名(ccTLD)、多级后缀 */ export const VALID_TLDS: string[] = [ // 通用顶级域名(gTLD) 'com', 'org', 'net', 'edu', 'gov', 'info', 'biz', 'xyz', 'app', 'blog', // 国家顶级域名(ccTLD) 'cn', 'hk', 'tw', 'us', 'uk', 'jp', 'de', 'fr', 'au', // 多级后缀(优先匹配) 'co.uk', 'org.uk', 'ac.uk', // 英国 'co.cn', 'org.cn', 'gov.cn', // 中国 'co.jp', 'or.jp', 'ne.jp' // 日本 ]; /** * 从ICANN官方源更新合规TLD列表(模拟接口,实际需对接权威API) */ export const updateValidTlds = async (): Promise<void> => { try { // 模拟请求ICANN官方TLD数据源 // const response = await http.request('https://data.iana.org/TLD/tlds-alpha-by-domain.txt'); // const newTlds = response.result.split('\n').filter(tld => tld && !tld.startsWith('#')).map(tld => tld.toLowerCase()); // VALID_TLDS.length = 0; // VALID_TLDS.push(...newTlds); console.info('合规TLD列表更新成功'); } catch (err) { console.error(`TLD列表更新失败:${(err as BusinessError).message}`); } }; (二)地址解析工具封装标准化解析输入地址,提取纯域名(去除协议、路径、端口等):/** * 从输入地址中提取纯域名(去除协议、路径、端口、www前缀) * @param input 用户输入的外链地址(如"http://www.example.co.uk/path?query=1") * @returns 纯域名(如"example.co.uk"),失败返回null */ export const extractPureHostname = (input: string): string | null => { if (!input.trim()) return null; let urlStr = input.trim(); try { // 补全缺失的HTTP/HTTPS协议(避免URL构造失败) if (!/^https?:\/\//i.test(urlStr)) { urlStr = `https://${urlStr}`; } const url = new URL(urlStr); // 去除"www."前缀(不影响后缀检测,如"www.example.co.uk"→"example.co.uk") return url.hostname.replace(/^www\./i, ''); } catch (error) { // 捕获无效URL格式(如含特殊字符、端口错误等) logger.error(`地址解析失败:${(error as Error).message}`); return null; } }; (三)多级后缀提取逻辑优先匹配最长合规后缀,避免误判多级域名:import { VALID_TLDS } from './ValidTldConfig'; /** * 从纯域名中提取最长匹配的合规后缀 * @param hostname 纯域名(如"example.co.uk") * @returns 合规后缀(如"co.uk"),无匹配返回null */ export const getMatchedValidTld = (hostname: string): string | null => { if (!hostname || hostname.split('.').length < 2) return null; // 分割域名片段(如"example.co.uk"→["example","co","uk"]) const domainParts = hostname.split('.').filter(part => part); // 从最长片段开始匹配(先试"co.uk",再试"uk") for (let i = 1; i < domainParts.length; i++) { const tldCandidate = domainParts.slice(i).join('.').toLowerCase(); if (VALID_TLDS.includes(tldCandidate)) { return tldCandidate; } } return null; }; (四)合规检测与结果处理整合解析、检测逻辑,实现跳转 / 提示分层处理:import { extractPureHostname } from './AddressParser'; import { getMatchedValidTld } from './TldExtractor'; import { promptAction } from '@kit.ArkUI'; // 鸿蒙提示组件 /** * 外链地址合规检测与结果处理 * @param input 用户输入的外链地址 * @returns 检测结果(含合规状态、提示信息、目标URL) */ export function checkDomainCompliance(input: string): Promise<CompliantInt> { return new Promise((resolve, reject) => { try { // 1. 空输入校验(增加return确保终止执行) if (!input.trim()) { return resolve({ isCompliant: false, message: '请输入有效的外链网络地址' }); } // 2. 提取纯域名(示例实现,需补充具体逻辑) const pureHostname = extractPureHostname(input); promptAction.showToast({ message: '输入地址格式无效,请检查(如含特殊字符、错误端口)', duration: 2000 }); if (!pureHostname) { return resolve({ isCompliant: false, message: '输入地址格式无效,请检查(如含特殊字符、错误端口)' }); } // 3. 合规后缀检测(示例:可结合鸿蒙网络能力获取最新TLD列表) const matchedTld = getMatchedValidTld(pureHostname); if (!matchedTld) { const invalidSuffix = pureHostname.split('.').pop() || ''; promptAction.showToast({ message: `域名后缀".${invalidSuffix}"不合规,请修正或执行网段搜索`, duration: 2000 }); return resolve({ isCompliant: false, message: `域名后缀".${invalidSuffix}"不合规,请修正或执行网段搜索` }); } // 4. 补全协议(适配鸿蒙安全策略) let targetUrl = input.trim(); if (!/^(https?):\/\//i.test(targetUrl)) { targetUrl = `https://${targetUrl.replace(/^(https?:\/\/)?/i, '')}`; } // 5. 鸿蒙API调用优化 promptAction.showToast({ message: `后缀合规(.${matchedTld}),即将跳转`, duration: 2000 }); resolve({ isCompliant: true, message: '地址合规,已触发跳转', targetUrl }); } catch (e) { // 鸿蒙错误日志记录(示例) console.error(`Domain check failed: ${JSON.stringify(e)}`); reject(new Error('域名合规性检查异常,请稍后重试')); } }); } // 示例使用 const compliance = await checkDomainCompliance('example.com') console.log(compliance); // { isCompliant: true, message: '地址合规,已触发跳转', targetUrl: 'https://example.com' } const compliance = await checkDomainCompliance('https://www.abc.co.uk/path') console.log(compliance); // { isCompliant: true, message: '地址合规,已触发跳转', targetUrl: 'https://www.abc.co.uk/path' } const compliance = await checkDomainCompliance('test.invalid') console.log(compliance); // { isCompliant: false, message: '域名后缀".invalid"不合规,请修正或执行网段搜索' } 5. 方案成果总结(一)功能覆盖全面实现 “输入解析→合规检测→结果处理” 全流程,支持带协议 / 路径、纯域名等 8 种常见地址格式,多级后缀匹配准确,无漏判 / 误判。(二)可维护性强合规 TLD 列表支持从 ICANN 官方源动态更新,无需手动修改代码;核心逻辑按 “配置 - 解析 - 检测 - 处理” 拆分,新增功能(如自定义合规后缀)仅需扩展配置。(三)用户体验优化错误提示精准区分 “格式无效”“后缀不合规”,合规地址自动补全 HTTPS 协议,减少无效操作。(四)安全防护提升通过合规后缀过滤,非 ICANN 认可的恶意地址拦截,降低钓鱼、恶意程序访问风险;地址解析阶段过滤特殊字符,避免注入攻击,安全防护层级显著增强。
-
1.1问题说明该案例主要解决鸿蒙应用里 “富文本输入 + 表情使用” 的常见问题,具体如下:系统自带输入法和自定义表情功能分开,用户输入时要频繁切换,操作麻烦;富文本输入框默认只能输文字,没法直接插表情,也不能把 “文字 + 表情” 的内容整理成规整的数据格式;切换系统键盘和自定义表情键盘时,界面高度不能自动调整,容易出现遮挡或空白;没有常用表情的快速入口,用户得在很多表情里一个个找,用起来不方便;系统键盘高度变化时,组件没法同步调整,导致不同设备上显示效果不一致。1.2原因分析针对上述问题,从鸿蒙技术框架特性与组件设计角度分析根本原因:(一)原生模块隔离:鸿蒙原生输入法与自定义组件属于独立模块,无默认通信与切换机制,需手动管理 “原生键盘 - 表情键盘” 的状态切换;(二)输入框功能有限:鸿蒙的富文本输入框只支持基础文字编辑,表情这类图片内容需要手动添加、管理和解析;(三)键盘高度需主动获取:系统键盘高度变化是 “被动事件”,得主动监听才能拿到实时高度,否则没法调整界面;(四)无默认常用表情组件:鸿蒙 SDK 未提供 “常用表情” 存储与展示的默认组件,需自定义列表(List)并实现数据生成 / 管理逻辑;(五)组件生命周期未关联事件:若未在aboutToAppear/aboutToDisappear中绑定 / 解绑键盘监听,会导致内存泄漏或事件监听失效。1.3解决思路围绕 “功能实现 + 适配性 + 易用性” 目标,针对问题制定分层解决思路:(一)界面高度自适应:获取当前应用窗口,监听键盘高度变化,把像素单位转换成视觉适配单位后,同步到组件的状态里,用来调整界面高度;(二)表情与文字管理:用富文本输入框的控制器,封装 “插表情” 和 “解析内容” 的功能,把 “文字 + 表情” 的内容整理成统一的数组格式;(三)键盘切换控制:用一个状态变量管理 “显示系统键盘还是表情键盘”,绑定到富文本输入框上,实现无缝切换;(四)常用表情快速访问:自定义横向 List 组件(FrequentEmojiList),生成固定数量的常用表情(代码中暂用随机逻辑,可扩展为持久化存储),降低用户查找成本;(五)数据通信与组件化:定义onSendDataCallBack回调函数,将结构化的RichEditorSpan数据传递给父组件,同时采用@Builder拆分 UI 模块(ToolBar/EmojiKeyboard/FrequentEmojiList),提升组件复用性。1.4解决方案整体围绕 “让表情与文字输入更流畅、界面适配更灵活”,从界面设计、高度适配、内容管理、操作优化四个核心维度落地,具体如下:(一)界面整体设计:分层分模块,避免混乱把核心界面拆成 4 个独立模块:负责输入和操作的 “工具栏”、展示所有表情的 “表情键盘”、快速找常用表情的 “常用表情栏”、自动调高度的 “自适应区域”;模块间分工明确,既方便后续修改,又能避免界面显示异常(如遮挡、空白)。(二)键盘高度适配:主动监听,实时同步先获取当前应用窗口,在组件加载时开启 “键盘高度变化” 监听,卸载时关闭监听,避免浪费内存;监听到键盘高度变了,就把系统的像素单位转成适配不同设备的视觉单位,同步到组件状态里,用来调整界面高度。(三)表情与文字管理:统一控制,规整数据用富文本输入框的 “控制器”,实现两个核心功能:点击表情时,把表情插入到输入框光标位置;点击发送时,把 “文字 + 表情” 整理成统一的数组格式;设计 “数据回调” 功能,把整理好的内容传给上级组件,方便后续发送或存储。(四)操作优化:简化切换,快速找表情用一个状态变量控制 “显示系统键盘还是表情键盘”,点击表情按钮就能切换,输入框会自动同步;做一个横向的常用表情栏,只在显示系统键盘时出现,不用在所有表情里翻找,节省时间。1、组件化代码示例:import { window } from "@kit.ArkUI"; import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'CustomEmojiBoard'; interface OperateItem { icon: Resource; onClick?: (event: ClickEvent) => void; } export interface RichEditorSpan { id: string; value?: string; resourceValue?: ResourceStr; type: 'text' | 'image'; } @Component export struct CustomEmojiBoard { private richEditorController = new RichEditorController(); private frequentEmojiListHeight = 60; @State keyboardHeight: number = 0; @State isEmojiKeyboardVisible: boolean = false; onSendDataCallBack?: (richEditorSpans: RichEditorSpan[]) => void; aboutToAppear(): void { window.getLastWindow(this.getUIContext().getHostContext()).then(win => { this.addKeyboardHeightListener(win); }).catch((err: BusinessError) => { console.error(TAG, `getLastWindow Failed. Code:${err.code}, message:${err.message}`); }); } aboutToDisappear(): void { window.getLastWindow(this.getUIContext().getHostContext()).then(win => { this.removeKeyboardHeightListener(win); }).catch((err: BusinessError) => { console.error(TAG, `getLastWindow Failed. Code:${err.code}, message:${err.message}`); }); } getResourceString(resource: Resource): string { try { return this.getUIContext().getHostContext()!.resourceManager.getStringSync(resource.id); } catch (exception) { console.error(TAG, `getLastWindow Failed. Code:${exception.code}, message:${exception.message}`); return ''; } } addKeyboardHeightListener(win: window.Window) { win.on('keyboardHeightChange', height => { console.info(TAG, 'keyboard height has changed', this.getUIContext().px2vp(height)); if (height !== 0) { this.keyboardHeight = this.getUIContext().px2vp(height); return; } if (!this.isEmojiKeyboardVisible) { console.info(TAG, 'click soft keyboard close button'); } }); } removeKeyboardHeightListener(win: window.Window) { win.off('keyboardHeightChange'); } getOperateItems(): OperateItem[] { return [ { icon: this.isEmojiKeyboardVisible ? $r('app.media.keyboard_circle') : $r("app.media.keyboard_face"), onClick: this.onEmojiButtonClick }, { icon: $r('app.media.paper_plane'), onClick: this.onSendData } ]; } onEmojiButtonClick: (event: ClickEvent) => void = event => { this.isEmojiKeyboardVisible = !this.isEmojiKeyboardVisible; } onRichEditorClick: (event: ClickEvent) => void = event => { this.isEmojiKeyboardVisible = false; } onEmojiClick: (icon: Resource) => void = icon => { this.richEditorController.addImageSpan(icon, { offset: this.richEditorController.getCaretOffset(), imageStyle: { size: [20, 20] } }); } onSendData: () => void = () => { let richEditorSpan: RichEditorSpan; const richEditorSpans: RichEditorSpan[] = []; this.richEditorController.getSpans().forEach((span, index) => { const textSpan = span as RichEditorTextSpanResult; const imageSpan = span as RichEditorImageSpanResult; if (textSpan.value) { richEditorSpan = { id: JSON.stringify(index), value: textSpan.value, type: 'text' }; } else { richEditorSpan = { id: JSON.stringify(index), resourceValue: imageSpan.valueResourceStr, type: 'image' }; } richEditorSpans.push(richEditorSpan); }); console.info(TAG, 'richEditorContent', JSON.stringify(richEditorSpans)); this.onSendDataCallBack?.(richEditorSpans); } hasSelection(controller: RichEditorController) { const selection = controller.getSelection().selection; return selection[0] !== selection[1]; } getEmojiIcons(): Resource[] { let resourceList: Resource[] = []; for (let i = 0; i < 30; i++) { resourceList.push($r(`app.media.emoji_${i + 1}`)) } return resourceList; } getFrequentEmojiIcons(): Resource[] { const getRandomNum = () => Math.floor(Math.random() * 30) + 1; return Array(10).fill(1).map(() => $r(`app.media.emoji_${getRandomNum()}`)); } @Builder ToolBar() { Column() { RichEditor({ controller: this.richEditorController }) .customKeyboard(this.isEmojiKeyboardVisible ? this.EmojiKeyboard() : undefined) .constraintSize({ maxHeight: 120 }) .placeholder($r('app.string.write_editor_content')) .defaultFocus(true) .onClick(this.onRichEditorClick) Row({ space: 15 }) { ForEach(this.getOperateItems(), (operateItem: OperateItem) => { Image(operateItem.icon) .width(24) .onClick(operateItem.onClick) }, (operateItem: OperateItem) => JSON.stringify(operateItem)) } .justifyContent(FlexAlign.End) .width('100%') .padding({ bottom: 5, right: 10 }) } .margin(10) .backgroundColor("rgba(0, 0, 0, 0.05)") .borderRadius(20) } @Builder EmojiKeyboard() { Grid() { ForEach(this.getEmojiIcons(), (icon: Resource) => { GridItem() { Image(icon) .width(45) .onClick(() => { this.onEmojiClick(icon) }) } }) } .width('100%') .height(this.keyboardHeight + this.frequentEmojiListHeight) .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr') .rowsGap(15) .padding(10) .scrollBar(BarState.Off) .backgroundColor(Color.White) } @Builder FrequentEmojiList() { List({ space: 12 }) { ForEach(this.getFrequentEmojiIcons(), (icon: Resource) => { ListItem() { Image(icon) .width(40) .onClick(() => { this.onEmojiClick(icon) }) } }) } .width('100%') .height(this.frequentEmojiListHeight) .padding({ left: 15 }) .listDirection(Axis.Horizontal) .scrollBar(BarState.Off) .alignListItem(ListItemAlign.Center) .align(Alignment.Start) } build() { Column() { this.ToolBar() Divider() if (!this.isEmojiKeyboardVisible) { this.FrequentEmojiList() } Column() .height( this.isEmojiKeyboardVisible ? this.keyboardHeight + this.frequentEmojiListHeight : this.keyboardHeight ) } } } 2、演示代码示例:import { CustomEmojiBoard, RichEditorSpan } from "./CustomEmojiBoard"; @Entry @Component struct Index { @State dataList: RichEditorSpan[] = []; aboutToAppear(): void { } build() { Column() { CustomEmojiBoard({ onSendDataCallBack: (data: RichEditorSpan[]) => { this.dataList = data; } }) Flex({direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { ForEach(this.dataList, (richEditorSpan: RichEditorSpan) => { if (richEditorSpan.type === 'text') { Text(richEditorSpan.value) } else { Image(richEditorSpan.resourceValue) .width(20) } }, (richEditorSpan: RichEditorSpan) => richEditorSpan.id) } } .height('100%') .width('100%') } } 1.5方案成果总结整体成果从用户体验、设备适配、开发复用三个核心维度落地,既解决了实际使用痛点,也为后续开发提供便利,具体如下:(一)用户体验成果:操作流畅、使用便捷实现 “文字输入 + 表情插入 + 键盘切换 + 内容发送” 全流程闭环,用户不用频繁切换功能,操作更顺;新增常用表情横向列表,省去在大量表情中查找的时间,快速就能用;支持 “文字 + 表情” 混排,并能把内容整理成规整格式,满足后续发送、存储需求。(二)设备适配成果:显示一致、性能稳定能自动同步系统键盘高度,不同设备上界面都能自适应调整,不会出现遮挡或空白;组件加载 / 卸载时同步绑定 / 取消键盘监听,避免内存浪费,符合鸿蒙系统性能要求;富文本输入框设置了最大高度和默认聚焦,在聊天、评论等不同场景下都能正常使用。(三)开发复用成果:可扩可改、维护方便界面拆成工具栏、表情键盘、常用表情栏等独立模块,能直接复用到其他需要 “输入 + 表情” 的场景;定义了统一的 “文字 + 表情” 数据格式,后续想加链接、@他人等新内容类型也方便扩展;有完整的错误处理(如窗口获取失败、资源读取异常),后续排查问题、修改功能更简单。
-
1.问题说明:Flutter为实现列表组头悬浮2.原因分析:ListView组件是没有组头的,只能找其他组件代替,例如:CustomScrollView3.解决思路:CustomScrollView组件slivers是可以添加组SliverMainAxisGroup组件,在SliverMainAxisGroup中包裹组头SliverPersistentHeader和列表SliverList组件,分别在它们的代理组件上实现组头和列表4.解决方案:一、CustomScrollView组件代码实现CustomScrollView( slivers: _viewModel.bloodShareGroups.map((group) { return SliverMainAxisGroup(slivers: [ SliverPersistentHeader( delegate: BloodShareHeaderDelegate(_viewModel, group), pinned: true, ), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return BloodShareFriendItem( _viewModel, group, index); }, childCount: group.children.length, ), ), ]); }).toList(),),二、组头SliverPersistentHeader组件的代理组件实现,列表滑动组头悬浮就在于pinned: true,import 'package:flutter/material.dart';import '../../../../common/theme/app_theme.dart';import '../models/blood_share_group_model.dart';import '../viewmodels/blood_share_viewmodel.dart';class BloodShareHeaderDelegate extends SliverPersistentHeaderDelegate { final BloodShareViewModel _viewModel; final BloodShareGroupModel groupModel; const BloodShareHeaderDelegate(this._viewModel, this.groupModel); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return _noLastBuild(); } Widget _noLastBuild() { return Container( alignment: Alignment.centerLeft, decoration: BoxDecoration( color: AppTheme.bodyBackgroundColor, ), child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(width: 14), Text( groupModel.title, textAlign: TextAlign.start, style: TextStyle( color: Color(0xFF999999), fontSize: 14, fontWeight: FontWeight.normal, ), ), ], ), ); } @override double get maxExtent { return 36; } @override double get minExtent { return 36; } @override bool shouldRebuild(covariant BloodShareHeaderDelegate oldDelegate) { return this.groupModel.title != oldDelegate.groupModel.title; }}三、组列表SliverList组件的代理组件实现直接在原生代理SliverChildBuilderDelegate组件的子组件回调中创建列表的自定义Item常规组件BloodShareFriendItemSliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return BloodShareFriendItem( _viewModel, group, index); }, childCount: group.children.length, ),)四、作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1.问题说明:Flutter为实现刻度尺组件,可左右滑动、且滑动组件到刻度时在屏幕中间ListView的偏移量的监听是个难点2.原因分析:ListView没有滑动结束的方法回调,无法定位偏移量,只能通过NotificationListener监听组件,去监听ListView是否滑动结束3.解决思路:通过使用NotificationListener监听组件,监听ListView滑动结束,获取ListView的偏移量,计算当前偏移量距离哪个刻度比较近,使用ListView的控制器ScrollController的jumpTo()方法,滑动到对应刻度4.解决方案: 一、刻度尺Dialog的代码实现import 'package:flutter/material.dart';import 'package:get/get.dart';import '../../../../common/theme/app_theme.dart';import '../models/blood_friend_scale_model.dart';class BloodFriendScaleDialog extends StatefulWidget { final FriendScaleDialogModel dialogModel; const BloodFriendScaleDialog( this.dialogModel, ); @override State<BloodFriendScaleDialog> createState() => _BloodFriendScaleDialogState();}class _BloodFriendScaleDialogState extends State<BloodFriendScaleDialog> { late final FriendScaleDialogViewModel _viewModel; late final FriendScaleDialogModel _dialogModel; late final ScrollController _controller; // 滑动控制器用于监听 bool _scrollEnd = true; double _lastOffset = 0; late final double lineWidth = 2; late final double lineSpace = 100; @override void initState() { super.initState(); _viewModel = FriendScaleDialogViewModel(); _dialogModel = widget.dialogModel; _viewModel.dealData(_dialogModel); // 初始化偏移量 double offsetSpace = lineWidth + lineSpace; double jumpOffset = (_viewModel.currentIndex ?? 0) * offsetSpace; _controller = ScrollController( initialScrollOffset: jumpOffset, ); addListener(); } @override void dispose() { _controller.dispose(); super.dispose(); } // 添加偏移量监听 addListener() { _controller.addListener(() { double offsetSpace = lineWidth + lineSpace; int integer = (_controller.offset / offsetSpace).floor(); double residue = _controller.offset % offsetSpace; if (residue > offsetSpace) { integer += 1; } _viewModel.getSureScale(integer); }); } // 更新偏移量 _updateOffset(double pixels) { if (_lastOffset == pixels) { return; } else { _lastOffset = pixels; } if (_scrollEnd) { _scrollEnd = false; double offsetSpace = lineWidth + lineSpace; int integer = (pixels / offsetSpace).floor(); double residue = pixels % offsetSpace; if (residue > offsetSpace / 2) { integer += 1; } double jumpOffset = integer * offsetSpace; _controller.jumpTo(jumpOffset); } } // 点击取消事件 _clickCancelEvent() { Navigator.of(context).pop(); } _clickSureEvent() { if (_dialogModel.clickSureBlock != null) { if (_viewModel.sureScale != null) { _dialogModel.clickSureBlock(_viewModel.sureScale!.value); } } } @override Widget build(BuildContext context) { // 半屏幕间隔,用于第一个和最后一个 double hspace = (MediaQuery.of(context).size.width - lineWidth) / 2; return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), ), ), height: 300, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: SizedBox()), GestureDetector( onTap: () => _clickCancelEvent(), child: Text( '取消', textAlign: TextAlign.center, style: TextStyle( color: Color(0xFF999999), fontSize: 15, fontWeight: FontWeight.w600), ), ), SizedBox(width: 15), ], ), Text( _dialogModel.title, textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, ), ), SizedBox(height: 10), Text( _dialogModel.subTitle, textAlign: TextAlign.center, style: TextStyle( color: Color(0xFF999999), fontSize: 12, fontWeight: FontWeight.w600, ), ), SizedBox(height: 5), Obx(() { return Text.rich( TextSpan( text: _viewModel.sureScale?.value.scale.value ?? '', style: TextStyle( color: Color(0xFFF79797), fontSize: 28, fontWeight: FontWeight.normal, ), children: [ TextSpan( text: _dialogModel.unit, style: TextStyle( color: Color(0xFFF79797), fontSize: 12, fontWeight: FontWeight.w600, ), ) ], ), ); }), SizedBox(height: 5), Container( decoration: BoxDecoration( color: AppTheme.bodyBackgroundColor, ), height: 80, child: Stack( alignment: Alignment.topCenter, children: [ NotificationListener<ScrollNotification>( onNotification: (notification) { if (notification is ScrollEndNotification) { _scrollEnd = true; _updateOffset(notification.metrics.pixels); return true; } _scrollEnd = false; return false; }, child: ListView.builder( controller: _controller, scrollDirection: Axis.horizontal, itemCount: _dialogModel.scales.length, itemBuilder: (BuildContext context, int index) { return Container( decoration: BoxDecoration( color: Colors.transparent, ), width: _getItemWidth(hspace, index), child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (index == 0 && _dialogModel.scales.length > 1) SizedBox(width: hspace), Container( alignment: Alignment.topCenter, decoration: BoxDecoration( color: Colors.transparent, ), width: 2, child: Container( decoration: BoxDecoration( color: Color(0xFF999999), ), width: index % 2 == 0 ? 2 : 1, height: index % 2 == 0 ? 40 : 20, ), ), ], ), ); }), ), Positioned( top: -10, child: Image.asset( 'assets/images/personalcenter_scale.png', width: 30, height: 50, fit: BoxFit.fill, ), ), ], ), ), _sureBuilder(), ], ), ); } double _getItemWidth(double hspace, int index) { if (index == 0) { if (_dialogModel.scales.length == 1) { return hspace + lineWidth; } else { return hspace + lineWidth + lineSpace; } } else if (index == _dialogModel.scales.length - 1) { return lineWidth + hspace; } else { return lineWidth + lineSpace; } } // 确定UI Widget _sureBuilder() { return GestureDetector( onTap: () => _clickSureEvent(), child: Container( alignment: Alignment.center, decoration: BoxDecoration( color: Color(0xFFF79797), borderRadius: BorderRadius.horizontal( left: Radius.circular(22), right: Radius.circular(22)), ), height: 44, width: double.infinity, margin: EdgeInsets.only(top: 10, left: 15, right: 15), child: Text( '确定', textAlign: TextAlign.start, style: TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.normal, ), ), ), ); }}class FriendScaleDialogViewModel extends GetxController { late final FriendScaleDialogModel dialogModel; Rx<BloodFriendScaleModel>? sureScale; int? currentIndex; // 处理数据 dealData(FriendScaleDialogModel model) { dialogModel = model; sureScale = BloodFriendScaleModel(scale: ''.obs).obs; for (int i = 0; i < dialogModel.scales.length; i++) { BloodFriendScaleModel scaleModel = dialogModel.scales[i]; if (dialogModel.currentScale != null) { if (scaleModel.scale == dialogModel.currentScale?.value.scale.value) { currentIndex = i; sureScale?.value.scale.value = scaleModel.scale.value; } } else { if (i == 0) { currentIndex = 0; sureScale?.value.scale.value = scaleModel.scale.value; } } } } // 当前选中的刻度Model赋值 getSureScale(int index) { if (dialogModel.scales.length > index) { currentIndex = index; BloodFriendScaleModel scaleModel = dialogModel.scales[index]; sureScale?.value.scale.value = scaleModel.scale.value; } }}class FriendScaleDialogModel { String title; String subTitle; String unit; List<BloodFriendScaleModel> scales; Rx<BloodFriendScaleModel>? currentScale; Function(BloodFriendScaleModel) clickSureBlock; FriendScaleDialogModel({ required this.title, required this.subTitle, required this.unit, required this.scales, this.currentScale, required this.clickSureBlock, });}二、ViemModel中的代码实现import 'package:flutter/material.dart';import 'package:get/get.dart';import '../dialogs/blood_friend_scale_dialog.dart';import '../models/blood_friend_scale_model.dart';import '../models/blood_friend_warn_model.dart';class BloodFriendAddWarnViewModel extends GetxController { Rx<BloodFriendWarnModel>? warnModel; // 新增或编辑提醒Model // 重复刻度数集合 List<String> scales = <String>[ '0', '1', '3', '5', '10', ]; // 重复刻度Model集合 List<BloodFriendScaleModel> repeats = <BloodFriendScaleModel>[]; // 重复刻度Model集合 getRepeats() { scales.forEach((name) { BloodFriendScaleModel scaleModel = BloodFriendScaleModel( scale: name.obs, ); repeats.add(scaleModel); }); } // 刻度选择器 showScaleSelectPicker(BuildContext context) { FriendScaleDialogModel dialogModel = FriendScaleDialogModel( title: '重复提醒次数', subTitle: '每次响铃重复次数', unit: '次', scales: repeats, currentScale: warnModel?.value.repeat, clickSureBlock: (BloodFriendScaleModel scaleModel) { Navigator.of(context).pop(); warnModel?.value.repeat?.value.scale.value = scaleModel.scale.value; }, ); showModalBottomSheet( enableDrag: false, context: context, builder: (BuildContext sheetContext) { return BloodFriendScaleDialog(dialogModel); }, ); }}三、Model中的代码实现class BloodFriendScaleModel { RxString scale = ''.obs; // 刻度数值 BloodFriendScaleModel({ required this.scale, });}四、个人感悟1.Flutter的列表ListView不像iOS或安卓有滑动方法回调ListView只能通过监听NotificationListener包裹ListView,监听其滑动结束的回调 监听回调更新偏移量 2.ScrollController的addListener只能监听偏移量的变化,在其监听中实现只要滑动偏移量大于等于某个刻度时,当前顶部刻度显示就是某个刻度 3.实现效果如下 4.作为一个Flutter初学者,恳请大佬们多多指教,多给宝贵意见,万分感谢
-
1、关键技术难点总结1.1 问题说明假设APP一款面向全球用户的 HarmonyOS 应用,用户群体遍布世界各地,使用不同的语言(如中文、英文等)。在实际使用场景中,用户可能会遇到以下问题:语言切换不流畅:用户在使用应用时够根据自己的偏好切换界面语言,但切换后不能立即生效,影响用户体验。语言偏好丢失:用户设置了自己偏好的语言,但下次打开应用时又恢复为系统默认语言,需要重新设置。无法响应系统语言变化:当用户在系统设置中更改了设备语言时,应用无法自动适配新的系统语言。多语言资源管理困难:随着支持的语言种类增加,如何有效管理和维护不同语言的字符串资源成为一个挑战。文本方向适配问题:对于从右到左(RTL)书写的语言(如阿拉伯语、希伯来语)和从左到右(LTR)书写的语言(如中文、英文),应用界面需要能够正确处理文本显示方向,否则会影响阅读体验。1.2 原因分析HarmonyOS系统虽然提供了多语言支持,但默认的语言切换机制缺乏动态切换能力,导致用户体验不佳。系统默认不会持久化保存用户选择的语言偏好,应用重启后会重新读取系统语言设置,导致用户需要重复设置。应用没有监听系统语言变化的机制,当用户在系统设置中更改设备语言时,应用无法自动适配新的系统语言。随着支持的语言种类增加,如果缺乏统一的资源管理机制,会导致资源文件分散、维护困难,增加开发和维护成本。HarmonyOS系统没有自动处理RTL和LTR语言的文本方向适配,需要开发者手动实现文本方向的判断和设置,否则RTL语言(如阿拉伯语、希伯来语)的显示会出现问题。2、解决思路创建语言资源管理器类AppResourceManager,统一管理语言切换逻辑在应用启动时初始化语言设置并监听系统语言变化提供语言切换接口,支持动态切换应用语言使用preferences模块保存用户语言偏好设置在UI页面中使用$r()方法引用国际化字符串资源通过Direction属性控制文本显示方向,支持RTL和LTR语言的正确显示3、解决方案步骤1:创建语言资源管理器(AppResourceManager.ets)import preferences from '@ohos.data.preferences'; import i18n from '@ohos.i18n'; import { AsyncCallback, BusinessError, commonEventManager } from '@kit.BasicServicesKit'; const CUL_LANG = 'currentLanguage'; const TAG = 'commonEventManager' export class AppResourceManager { private static instance: AppResourceManager; private currentLanguage: string | null = null; private preferences: preferences.Preferences | null = null; private static context: Context; private direction:Direction = Direction.Auto private onChange: (lang: string | null) => void = () => {} public static getInstance(): AppResourceManager { if (!AppResourceManager.instance) { AppResourceManager.instance = new AppResourceManager(); } return AppResourceManager.instance; } // 初始化偏好设置 async initPreferences(context: Context): Promise<void> { try { AppResourceManager.context = context; this.preferences = await preferences.getPreferences( AppResourceManager.context, 'app_language_settings' ); // 读取保存的语言设置 const savedLanguage = await this.preferences.get(CUL_LANG, ''); if (savedLanguage) { this.currentLanguage = savedLanguage as string; } else { // 使用系统语言 this.currentLanguage = i18n.System.getSystemLanguage(); } await this.switchLanguage(this.currentLanguage) // 监听系统语言切换 let subscriber: commonEventManager.CommonEventSubscriber | null = null; let subscribeInfo2: commonEventManager.CommonEventSubscribeInfo = { events: ["usual.event.LOCALE_CHANGED"], } commonEventManager.createSubscriber(subscribeInfo2, (err: BusinessError, data: commonEventManager.CommonEventSubscriber) => { if (err) { console.error(TAG,`Failed to create subscriber. Code is ${err.code}, message is ${err.message}`); return; } subscriber = data; if (subscriber !== null) { commonEventManager.subscribe(subscriber, (err: BusinessError, data: commonEventManager.CommonEventData) => { if (err) { console.error(TAG,`订阅语言地区状态变化公共事件失败. Code is ${err.code}, message is ${err.message}`); return; } console.info(TAG,'成功订阅语言地区状态变化公共事件: data: ' + JSON.stringify(data)) // 监听到语言切换后,触发镜像能力 console.info(TAG, '当前系统语言为:' + i18n.System.getSystemLanguage()) // 读取保存的语言设置 const savedLanguage = this.preferences?.getSync(CUL_LANG, ''); if (savedLanguage) { this.currentLanguage = savedLanguage as string; } else { // 使用系统语言 this.currentLanguage = i18n.System.getSystemLanguage(); this.switchLanguage(this.currentLanguage) } this.onChange(i18n.System.getSystemLanguage()) }) } else { console.error(TAG,`MayTest Need create subscriber`); } }) } catch (error) { console.error('Failed to init preferences:', error); } } // 获取当前语言 getCurrentLanguage(): string | null { return this.currentLanguage; } getCurrentDirection(): Direction { return this.direction; } setChange(change: (lang: string|null) => void) { this.onChange = change } // 切换语言 async switchLanguage(language: string, callback?: AsyncCallback<ESObject, void>): Promise<void> { let err: BusinessError | null = null try { if (this.currentLanguage !== language && this.preferences) { this.currentLanguage = language; await this.preferences.put(CUL_LANG, language); await this.preferences.flush(); } i18n.System.setAppPreferredLanguage(this.currentLanguage); this.direction = this.conversionDirection(this.currentLanguage) } catch (e) { err = e } finally { if (callback) { callback(err, this.currentLanguage) } } } // 获取支持的语言列表 getSupportedLanguages(): Array<ESObject> { return [ { code: 'zh-Hans', name: '简体中文' }, { code: 'en-Latn', name: 'English' }, { code: 'ar-Arab', name: 'اللغة العربية' } ]; } conversionDirection(language?: string|null): Direction { if (!language) { language = i18n.System.getSystemLanguage(); } // TODO 待具体实现文本对齐方式和读取顺序 let directionRTL = [ar-Arab'] if (directionRTL.indexOf(language) > -1) { return Direction.Rtl } return Direction.Ltr } } 步骤2:配置多语言资源文件在entry/src/main/resources目录下创建不同语言的资源文件:基础资源文件(base/element/string.json):{ "string": [ { "name": "module_desc", "value": "module description" }, { "name": "EntryAbility_desc", "value": "description" }, { "name": "EntryAbility_label", "value": "label" }, { "name": "welcome_message", "value": "Hello World!" } ] } 中文资源文件(zh_CN/element/string.json):{ "string": [ { "name": "welcome_message", "value": "你好,世界!" } ] } 英文资源文件(en_US/element/string.json):{ "string": [ { "name": "welcome_message", "value": "Hello World!" } ] } 阿拉伯文资源文件(ar_SA/element/string.json):{ "string": [ { "name": "welcome_message", "value": "مرحباً أيها العالم" } ] } 步骤3:在页面中使用国际化资源(Index.ets)import { AppResourceManager } from '../i18n/AppResourceManager'; @Entry @Component struct Index { @State currentLanguage: string = 'en-Latn'; @State welcomeText: string = ''; @State changeLanguageText: string = ''; @State currentLanguageText: string = ''; @State isDirection:Direction = Direction.Auto private resourceManager: AppResourceManager = AppResourceManager.getInstance(); private context: Context = this.getUIContext().getHostContext() as Context; aboutToAppear() { // 初始化语言管理器 AppResourceManager.getInstance().initPreferences(this.context); this.resourceManager.setChange((lang: string|null) => { console.info('onChange data: ' + lang) this.isDirection = this.resourceManager.getCurrentDirection() this.currentLanguage = this.resourceManager.getCurrentLanguage() || 'en-Latn'; }) setTimeout(() => { this.currentLanguage = this.resourceManager.getCurrentLanguage() || 'en-Latn'; this.isDirection = this.resourceManager.getCurrentDirection(); }, 200) } build() { Column({ space: 20 }) { Text($r('app.string.welcome_message')) .fontSize(30) .fontWeight(FontWeight.Bold) Image($r('app.media.startIcon')) .width(30) .height(30) // 语言选择列表 this.buildLanguageList() Text('ab%123&*@') .direction(this.isDirection) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .padding(20) .backgroundColor(Color.Gray) } @Builder buildLanguageList() { Column({ space: 10 }) { ForEach(this.resourceManager.getSupportedLanguages(), (language: ESObject) => { Button(language.name) .width('60%') .height(40) .backgroundColor(this.currentLanguage === language.code ? '#409EFF' : '#F5F5F5') .fontColor(this.currentLanguage === language.code ? Color.White : Color.Black) .onClick(() => { this.resourceManager.switchLanguage(language.code, (data: ESObject) => { console.info('switchLanguage data: ' + data) this.currentLanguage = this.resourceManager.getCurrentLanguage() || 'en-Latn'; this.isDirection = this.resourceManager.getCurrentDirection() }) }) }) } .margin({ top: 30 }) } } 4、方案成果总结动态语言切换:用户可以在应用内动态切换语言,并能够及时刷新页面偏好设置保存:用户选择的语言偏好会被持久化保存,下次启动应用时会自动应用系统语言监听:应用能够监听系统语言变化并自动适配资源管理统一:通过AppResourceManager统一管理所有语言相关操作扩展性强:支持添加更多语言,只需添加对应的资源文件和在getSupportedLanguages方法中添加配置即可文本方向适配:支持RTL(从右到左)和LTR(从左到右)文本方向的自动适配,确保不同语言的正确显示
-
1. 问题说明(一) 原生 Slider 功能局限,无法满足双向需求鸿蒙原生 Slider 仅支持 “单向数值调整” 与 “单向选中色展示”,如从最小值(0)向最大值(100)滑动时,仅左侧到当前值显示选中色;无法以中间基准值(如 0)为界,同时支持 “正向增大(如前进时间)” 与 “负向减小(如后退时间)”,也无法分别展示双向选中样式,无法适配歌词校准、音量微调等场景。(二) 实际场景交互与样式不匹配在歌词时间校准场景中,用户需 “前进 5 秒” 或 “后退 5 秒” 调整演唱起点,但原生 Slider 需频繁切换滑动方向(从 0 滑向 100 实现前进,从 100 滑向 0 实现后退),操作繁琐;且无法直观区分 “前进 / 后退” 的视觉反馈,用户难以快速感知调整方向,易出现误操作。(三) 原组件和实际需要的组件的对比:系统组件需求组件 2. 原因分析(一)原生组件设计定位单一原生 Slider 的核心定位是 “单向线性数值选择”(如音量、亮度、进度条),未考虑 “中间基准值双向调整” 场景,因此未提供reverse(反向展示)与双向选中色的配置能力,样式与交互逻辑均受限于单向模型,无法突破双向需求。(二)双向样式与数值同步无原生支持原生 Slider 仅提供selectedColor(全局选中色)、trackColor(滑道色)等基础样式配置,无法分别控制 “正向” 与 “负向” 的选中色;且无内置双向数值关联机制,若手动处理中间基准值与两侧数值的同步,需编写大量冗余代码,易出现数值不一致问题。3. 解决思路(一)组件分层整合,复用原生能力采用 3 个原生 Slider 组件分层协作:下层 2 个 Slider 负责 “双向样式展示”(分别处理负向、正向选中色),上层 1 个 Slider 负责 “用户交互与数值同步”,既复用原生 Slider 的滑动交互能力,又突破双向样式与数值调整的限制。(二)双向样式拆分,明确视觉区分下层左侧 Slider:开启reverse: true,反向展示负向选中色(如从 0 到当前负值),适配 “后退” 场景;下层右侧 Slider:正向展示正向选中色(如从 0 到当前正值),适配 “前进” 场景;通过不同颜色(如红色表负向、蓝色表正向)区分双向,提升用户对调整方向的感知。(三)参数化封装与事件解耦对外暴露minValue(最小值,支持负值)、maxValue(最大值)、defaultValue(基准值)等可配置参数,适配不同场景的数值范围;通过valueChang事件回调传递当前数值与滑动模式(滑动中 / 滑动结束),实现组件与业务逻辑的解耦。4. 解决方案(一)双向 Slider 组件封装通过分层 Slider 实现双向样式与交互,核心代码如下:@ComponentV2 export struct DoubleSlider { @Param defaultValue: number = 0 @Param maxValue: number = 100 @Param minValue: number = -100 @Param @Once initValue: number = 0 @Event valueChang: (value: number,mode: SliderChangeMode) => void build() { Column() { // 1. 实时数值展示(反馈当前调整结果) Text(`${this.initValue}`) .fontSize(16) .fontColor('#333') .textAlign(TextAlign.Center); // 2. 分层Slider容器(Stack实现上下叠加) Stack() { // 下层:2个Slider负责双向样式展示(无交互) Row() { // 左侧Slider:负向选中色(如红色,对应后退) Slider({ value: -this.initValue, reverse: true,// 反向滑动(从右向左对应数值减小) max: Math.abs(this.maxValue),// 最大值的绝对值 min: 0, style: SliderStyle.NONE // 隐藏滑块,仅展示滑道与选中色 }) .width("50%") .selectedColor(Color.Red)// 负向选中色(红色) // 右侧Slider:正向选中色(如蓝色,对应前进) Slider({ value: this.initValue, min: 0, max: this.maxValue, style: SliderStyle.NONE // 隐藏滑块 }) .width("50%") .selectedColor(Color.Green) } .width('calc(100% - 8vp)')// 适配父容器内边距 // 上层:透明Slider,仅接收用户交互(核心) Slider({ value: $$this.initValue, // 双向绑定当前数值 min: this.minValue, max: this.maxValue }) .selectedColor(Color.Transparent)// 隐藏选中色(由下层Slider展示) .backgroundColor(Color.Transparent) // 滑道透明 .trackColor(Color.Transparent) // 滑块颜色(突出交互区域) // 滑块大小(提升点击交互性) // 数值变化时同步回调 .onChange((value: number, mode: SliderChangeMode) => { this.valueChang(value , mode) // 传递数值与模式给业务层 }) }.width("100%") .padding({ left: 20, right: 20 }) // 避免滑块超出容器边界 } } } (二)组件使用示例(歌词时间校准场景)基于双向 Slider 实现 “-20 秒~+20 秒” 的歌词时间调整,代码如下:import { DoubleSlider } from './DoubleSlider'; import { SliderChangeMode } from '@kit.ArkTS'; @Entry @Component export struct LyricCalibratePage { // 歌词校准时间(单位:秒,负值=后退,正值=前进) @State calibrateTime: number = 0; build() { Column({ space: 20 }) { Text('歌词时间校准') .fontSize(20) .fontWeight(FontWeight.Medium) .color('#333'); Text(`当前调整:${this.calibrateTime > 0 ? '前进' : '后退'}${Math.abs(this.calibrateTime)}秒`) .fontSize(14) .color('#666'); // 调用双向Slider组件 DoubleSlider({ defaultValue: 0, minValue: -20, // 最大后退20秒 maxValue: 20, // 最大前进20秒 initValue: 0, // 数值变化回调:更新校准时间,滑动结束提示结果 valueChang: (value: number, mode: SliderChangeMode) => { this.calibrateTime = value; // 滑动结束(mode=End)时弹窗提示 if (mode === SliderChangeMode.END) { Prompt.showToast({ message: `校准完成:${value > 0 ? '前进' : '后退'}${Math.abs(value)}秒` }); } } }); } .width('100%') .height('100%') .padding(20vp) .backgroundColor('#F5F5F5'); } } 5. 方案成果总结(一)功能完备性双向调整全覆盖:支持以中间值为基准的正向 / 负向调整,数值范围可通过minValue/maxValue灵活配置,适配歌词校准、音量微调等多场景;样式直观区分:通过红 / 蓝双色分别标识 “后退 / 前进”,用户可快速感知调整方向,减少误操作。(二)交互与体验优化原生交互复用:基于原生 Slider 的滑动逻辑,操作流畅度与系统组件一致,无额外学习成本;实时反馈清晰:数值展示与滑动同步更新,滑动结束弹窗提示结果,用户可实时掌握调整状态;边界控制严谨:通过minValue/maxValue限制调整范围,避免数值超出合理区间(如歌词校准不超过 ±20 秒),减少异常场景。(三)代码可维护性模块化封装:基于鸿蒙原生 Slider 封装,组件内部处理双向样式与数值同步,对外仅暴露参数与回调,与业务逻辑解耦,可直接复用于不同场景;扩展性强:新增场景(如音量 ±10dB 调整)仅需修改minValue/maxValue参数,无需重构核心代码;
-
1. 问题说明(一)横向宽度自适应难实现需求要求横向瀑布流宽度随内容自适应,但 WaterFlow 的 FlowItem 需手动指定宽度,而内容含文字(长度不固定)与小图片(需格式转化),直接固定宽度会导致文字溢出或留白过多,无法适配不同内容长度。(二)图文混合展示处理复杂内容中图片以 “符号占位符”(如[笑脸])形式存在,需转化为实际图片;若不拆分图文数据,会导致图片无法渲染,且文字与图片排版混乱,影响 UI 一致性。(三)双排布局与滑动交互异常需实现固定高度的双排横向展示,但 WaterFlow 默认布局方向与行列配置不满足需求,易出现 “单排展示”“滑动方向错误”;同时未处理滑动交互开关,导致无法横向滑动浏览多内容。(四)点击数据传递不连贯点击 FlowItem 需将内容添加至输入框,但缺乏统一的数据传递机制,直接在点击事件中操作输入框会导致组件耦合,且多组件间数据同步困难,易出现 “点击无响应”“内容未更新”。2. 原因分析(一)WaterFlow 核心属性配置缺失未设置layoutDirection(主轴方向)为横向(FlexDirection.Row),默认纵向布局无法满足横向瀑布流需求;未通过rowsTemplate配置 “1fr 1fr” 实现双排,导致行列展示不符合预期。(二)图文数据未标准化建模未定义统一的图文数据结构,无法区分文字与图片类型;对 “符号占位符转图片” 的逻辑处理零散,未遍历匹配 emoji 数据,导致图片无法正确替换占位符。(三)FlowItem 宽度未动态计算WaterFlow 的 FlowItem 需明确宽度,未使用MeasureText.measureText计算文字宽度,也未叠加图片固定宽度(如 20vp),直接固定宽度无法适配不同内容长度,导致溢出或留白。(四)点击事件与数据传递耦合未采用事件总线(eventHub)实现跨组件数据传递,点击事件直接操作输入框组件,导致 FlowItem 与输入框强耦合;无事件订阅 / 发布机制,多组件间数据同步需重复编写逻辑,易出错。3. 解决思路(一)配置 WaterFlow 核心属性设置layoutDirection: FlexDirection.Row,确定横向主轴方向;用rowsTemplate: '1fr 1fr'实现双排布局,rowsGap控制行间距;开启enableScrollInteraction: true,支持横向滑动交互,满足多内容浏览。(二)图文数据标准化处理定义SplitData类,区分文字(text)与图片(emoji)类型,标记数据是否为最终格式(finalData);遍历 emoji 数据,拆分含占位符的文本,替换占位符为图片数据,生成结构化的图文列表。(三)动态计算 FlowItem 宽度用MeasureText.measureText计算文字宽度(含字体大小、权重),转换为 vp 单位;叠加图片固定宽度(如 20vp),汇总单条内容的总宽度,赋值给 FlowItem 的width,实现宽度自适应。(四)事件总线解耦数据传递点击 FlowItem 时,通过eventHub.emit发布包含内容的事件;在输入框组件中通过eventHub.on订阅事件,接收数据后更新输入框内容,实现跨组件解耦。4. 解决方案(一)基础数据结构定义定义图文数据类与 emoji 模型,标准化数据格式:// emoji 模型(存储图片路径与占位符含义) export interface EmojiModel { meaning: string; // 占位符含义(如"笑脸",对应占位符"[笑脸]") imgSrc: ResourceStr; // 图片路径 } // 图文拆分后的数据结构 export class SplitData { text: string | undefined; // 文字内容 emoji: EmojiModel | undefined; // 图片数据 finalData: boolean = false; // 是否为最终格式(图片为true,文字为false) constructor(text: string | undefined, emoji: EmojiModel | undefined, finalData: boolean) { this.text = text; this.emoji = emoji; this.finalData = finalData; } } // 模拟emoji数据(实际项目可从配置文件读取) export const EmojiData: EmojiModel[] = [ { meaning: "笑脸", imgSrc: $r('app.media.emoji_smile') }, { meaning: "爱心", imgSrc: $r('app.media.emoji_love') } ]; // 列表项原始数据模型 export interface SocialGreetConf { msg: string; // 含占位符的文本(如"你好[笑脸],欢迎使用") } (二)WaterFlow 控件核心配置实现横向双排瀑布流,支持滑动与自适应宽度:import { SplitData, EmojiData, SocialGreetConf } from '../constants/SocialGreetConfig'; import { MeasureText } from '@kit.ArkUI'; const TAG = 'HorizontalWaterFlow' @Component export struct HorizontalWaterFlow { // 列表数据源(含占位符的文本) @Prop msgList: SocialGreetConf[]; // 事件总线(跨组件传递数据) private eventHub = getContext().eventHub; scroller: Scroller = new Scroller(); textController: TextController = new TextController(); options: TextOptions = { controller: this.textController }; build() { // 横向瀑布流核心配置 WaterFlow({ scroller: this.scroller }) { ForEach(this.msgList, (item: SocialGreetConf) => { FlowItem() { // 单个列表项:横向布局承载图文 Row() { Text(undefined, this.options) { // 遍历拆分后的图文数据,渲染文字或图片 ForEach(this.getSplitContents(item.msg), (splitItem: SplitData) => { if (splitItem.emoji) { // 渲染图片(固定宽度20vp) ImageSpan(splitItem.emoji.imgSrc) .width(20) .objectFit(ImageFit.Contain); } else if (splitItem.text) { // 渲染文字 Span(splitItem.text) .fontSize(14) .fontWeight(450) .fontColor('#333'); } }); } .padding({ left: 5 }) .textOverflow({overflow:TextOverflow.Ellipsis}) .maxLines(1) } .border({ width: 1, color: '#eee' }) .width('100%') // 内部宽度占满FlowItem .height(35) // 点击事件:发布内容到事件总线 .onClick(() => { const content = this.getPureText(item.msg); // 获取纯文本(含图片占位符替换后) this.eventHub.emit('flowItemClick', { content }); // 发布事件 }); } .width(this.getSplitTextWidth(item.msg)) // 动态计算FlowItem宽度 .height(38) .margin({ right: 10 }); // 列间距 }, (item: SocialGreetConf) => item.msg); // ForEach唯一标识 } .rowsTemplate('1fr 1fr') // 双排布局 .layoutDirection(FlexDirection.Row) // 横向主轴 .enableScrollInteraction(true) // 开启横向滑动 .rowsGap(10) // 行间距 .width('100%') // 宽度占满父容器 .height(94) // 固定高度(双排+间距) .padding({ bottom: 10 }); } // 辅助:拆分图文数据(替换占位符为emoji) private getSplitContents(text: string): SplitData[] { let result: SplitData[] = [new SplitData(text, undefined, false)]; // 遍历emoji数据,替换文本中的占位符 EmojiData.forEach(emoji => { const placeholder = `[${emoji.meaning}]`; const temp: SplitData[] = []; result.forEach(item => { if (item.finalData) { temp.push(item); return; } if (item.text?.includes(placeholder)) { // 拆分含占位符的文本 const parts = item.text.split(placeholder); parts.forEach((part, index) => { if (part) temp.push(new SplitData(part, undefined, false)); // 占位符位置插入emoji数据 if (index !== parts.length - 1) { temp.push(new SplitData(undefined, emoji, true)); } }); } else { temp.push(item); } }); result = temp; }); return result; } // 辅助:计算单条内容总宽度(文字+图片) private getSplitTextWidth(text: string): number { const splitContents = this.getSplitContents(text); let totalWidth = 0; splitContents.forEach(item => { if (item.emoji) { totalWidth += 20; // 图片固定宽度20vp } else if (item.text) { // 计算文字宽度(px转vp) const textWidth = MeasureText.measureText({ textContent: item.text, fontSize: 14, fontWeight: 450 }); totalWidth += px2vp(textWidth) + 10; // 文字额外间距10vp } }); console.log(TAG,totalWidth) return totalWidth; } // 辅助:获取纯文本内容(用于传递给输入框) private getPureText(text: string): string { const splitContents = this.getSplitContents(text); return splitContents.map(item => item.text || `[${item.emoji?.meaning}]`).join(''); } } (三)输入框组件事件订阅通过事件总线接收点击数据,更新输入框内容:interface content { content: string } @Component export struct InputComponent { @State inputValue: string = ''; private eventHub = getContext().eventHub; // 组件显示时订阅事件 aboutToAppear() { this.eventHub.on('flowItemClick', (data: content) => { // 接收FlowItem点击数据,更新输入框 this.inputValue = data.content; }); } // 组件销毁时取消订阅,避免内存泄漏 aboutToDisappear() { this.eventHub.off('flowItemClick'); } build() { Column({ space: 10 }) { TextInput({ placeholder: '点击瀑布流内容添加至此...', text: this.inputValue }) .padding(12) .border({ width: 1, color: '#eee' }) .borderRadius(8) .width('100%'); } } } (四)整体页面集成示例组合瀑布流与输入框组件,实现完整功能:import { HorizontalWaterFlow } from "../components/HorizontalWaterFlow"; import { InputComponent } from "../components/InputComponent"; import { SocialGreetConf } from "../constants/SocialGreetConfig"; // 模拟列表数据源(含占位符) const mockMsgList: SocialGreetConf[] = [ { msg: "欢迎[笑脸]使用本功能" }, { msg: "今日推荐[爱心]优质内容" }, { msg: "点击查看更多" }, { msg: "新用户专享[笑脸]福利" }, { msg: "使用愉快[爱心]" }, { msg: "本来应该[笑脸]从从容容游刃有余" }, { msg: "现在是😂匆匆忙连滚带爬" }, { msg: "你哭什么哭😭没出息" } ]; @Entry @Component export struct WaterFlowDemoPage { build() { Column({ space: 20 }) { Row(){ Text('热词推荐') .fontSize(20) .fontWeight(600) } .width('100%') // 横向瀑布流组件 HorizontalWaterFlow({ msgList: mockMsgList }); // 输入框组件(接收点击数据) InputComponent(); } .padding(20) .backgroundColor('#f5f5f5') .width('100%') .height('100%'); } } 5. 方案成果总结(一)成功实现横向双排瀑布流,rowsTemplate与layoutDirection配置准确,无 “单排”“滑动方向错误” 问题,横向滑动交互流畅(二)FlowItem 宽度动态计算准确,文字无溢出、无多余留白,适配不同长度内容;图文替换成功,“符号占位符” 正确转为图片,排版整齐,UI 一致性强。(三)通过eventHub实现跨组件解耦,FlowItem 与输入框无直接依赖,点击数据传递响,无 “内容未更新” 问题,多组件数据同步即时性高。(四)瀑布流组件可直接复用于 “标签选择”“快捷短语” 等场景,修改数据源即可适配;图文拆分与宽度计算逻辑模块化,新增 emoji 仅需扩展EmojiData,无需修改核心代码。
上滑加载中
推荐直播
-
华为云码道 × 仓颉编程:工程化AI编码探索2026/05/27 周三 19:00-21:00
刘俊杰-华为云仓颉语言专家/李炎-华为云码道技术专家/王智鹏-OpenCangjie开源社区发起人
本场直播围绕华为云仓颉语言与华为云码道的深度结合,展示华为云智能编程从零基础到高效落地的完整生态能力。以华为云码道为引擎,仓颉语言为载体,带给大家日常提效、趣味创新到极速量产的开发体验。
回顾中
热门标签