做 Android 自定义控件时,很多人脑子里只有一条主线:继承一个已有 View,保留它原来的能力,再补自己的能力。
比如做一个增强版 TextView:
class BrandTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatTextView(context, attrs) {
fun setBrandLevel(level: BrandTextLevel) {
// 设置品牌字号、字重、颜色、行高等规则
}
}
用的时候,它仍然可以当普通 TextView 看:
<com.example.ui.BrandTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="订单标题"
android:textSize="18sp"
android:textColor="@color/text_primary"
android:maxLines="1" />
这套模型的好处很直接:组件库只需要关心“新增什么能力”,父控件已有的 textSize、textColor、maxLines、ellipsize、drawableStart、includeFontPadding 等能力仍然在。
ArkUI 不是这个模型。
在 HarmonyOS 里,如果把 Text 包成一个自定义组件:
@Component
export struct BrandText {
text: string = ''
build() {
Text(this.text)
.fontSize(14)
.fontColor('#111827')
}
}
调用方拿到的是 BrandText,不是 Text:
BrandText({ text: '订单标题' })
.fontSize(18) // 这里并不是 Text 的 fontSize
自定义组件形成了新的组件边界。内部用了 Text,不代表外部还能继续拿到 Text 的完整属性链。这个差异会直接影响组件库设计:基础组件一旦包死,业务项目后面想调一个 fontSize、maxLines、textOverflow,可能就必须回到组件库里补 API。
先把 Android 与 ArkUI 的自定义控件能力画成地图,后面再拆具体分支。

