拓冰建站拓冰建站
首页 / 资讯中心 / 正文

HarmonyOS ArkUI组件扩展实战:自定义组件、状态管理与Canvas绘制

做鸿蒙应用开发这段时间我大部分时间都泡在ArkUI的声明式语法里。说实话刚接触时觉得这种写法很简洁但真到了复杂业务页面系统组件不够用是常态。这时候能不能把组件扩展玩明白直接决定了开发效率和代码质量。这篇文章不聊虚的围绕HarmonyOS ArkUI的组件扩展把自定义组件、属性扩展、样式复用、Canvas绘制这些常用手段一次讲透。适合正在用ArkUI写业务、想把手头代码抽得干净一点的开发者也适合刚入门但厌倦了到处复制粘贴基础demo的朋友。1. 先搞清楚组件扩展到底在扩展什么1.1 为什么系统组件永远不够用ArkUI自带的基础组件其实就是那几板斧Text、Button、Image、List、Stack、Row、Column。它们解决的是通用问题但业务里的UI几乎都带着很强的领域特征。比如一个商品卡片要封面图、标题、价格标签、库存进度条还要支持点击跳转再比如一个工单列表项要状态图标、多行文本、时间戳、处理按钮。如果每次都把这一坨代码复制到不同页面改一个样式就要全局搜一遍这种滋味我太熟了。组件扩展解决的就是这个痛点。它的本质不是“凭空造一个陌生控件”而是把系统提供的基础能力按照业务语义重新组装、封装、增强。你可以让一个Button天生就带圆角阴影和点击缩放也可以让一个Text直接支持价格展示和数量角标还可以把一个完整的业务卡片封装成独立组件让页面只负责传数据。1.2 组件扩展的三种典型形态在实际项目里我把ArkUI的组件扩展分成三个粒度它们不是替代关系而是互相配合。页面级别用Builder抽取UI片段解决同一页面内重复布局比如循环渲染的列表项头部、空状态提示图。组件级别用Extend和Styles给系统组件添加业务化属性和样式让Text、Button这类基础组件拥有项目里的默认表现。页面/应用级别用struct配合Component封装完整自定义组件支持状态管理、生命周期、事件回调这是最重的扩展方式也是组件化的核心。这三者的关系可以这样理解Builder是“局部复用”Extend和Styles是“属性增强”自定义组件是“整体封装”。实际开发中我会优先考虑Builder和Extend因为轻量、改动小等一个UI片段确实要承载状态和逻辑的时候再升级成自定义组件这能避免一开始就把组件拆得过度设计。1.3 一个完整自定义组件的基本骨架先看一个最简单的自定义组件我把它叫做CustomCard用来展示一条带标题、副标题和操作按钮的卡片。Component export struct CustomCard { Prop title: string ; BuilderParam extraSlot: () void () {}; clickAction: () void () {}; build() { Row() { Column() { Text(this.title) .fontSize(18) .fontWeight(FontWeight.Bold) Text(这是副标题区域) .fontSize(14) .fontColor(#999999) if (this.extraSlot) { this.extraSlot() } } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Button(查看) .onClick(() { if (this.clickAction) { this.clickAction() } }) } .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) .shadow({ radius: 8, color: #22000000, offsetY: 2 }) } }这里最值得关注的是Prop和BuilderParam。Prop表示这个组件接收外部传入的字符串并且外部变化时会同步刷新BuilderParam的意思是允许外部往组件里塞一段UI这个机制让自定义组件不再是写死的闭合盒子而是可以在内部预埋插槽。这种设计在业务组件里非常实用比如卡片底部的按钮区域不同页面可能放不同的操作按钮通过BuilderParam就能做到灵活替换。很多人一开始只把自定义组件当成“把模板代码提出来”忽略了对外的输入输出设计。真正的扩展是确保组件既能接收参数、又能传出事件还能留出UI插槽这样才算一个合格的组件。2. Builder和Extend页面级扩展的亿万细节2.1 Builder的正确用法和常见误区Builder是ArkUI里非常轻巧的UI复用工具。它不创建新的组件实例只是把一段UI描述抽出来在build里可以像调用函数一样调用。Builder function highlightPrice(price: string) { Text(price) .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor(#FF7500) } Component export struct ProductPage { build() { Column() { this.PriceHeader(今日特价) ... } } Builder PriceHeader(title: string) { Text(title) .fontSize(16) .fontColor(#333333) highlightPrice(9.9) } }这里有一个非常容易踩的坑Builder函数的参数传递方式。ArkUI的Builder如果按值传递普通类型参数比如字符串、数字在状态变化时可能不会触发局部刷新如果传递的是对象并且希望在子级修改时同步刷新就要特别注意引用传递的问题。我的经验是如果一段UI需要依赖响应式状态尽量把参数封装成对象整体传入避免基本类型拆开传。另一个误区是在Builder里写复杂逻辑。我之前见过有人把网络请求回调直接塞进Builder结果页面一刷新回调被重复执行。Builder本质上只负责UI描述不要在它里面处理业务副作用数据请求、事件绑定应该放在组件的方法里。2.2 Extend给系统组件加“外挂”Extend是ArkUI一个相当“香”的能力。它允许你给现有组件类型追加自定义的链式属性方法调用方式和系统属性完全一致。Extend(Text) function priceStyle(color: string #FF7500) { .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor(color) } Extend(Button) function primaryButton() { .width(100%) .height(44) .backgroundColor(#007DFF) .borderRadius(8) }定义好之后在build里直接这样用Text(88.00).priceStyle(#FF0000) Button(立即购买).primaryButton()看起来就像Text和Button多了一套业务化方法。优点很明显把项目中反复出现的组合样式统一收敛改一个尺寸、一个颜色不用全项目替换。需要注意Extend目前不能给自定义组件做同样的扩展它主要面向系统组件。而且定义的位置建议放在全局或者独立的ets文件里这样多个组件都能引用。如果只在某个组件内部定义作用域就限制在组件内部容易忽略这种限制带来的困惑。2.3 Styles样式的“全局公共类”Styles和Extend有点像都是抽取可复用样式但Styles不支持参数只能写静态样式。它更适合做主题类的东西比如统一的卡片圆角、阴影、内边距。Styles function cardContainer() { .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) .shadow({ radius: 8, color: #22000000, offsetY: 2 }) .width(100%) }然后任何容器组件都能直接用Row() { Text(这是卡片) } .cardContainer()Styles还有一个特性既能全局定义也能在组件内部定义。组件内部定义的Styles可以访问组件的状态变量这让样式可以根据状态动态变化。比如一个按钮的激活态和禁用态就可以通过Styles加状态判断来实现。不过要提醒一句Extend和Styles不要滥用。如果项目里每个Text都套上不同的Extend最后反而会变得很难定位样式来源。我的原则是通用性超过三个页面的样式才抽单个页面的局部样式老老实实写在组件里可读性反而更好。3. 状态管理组件扩展的灵魂3.1 状态装饰器选型别再全用State了自定义组件要真正“活”起来离不开状态管理。ArkUI提供了非常丰富的装饰器新手最容易犯的错误就是一上来全用State。我整理了一张选型表平时开发直接照着参考装饰器作用典型场景State组件内部管理的状态变化时自动刷新UI展开收起、选中状态Prop从父组件传入的只读值父组件变化后同步更新展示标题、配置参数Link与父组件某个状态保持双向同步子组件内修改父组件实时更新Provide / Consume跨层级共享状态不需要逐层传参全局主题色、用户信息Observed / ObjectLink观察类对象嵌套属性变化嵌套对象、列表项数据模型Watch监听某个状态变化时触发回调状态改变后做额外逻辑我见过很多项目把本应该用Prop传递的值写成State结果子组件内部自己维护了一份副本父组件数据一变子组件还执着于旧数据排查半天才发现是装饰器用错了。其实规则很简单数据从哪来哪个装饰器负责。3.2 状态不刷新多半是嵌套对象的问题这是组件扩展里最常见的翻车现场。看一段反面代码class UserModel { name: string ; age: number 0; } State user: UserModel new UserModel();这看起来没问题但当你试图修改this.user.name 新名字时UI不一定会刷新。因为State只观察了user这个对象引用本身的变化并没有深入观察对象内部属性。解决办法有两种一是整体替换对象二是使用Observed修饰类并用ObjectLink在子组件里观察。Observed class UserModel { name: string ; age: number 0; } Component struct UserInfoView { ObjectLink user: UserModel; build() { Column() { Text(this.user.name) } } }数组也有类似的坑。修改数组某一项的属性同样不会触发刷新。正确的做法是重新构造数组或者使用支持深观察的数据结构。这些细节看起来小但在组件扩展里状态一旦不刷新界面上就是“死活不变”非常耽误时间。3.3 如何“命令式”调用子组件方法自定义组件默认是纯声明式的父组件无法直接调用子组件的方法。但在一些业务里我们确实需要一个时机去触发子组件的某个动作比如让子组件清空输入框、让图表组件重新绘制。我常用的做法是定义一个Controller类把子组件的执行逻辑挂载上去。父组件持有这个Controller调用它的方法时子组件内部已经实现好具体操作export class ChartController { refresh: () void () {}; } Component export struct ChartComponent { controller: ChartController; aboutToAppear() { this.controller.refresh () { // 执行刷新图表逻辑 }; } }父组件那边在创建ChartComponent时传入同一个controller实例然后调this.controller.refresh()就可以触发子组件刷新。这种方式虽然不如语法糖那么优雅但在处理Canvas图表、视频播放器控制这类场景时特别实用相当于给声明式UI开了一扇命令式操作的窗户。3.4 跨页面共享状态别乱用全局变量有时候一个状态需要在多个页面共享比如登录状态、主题色。新手容易直接建一个全局单例对象但全局对象改变后页面不会自动刷新。ArkUI提供的AppStorage和LocalStorage就是为了解决这个问题的。AppStorage.setOrCreate(themeColor, #007DFF); StorageLink(themeColor) themeColor: string ;这样绑定的状态在任意页面修改时所有绑定该key的组件都会自动刷新。组件扩展里如果自定义组件内部需要用到全局主题我建议通过StorageLink或StorageProp来绑定而不是在组件内部写死颜色值。这样换主题的时候全局组件能同步响应不需要每个页面去手动改。4. Canvas绘制当组件扩展遇上极限自定义4.1 Canvas基础绘制流程如果系统组件和自定义组件都搞不定比如要画一个带渐变进度环、波形图、签名板这时候就要请出Canvas了。ArkUI里的Canvas不是一次性位图而是通过CanvasRenderingContext2D实时绘制在组件表面。基本流程是先定义CanvasRenderingContext2D对象然后把它传给Canvas组件最后在onReady回调里开始绘制。private settings: RenderingContextSettings new RenderingContextSettings(true); private context: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings); build() { Canvas(this.context) .width(200) .height(200) .onReady(() { this.drawProgress(); }) }这里的new RenderingContextSettings(true)表示开启抗锯齿画出来的线条会更平滑。onReady是Canvas初始化完成的标志一定要在这个回调之后再调用绘制方法否则context还没有真正绑定到画布上调用绘制API会直接报错。4.2 实战封装一个可复用的进度环组件Canvas最常见的用途之一就是自定义进度环。我封装了一个MiniProgress组件代码如下Component export struct MiniProgress { private context: CanvasRenderingContext2D new CanvasRenderingContext2D(new RenderingContextSettings(true)); Prop progress: number 0; Prop ringColor: string #007DFF; drawProgress() { const ctx this.context; const width 200; const height 200; const centerX width / 2; const centerY height / 2; const radius 70; const lineWidth 14; const startAngle -Math.PI / 2; const endAngle startAngle (this.progress / 100) * 2 * Math.PI; ctx.clearRect(0, 0, width, height); // 背景圆环 ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.strokeStyle #EEEEEE; ctx.lineWidth lineWidth; ctx.stroke(); // 进度圆环 ctx.beginPath(); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.strokeStyle this.ringColor; ctx.lineWidth lineWidth; ctx.lineCap CanvasLineCap.Round; ctx.stroke(); } build() { Canvas(this.context) .width(200) .height(200) .onReady(() { this.drawProgress(); }) } }这段代码里的关键点是clearRect。每次重绘前都要先清空画布否则上一次的图形会残留叠加画面会越来越花。进度计算也很直白把进度值除以100再乘上2π从-90度开始画这样进度条从顶部开始而不是从水平方向开始视觉上更符合常规习惯。如果希望进度变化时能重绘需要在Prop progress变化后调用drawProgress()。可以在组件里加一个Watch(progress)装饰器监听progress变化后重新绘制。4.3 Canvas和组件扩展如何配合Canvas本质上只是一个绘制工具它本身不关心业务。把它放进自定义组件你就能得到一个真正可复用的图画组件。比如我之前做的一个趋势图组件内部用Canvas画折线对外暴露的是横向数据点数组和颜色主题页面只需要给数据不用关心Canvas的每个坐标计算。这也体现了组件扩展的最终形态底层用Canvas或系统组件实现表现上层用状态装饰器和Controller暴露清晰的接口调用方只和业务数据打交道。这时候你会发现复杂组件的维护难度并没有随着功能增加而变高关键是把“绘制细节”和“数据输入”分离。5. 自定义弹窗和自定义布局扩展系统能力的天花板5.1 CustomDialog弹窗也是可以自由扩展的组件系统默认的弹窗样式有限业务里经常需要定制弹窗的标题区、内容区、按钮区甚至要整屏展示二维码、表单。ArkUI提供的CustomDialog装饰器可以把弹窗变成一个自定义组件。CustomDialog export struct ConfirmDialog { controller: CustomDialogController; title: string 提示; message: string ; confirmAction: () void () {}; build() { Column() { Text(this.title) .fontSize(18) .fontWeight(FontWeight.Bold) Text(this.message) .fontSize(15) .fontColor(#666666) .margin({ top: 12 }) Row() { Button(取消) .onClick(() { this.controller.close(); }) Button(确定) .onClick(() { this.controller.close(); this.confirmAction(); }) } .margin({ top: 20 }) } .padding(24) } }使用的时候在页面里创建CustomDialogController并设置builder参数dialogController: CustomDialogController new CustomDialogController({ builder: ConfirmDialog({ title: 删除确认, message: 确定要删除这条记录吗, confirmAction: () { /* 执行删除 */ } }), autoCancel: true, alignment: DialogAlignment.Center });再在需要的地方调用this.dialogController.open()即可。自定义弹窗里同样可以使用State、Link等装饰器所以它不只是静态展示完全可以变成一个能交互的逻辑组件。唯一要注意的是弹窗关闭后如果需要返回结果建议通过confirmAction或Bind的回调传出去不要直接修改外部状态否则状态流会乱。5.2 自定义布局当Flex和Stack不够用的时候大部分布局用Row、Column、Stack就能搞定。但遇到瀑布流、雷达图、特殊摆放的卡片时系统的线性排版就不够灵活了。一个思路是使用Stack容器通过position属性或者offset属性手动控制子组件的位置。比如要做一个圆环上均匀分布8个图标的雷达菜单可以这样Stack() { ForEach(this.menuItems, (item: MenuItem, index: number) { CircleItem({ item: item, top: this.calcTop(index), left: this.calcLeft(index) }) }, (item: MenuItem, index: number) index.toString()) } .width(300) .height(300)calcTop和calcLeft根据三角函数算出每个图标的位置这样布局不再受Row/Column的线性限制。这种方法适合元素数量固定、位置可计算的场景。如果子组件尺寸不确定还可以用onAreaChange去监听各自的宽高再动态修正位置。自定义布局最考验的不是写代码而是对边界条件的处理。比如容器宽度变化时子组件位置要跟着变化不同屏幕尺寸下半径和节点间距需要自适应。我一般会把布局参数抽成方法方便统一计算而不是在每个子组件里写死位置。6. 组件库封装与工程化避坑6.1 公共组件库的目录组织当组件越来越多组织方式就变得重要。我在HarmonyOS工程里的习惯是按照业务域和通用层级分层src/main/ets/components/ common/ # 跨业务通用组件 CustomCard.ets EmptyView.ets business/ # 业务相关组件 ProductCard.ets OrderListItem.ets每个组件文件里组件名使用大驼峰文件命名和组件名保持一致这样别人在引入时一眼就能找到对应文件。如果组件内包含复杂逻辑我还会额外抽一个Controller或ViewModel文件保持组件本身只负责UI展示。如果多个模块之间要共享组件可以抽成har包通过ohpm发布到本地仓库这样多个应用工程都能引用。这个习惯虽然前期增加了工作但收益非常明显组件改动一次所有依赖方统一升级即可。6.2 自定义组件的性能优化组件扩展做多了以后性能问题就会冒出来。我遇到过最典型的问题是高频率刷新导致页面卡顿根因往往不是组件本身慢而是状态更新粒度太粗。比如一个列表页每一条数据变化都触发整个列表刷新这肯定扛不住。我的优化经验有三条State尽量贴近使用它的UI不要放到大而全的ViewModel里。ForEach必须指定稳定且唯一的keykey不能用数组索引否则增删元素时会导致组件复用错乱。列表项如果足够复杂可以给自定义组件添加Reusable装饰器配合列表的cachedCount参数让组件滚动时被复用而不是反复创建销毁。还有一个容易被忽略的问题在build里做耗时计算。ArkUI的build每次状态变化都可能重新执行如果在build里做字符串拼接、对象深拷贝会把UI线程拖垮。正确做法是提前在状态变化回调里算好用局部变量或计算属性接收。6.3 高频问题排查速查表最后整理一份我工作中频繁遇到的排查表希望能帮你少走弯路。现象可能原因解决方案自定义组件状态更新但UI不刷新使用了State观察嵌套对象用Observed/ObjectLink或整体替换对象Builder传参后UI局部不刷新参数按基本类型传递改为传对象或者使用PropExtend扩展的方法调用时报错方法定义在组件内部且作用域不符放到全局或者独立文件再引用Canvas只显示一次更新无效没有在每次绘制前clearRect重绘前先ctx.clearRect清空画布组件在列表滚动时闪烁或卡顿ForEach的key不稳定改用业务唯一ID作为key配合Reusable弹窗关闭后数据仍被修改close后回调里继续处理异步逻辑用确认回调统一管理关闭后流程状态变化触发重复网络请求在build中做了副作用操作把网络请求移到aboutToAppear或事件回调中排查问题时我一般会先看是不是装饰器用错了再看生命周期是否对。大多数诡异问题都能归结到这两个原因上。做组件扩展这么久我最大的体会是它不是单纯地抽出公共代码而是在设计一种约定。调用你的人能不能一眼就懂组件的输入输出组件可不可以独立测试状态流动是否清晰这些都比代码本身更影响开发体验。我建议每个项目在进入中期时都停下来梳理一遍已经出现重复的UI片段趁早把它们升级成真正的组件扩展等到后期再重构成本会大得多。希望这篇文章能给你一些可落地的参考。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门