Android 自定义 View vs ArkUI 自定义组件:从继承、Modifier 到 Canvas/RenderNode 的能力全景

做 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" />

这套模型的好处很直接:组件库只需要关心“新增什么能力”,父控件已有的 textSizetextColormaxLinesellipsizedrawableStartincludeFontPadding 等能力仍然在。

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 的完整属性链。这个差异会直接影响组件库设计:基础组件一旦包死,业务项目后面想调一个 fontSizemaxLinestextOverflow,可能就必须回到组件库里补 API。

先把 Android 与 ArkUI 的自定义控件能力画成地图,后面再拆具体分支。

Android 自定义控件 vs HarmonyOS ArkUI 自定义组件能力全景图

Android 是一条继承主线

Android View 体系里的自定义控件,最常见的入口有三类。

第一类是继承已有控件:

class BrandButton : AppCompatButton {
    fun setLoading(loading: Boolean) {
        isEnabled = !loading
        text = if (loading) "加载中" else "提交"
    }
}

它仍然是一个 Button,可以继续使用 Button / TextView 的绝大部分属性和方法。对组件库来说,这是一种低成本增强:新增品牌能力,但不截断原控件能力。

第二类是继承 ViewViewGroup,自己处理测量、布局和绘制:

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。它不适合无脑替代 TextButtonImage 这类基础组件。因为一旦包成 EmptyViewBrandTextBrandButton,内部基础组件的属性链不会自动外露。

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 的原生能力。业务项目一旦要设置 maxLinestextOverflowfontWeightlineHeighttextAlign,组件库就要不断补参数:

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 可以把样式对象导出;CanvasDrawModifierContentModifierRenderNode 给了不同深度的扩展入口。

问题也在这里:ArkUI 的入口太多,彼此不在一条直线上。Android 开发者想找一个“自定义 View 等价物”,很容易迷路。

可以先按下面这组问题选入口:

  1. 只是统一字号、颜色、间距?
  2. 先用 token / resource,不要急着封组件。

  1. 要复用一组基础组件样式,并且跨文件/跨模块使用?
  2. 优先看 AttributeModifier,不要押宝 @Extend

  1. 要做业务语义组件?
  2. export struct,把 API 设计清楚,必要时用 @BuilderParam 留插槽。

  1. 要画图表、路径、复杂图形?
  2. Canvas

  1. 要给已有组件加绘制层?
  2. DrawModifier

  1. 要替换某些组件内部内容?
  2. ContentModifier

  1. 要接入三方渲染框架或管理底层节点树?
  2. 再看 FrameNode / RenderNode

小结

这部分能力不能简单下结论说 HarmonyOS 弱。更准确的说法是:

> Android 的自定义控件能力围绕 View 继承形成主线;ArkUI 的自定义能力被拆成组合、样式、Modifier、Canvas、内容修改、绘制修改、自定义节点等多条分支。能力存在,但入口零散,组件库设计成本更高。

对 Android 开发者来说,最需要改掉的是“封一个基础控件再慢慢加能力”的习惯。ArkUI 里基础组件不应该轻易包死;先把 token、modifier、业务组件、自绘、节点能力分层,后面才不会被自己的组件库卡住。

参考素材(精选)

// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-07-09

上一篇