Android 是一条继承主线
Android View 体系里的自定义控件,最常见的入口有三类。
第一类是继承已有控件:
class BrandButton : AppCompatButton {
fun setLoading(loading: Boolean) {
isEnabled = !loading
text = if (loading) "加载中" else "提交"
}
}
它仍然是一个 Button,可以继续使用 Button / TextView 的绝大部分属性和方法。对组件库来说,这是一种低成本增强:新增品牌能力,但不截断原控件能力。
第二类是继承 View 或 ViewGroup,自己处理测量、布局和绘制:
class PriceTrendView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
setMeasuredDimension(width, 160.dp)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 使用 Canvas 画折线、圆点、阴影和文字
}
}
这里的主线还是很清楚:一个 View 对象同时承载测量、布局、绘制、事件和状态。你要自绘,进入 onDraw(Canvas);你要改尺寸,进入 onMeasure();你要布局子 View,进入 onLayout()。
第三类是 Compose 里的组合与 Modifier。Compose 已经不再是 View 继承模型,但它仍然有相对统一的入口:@Composable 负责组合,Modifier 负责布局、绘制、手势、语义等链式扩展,Canvas 和 custom layout 负责更底层的绘制与测量。
Android 这边的学习路径大致是:
增强已有控件:extends TextView/Button
自定义 View:onMeasure / onLayout / onDraw
自定义容器:ViewGroup / RecyclerView.ItemDecoration 等
Compose:Composable / Modifier / Canvas / custom layout
它不是没有分支,但主干比较清楚。
ArkUI 的能力分散在几条路上
HarmonyOS ArkUI 这边的问题不是“没有能力”,而是入口明显更分散。你想做的事情不同,落点可能完全不同。
1. `@Component`:做组合组件
@Component 是最常见的自定义组件方式。它适合做业务语义组件,比如订单卡片、用户头部、空状态页。
@Component
export struct EmptyView {
title: string = ''
desc: string = ''
build() {
Column({ space: 8 }) {
Image($r('app.media.empty_box'))
.width(96)
.height(96)
Text(this.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(this.desc)
.fontSize(13)
.fontColor('#64748B')
}
}
}
这类组件应该按业务语义设计 API。它不适合无脑替代 Text、Button、Image 这类基础组件。因为一旦包成 EmptyView、BrandText、BrandButton,内部基础组件的属性链不会自动外露。
2. `@BuilderParam`:给业务组件留插槽
如果组件有一块内容需要由业务侧决定,可以用 @BuilderParam。
@Component
export struct CardShell {
title: string = ''
@BuilderParam extra?: () => void
build() {
Column() {
Row() {
Text(this.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
this.extra?.()
}
}
.padding(16)
.borderRadius(12)
.backgroundColor($r('sys.color.background_secondary'))
}
}
调用方可以把右上角按钮、角标、开关传进来:
CardShell({ title: '账号状态' }) {
Button('管理')
}
这条路解决的是“业务组件里的内容扩展”,不是基础控件能力继承。
3. `@Styles` / `@Extend`:当前文件内的样式复用
@Styles 和 @Extend 都能减少重复样式。
@Styles 只能处理通用属性和通用事件,不支持参数,也不支持导出。@Extend(Text) 可以处理 Text 这类组件的私有属性,也支持参数:
@Extend(Text)
function brandTitle(size: number) {
.fontSize(size)
.fontWeight(FontWeight.Medium)
.fontColor('#111827')
}
Text('订单标题')
.brandTitle(18)
.maxLines(1)
这看起来很像“增强 Text”,但官方限制很关键:@Extend 只能在当前文件内使用,不支持导出。也就是说,它不适合作为多项目组件库的基础扩展机制。
4. `AttributeModifier`:组件库样式层更应该看它
官方文档里明确指出,@Styles / @Extend 不能跨文件导出,推荐用 AttributeModifier 解决样式导出与参数传递问题。
export class BrandTitleTextModifier implements AttributeModifier<TextAttribute> {
applyNormalAttribute(instance: TextAttribute): void {
instance
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#111827')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
业务侧仍然写原生 Text:
Text('订单标题')
.attributeModifier(new BrandTitleTextModifier())
.fontSize(20)
这样写的结果很具体:它没有把 Text 包成另一个组件,因此 Text 原来的属性链还在。对组件库来说,AttributeModifier 更适合做“样式层”:标题文本、正文文本、主按钮、危险按钮、弱提示文本。
但它也不是 Android 继承的等价物。它只是把一组属性设置打包成对象,不能让你得到一个新的、继承 Text 全部能力的控件类型。
5. `Canvas`:画布区域里的自绘
如果要画折线图、仪表盘、路径、动态图形,ArkUI 提供 Canvas。
@Entry
@Component
struct PriceTrendCanvas {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Canvas(this.context)
.width('100%')
.height(180)
.onReady(() => {
this.context.beginPath()
this.context.moveTo(16, 120)
this.context.lineTo(80, 80)
this.context.lineTo(140, 96)
this.context.lineTo(220, 48)
this.context.stroke()
})
}
}
Android 里很多自绘需求会自然想到 View.onDraw(Canvas)。ArkUI 里更像是显式放一个 Canvas 组件,然后在 onReady() 之后用 CanvasRenderingContext2D 绘制。
这条路适合“我需要一块画布”。它不是改造已有 Text / Button 的方式。
6. `DrawModifier`:挂到已有组件的绘制层
DrawModifier 更像是在已有组件上插入绘制逻辑。它可以处理内容背景、内容本身、内容前景、前景绘制。
这个入口适合做徽标、特殊背景、局部绘制增强。它和 Canvas 的区别是:Canvas 是一个独立画布组件,DrawModifier 是挂到已有组件上的绘制修改器。
所以如果问题是“做一个独立图表”,优先看 Canvas;如果问题是“给一个已有组件加一层特殊绘制”,再看 DrawModifier。
7. `ContentModifier`:替换部分组件的内容区
ContentModifier 面向的是另一类问题:组件本身还在,但它内部的内容区要换掉。官方例子里会用它改 Checkbox 这类组件内部的表现。
它不适合做通用组件库基础样式层。它更像某些基础组件开放出来的“内容替换口”。
8. `FrameNode` / `RenderNode`:更底层的节点能力
再往下就是自定义节点能力。
FrameNode 是自定义组件节点相关能力;RenderNode 更轻量,主要面向渲染属性、节点树操作、自定义绘制、动画等场景。官方对 RenderNode 的定位很清楚:它适合不需要完整组件通用属性、通用事件,但需要系统基础渲染和动画能力的场景。比如第三方框架已经有自己的解析、布局和事件处理,只需要接入系统渲染节点。
也就是说,RenderNode 不是“普通业务组件封装”的优先入口。它是更底层的能力,适合框架接入、特殊渲染树、性能敏感的节点管理。
组件库设计不要照搬 Android
如果把 Android 的继承模型硬套到 ArkUI,就容易得到一个问题很大的组件库:
@Component
export struct BrandText {
text: string = ''
build() {
Text(this.text)
.fontSize(14)
.fontColor('#111827')
}
}
短期看,它统一了品牌样式。长期看,它截断了 Text 的原生能力。业务项目一旦要设置 maxLines、textOverflow、fontWeight、lineHeight、textAlign,组件库就要不断补参数:
export interface BrandTextOptions {
text: string
size?: number
color?: ResourceColor
weight?: FontWeight
maxLines?: number
overflow?: TextOverflow
align?: TextAlign
}
补到最后,BrandText 会变成一个手动复刻 Text 属性全集的壳。维护者累,业务方也难受。
ArkUI 组件库更适合分层:
| 层级 | 推荐方式 | 适合放什么 |
|---|---|---|
| 设计规范层 | token / resource | 字号、颜色、间距、圆角、阴影 |
| 基础样式层 | AttributeModifier |
标题文本、正文文本、主按钮样式、危险按钮样式 |
| 业务组件层 | export struct + 参数 |
订单卡片、用户头部、价格展示、空状态页 |
| 插槽扩展层 | @BuilderParam |
右上角操作区、列表项尾部、自定义内容区 |
| 自绘层 | Canvas / DrawModifier / ContentModifier |
图表、徽标、特殊内部内容 |
| 节点层 | FrameNode / RenderNode |
三方框架接入、低层节点管理、特殊渲染树 |
这个分层不是为了让架构看起来漂亮,而是为了避开基础组件被包死的问题。
这张图里的关键判断
Android 的优势在“增强基础控件”的手感上。继承 TextView 之后,它仍然像 TextView;继承 View 之后,测量、布局、绘制、事件都在一个对象里。
ArkUI 的优势在“组合与显式分层”上。业务组件可以更清楚地表达页面结构;AttributeModifier 可以把样式对象导出;Canvas、DrawModifier、ContentModifier、RenderNode 给了不同深度的扩展入口。
问题也在这里:ArkUI 的入口太多,彼此不在一条直线上。Android 开发者想找一个“自定义 View 等价物”,很容易迷路。
可以先按下面这组问题选入口:
- 只是统一字号、颜色、间距?
先用 token / resource,不要急着封组件。
- 要复用一组基础组件样式,并且跨文件/跨模块使用?
优先看 AttributeModifier,不要押宝 @Extend。
- 要做业务语义组件?
用 export struct,把 API 设计清楚,必要时用 @BuilderParam 留插槽。
- 要画图表、路径、复杂图形?
看 Canvas。
- 要给已有组件加绘制层?
看 DrawModifier。
- 要替换某些组件内部内容?
看 ContentModifier。
- 要接入三方渲染框架或管理底层节点树?
再看 FrameNode / RenderNode。
小结
这部分能力不能简单下结论说 HarmonyOS 弱。更准确的说法是:
> Android 的自定义控件能力围绕 View 继承形成主线;ArkUI 的自定义能力被拆成组合、样式、Modifier、Canvas、内容修改、绘制修改、自定义节点等多条分支。能力存在,但入口零散,组件库设计成本更高。
对 Android 开发者来说,最需要改掉的是“封一个基础控件再慢慢加能力”的习惯。ArkUI 里基础组件不应该轻易包死;先把 token、modifier、业务组件、自绘、节点能力分层,后面才不会被自己的组件库卡住。
参考素材(精选)
- Android Developers:Create a view class
- Android Developers:Create a custom drawing
- Android Developers:Graphics in Compose
- Android Developers:Custom layouts in Compose
- HarmonyOS:创建自定义组件
- HarmonyOS:@BuilderParam 装饰器
- HarmonyOS:@Styles 装饰器
- HarmonyOS:@Extend 装饰器
- HarmonyOS:AttributeModifier
- HarmonyOS:自定义扩展能力概述
- HarmonyOS:使用画布绘制自定义图形(Canvas)
- HarmonyOS:DrawModifier
- HarmonyOS:ContentModifier
- HarmonyOS:自定义组件节点(FrameNode)
- HarmonyOS:自定义渲染节点(RenderNode)
// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-07-09