ArkUI DynamicLayout(API 24):同一批子组件换布局算法

商品列表、设置卡片、设备面板都会遇到类似排版:手机上一列,横屏或宽屏下两列。麻烦出在卡片内部:里面可能有 TextInputToggleScroll。用户刚输了一半的昵称、刚切开的开关,不能因为外层从列表换成网格就丢掉。

以前写这种动态布局,常见做法是在 build() 里判断宽度:

if (width >= 720) {
  Row() { /* cards */ }
} else {
  Column() { /* cards */ }
}

页面本身从上下结构变成左右结构时,直接在 build() 里分支就够用。HarmonyOS API 24 的 DynamicLayout 处理得更细:孩子还是那批孩子,外层换成 RowLayoutAlgorithmColumnLayoutAlgorithmGridLayoutAlgorithm 或自定义算法。官方文档明确提到,运行时切换不同布局算法时,子组件状态可以保持。

ArkUI DynamicLayout 能力分层

先看 if/else 的代价

最常见的写法是根据窗口宽度选择结构:

build() {
  if (this.hostWidthVp >= 720) {
    Row() {
      SettingsMainPanel()
        .layoutWeight(1)
      SettingsStatusPanel()
        .width(280)
    }
  } else {
    Column() {
      SettingsStatusPanel()
      SettingsMainPanel()
    }
  }
}

这类代码适合页面级结构变化。手机上先看状态卡片,平板上左边列表、右边详情;两套结构里的组件关系确实不同。

它的代价也很直接:分支越多,组件树越容易重复。SettingsMainPanel() 如果内部还有输入框、滚动列表、展开项,就要小心分支切换时组件身份是否稳定。页面结构变化越大,状态管理就越要前置设计。

onAreaChangemediaquery、窗口尺寸监听和条件渲染这套组合,解决的是“什么时候换页面结构”。它很适合页面级分支,不太适合一批卡片只换排列方式的场景。

DynamicLayout 把排列方式变成对象

DynamicLayout 入口只有一个关键参数:布局算法。

DynamicLayout(this.algorithm) {
  // children
}

this.algorithmLayoutAlgorithm 类型。API 24 这批算法大致对应几种常见容器:

算法 对应直觉 适合场景
RowLayoutAlgorithm 水平方向线性排列 工具栏、横向卡片
ColumnLayoutAlgorithm 垂直方向线性排列 表单、设置项列表
StackLayoutAlgorithm 层叠与定位 角标、浮层、卡片叠放
GridLayoutAlgorithm 网格排列 商品、图片、入口宫格
CustomLayoutAlgorithm 自己测量和摆放 瀑布流、标签云、非规则布局

差别在 child 一侧。if/else 通常会替换一段 UI 结构;DynamicLayout 只替换外层算法,里面的孩子仍在同一个容器里。

用设置卡片写下来,大概是下面这个形状:

import {
  DynamicLayout, DynamicLayoutAttribute,
  ColumnLayoutAlgorithm, GridLayoutAlgorithm,
  LayoutAlgorithm, LengthMetrics
} from '@kit.ArkUI'

enum ShelfMode {
  List,
  Grid
}

interface ShelfItem {
  id: string
  title: string
  desc: string
}

@Entry
@ComponentV2
struct DynamicShelfPage {
  @Local mode: ShelfMode = ShelfMode.List
  @Local algorithm: LayoutAlgorithm = new ColumnLayoutAlgorithm({
    space: LengthMetrics.vp(12)
  })

  private items: ShelfItem[] = [
    { id: 'account', title: '账号资料', desc: '昵称、头像、手机号' },
    { id: 'sync', title: '云同步', desc: '备份、跨端同步' },
    { id: 'privacy', title: '隐私设置', desc: '权限、数据导出' },
    { id: 'notify', title: '通知提醒', desc: '声音、横幅、角标' }
  ]

  private switchMode(mode: ShelfMode) {
    this.mode = mode
    if (mode === ShelfMode.List) {
      this.algorithm = new ColumnLayoutAlgorithm({
        space: LengthMetrics.vp(12)
      })
      return
    }

    this.algorithm = new GridLayoutAlgorithm({
      columnsTemplate: '1fr 1fr',
      columnsGap: LengthMetrics.vp(10),
      rowsGap: LengthMetrics.vp(10)
    })
  }

  build() {
    Column({ space: 16 }) {
      Row({ space: 10 }) {
        Button('列表')
          .onClick(() => this.switchMode(ShelfMode.List))
        Button('网格')
          .onClick(() => this.switchMode(ShelfMode.Grid))
      }

      DynamicLayout(this.algorithm) {
        ForEach(
          this.items,
          (item: ShelfItem) => {
            this.SettingCard(item)
          },
          (item: ShelfItem) => item.id
        )
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F6F8FA')
      .borderRadius(12)
    }
    .width('100%')
    .padding(16)
  }

  @Builder
  SettingCard(item: ShelfItem) {
    Column({ space: 8 }) {
      Text(item.title)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)

      Text(item.desc)
        .fontSize(13)
        .fontColor('#64748B')

      if (item.id === 'account') {
        TextInput({ placeholder: '临时输入昵称' })
          .width('100%')
      }

      if (item.id === 'sync') {
        Toggle({ type: ToggleType.Switch, isOn: true })
      }
    }
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(10)
  }
}

列表和网格只改 algorithm,卡片没有复制两份。ForEach 的 key 也要保留,不然“同一批 child”这个前提就站不住。官方文档提到状态保留,这里仍然要注意 child identity:容器可以保留孩子,开发者也要把稳定 key 和状态归属写对。

当然,直接分支也能做:

if (this.mode === ShelfMode.List) {
  Column() {
    // cards
  }
} else {
  Grid() {
    // cards
  }
}

简单页面这么写没什么问题。卡片越多、卡片内部状态越多,重复组件树越容易出错;这时把排列方式收进 algorithm,后续改动会少绕一点。

放到 Android 里,大概接近 ViewGroup / Compose Layout

Android View 体系里,普通页面通常用 LinearLayoutConstraintLayoutRecyclerView 这些现成容器。真要自己写布局容器,就会继承 ViewGroup,在 onMeasure() 里测量 child,在 onLayout() 里给 child 摆位置。

Compose 里也有类似层次:常规页面先用 RowColumnBoxLazyVerticalGrid;遇到现成容器表达不了的关系,再用 Layout 自己写 measure/place 逻辑。

DynamicLayout 加进来以后,ArkUI 这一侧可以分成三层看:

问题 Android / Compose 直觉 ArkUI 选择
固定线性、层叠、网格 LinearLayout / Row / Column / LazyGrid RowColumnStackGrid
同一批 child 在多种排列间切换 复用 child,只改外层布局策略 DynamicLayout + 内置 LayoutAlgorithm
非规则布局 ViewGroup.onMeasure/onLayout、Compose Layout CustomLayoutAlgorithm
画图形和像素 Canvas / Drawable / Compose Canvas ArkUI CanvasRenderNode

DynamicLayout 不是用来替代 RowColumnGrid 的新容器。更准确地说,它给 ArkUI 加了一个“布局算法插槽”:子组件仍然按声明式方式写,外层布局策略变成可替换对象。

CustomLayoutAlgorithm 已经碰到测量和摆放

CustomLayoutAlgorithm 已经进入测量和摆放层。它通过重写 onMeasureonLayout 工作:

这段代码可以按两个阶段读:

阶段 代码里做什么 对 Android 自定义 ViewGroup 的感觉
onMeasure 逐个拿到 child,给 child 一份测量约束,调用 measure(),最后设置容器自己的 measured size 接近 onMeasure()measureChild() / setMeasuredDimension() 的工作
onLayout 读取 child 测量后的尺寸,计算每个 child 的 x/y,调用 layout() 放到具体位置 接近 onLayout() 里给每个子 View 摆位置

下面的代码只做一件事:把所有子组件按固定 150 x 150 测量,然后从左到右排开。用途是把自定义布局算法的执行顺序露出来,实际项目里普通横向排列仍然优先用 RowLayoutAlgorithm

class MyCustomLayout extends CustomLayoutAlgorithm {
  onMeasure(self: FrameNode, constraint: LayoutConstraint): void {
    // 1. self 是当前 DynamicLayout 容器对应的 FrameNode。
    //    先拿到子节点数量,后面逐个给 child 下测量约束。
    const childCount = self.getChildrenCount()
    let totalWidth = 0
    let maxHeight = 0

    for (let i = 0; i < childCount; i++) {
      const child = self.getChild(i)
      if (!child) {
        continue
      }

      // 2. 这里把每个 child 都按 150 x 150 测量。
      //    真实瀑布流或标签云会根据容器宽度、内容尺寸生成不同约束。
      child.measure({
        maxSize: { width: 150, height: 150 },
        minSize: { width: 150, height: 150 },
        percentReference: constraint.percentReference
      })

      // 3. child.measure() 之后才能读取测量结果。
      //    这里把所有 child 宽度累加,并记录较大的高度。
      const childSize = child.getMeasuredSize()
      totalWidth += childSize.width
      maxHeight = Math.max(maxHeight, childSize.height)
    }

    // 4. 容器自己的测量尺寸不能超过外部传入的 maxSize。
    //    这一步对应 Android ViewGroup 里设置自身 measured width/height。
    self.setMeasuredSize({
      width: Math.min(totalWidth, constraint.maxSize.width),
      height: Math.min(maxHeight, constraint.maxSize.height)
    })
  }

  onLayout(self: FrameNode, position: Position): void {
    // 5. layout 阶段不再重新测量,只读取 onMeasure 阶段留下的尺寸。
    let offsetX = 0

    for (let i = 0; i < self.getChildrenCount(); i++) {
      const child = self.getChild(i)
      if (!child) {
        continue
      }

      // 6. 当前例子从左到右排列:第一个 child 放 x=0,
      //    后一个 child 的 x 等于前面宽度的累加。
      child.layout({ x: offsetX, y: 0 })
      offsetX += child.getMeasuredSize().width
    }

    // 7. 最后把容器本身放到父容器给定的位置。
    self.setLayoutPosition(position)
  }
}

这已经不是普通声明式排版了,写法很像 Android 自定义 ViewGroup:先拿 child,给 child 约束,测量 child,读测量结果,再摆位置。差别在于 ArkUI 这里面对的是 FrameNodeLayoutConstraint,布局算法以对象形式传给 DynamicLayout

限制也要跟着写在旁边:

限制 工程含义
布局算法类使用 @ObservedV2,不支持 @State 算法对象是可观察的布局策略,不是页面状态容器
onMeasure / onLayout 中不允许修改状态变量 测量和摆放阶段只计算尺寸与位置,不做业务状态变更

这类代码要理解测量约束、子组件尺寸、布局坐标和重排触发时机。普通业务页不要为了“用了新 API”而上自定义算法。

什么时候放进项目里

可以按场景拆:

场景 建议
页面从单栏变双栏,导航区域也变化 用条件渲染、媒体查询或窗口尺寸监听;把分支放在页面结构层
同一组卡片在列表和网格之间切换 DynamicLayout + ColumnLayoutAlgorithm / GridLayoutAlgorithm
切换布局时要保留输入框、开关、滚动状态 优先考虑 DynamicLayout,并保证 child identity 稳定
只是在 Row 和 Column 之间做一次简单切换 条件渲染也可以,别为了 API 新就引入复杂度
瀑布流、标签云、不规则卡片墙 评估 CustomLayoutAlgorithm
图表、涂鸦、像素级绘制 Canvas / RenderNode,不要强行塞进布局算法

放到最近几篇自定义控件讨论里,DynamicLayout 解决的是“布局策略复用”这一块。它不能自动解决 ArkUI 组合组件对基础属性开放不足的问题,也不会让跨仓组件库突然拥有 Android TextView 继承那种宽接口。

它有用的地方在布局策略。商品卡片瀑布流、标签云、仪表盘小组件墙这类场景,可以把“怎么测量和摆放”收进算法,页面里少写几套 if/else + Row/Column/Grid

总结

DynamicLayout 适合放在布局算法层处理问题:

  • 页面结构要换:用条件渲染、媒体查询和窗口尺寸监听。
  • children 不变,只换排列方式:用 DynamicLayout + 内置 LayoutAlgorithm
  • 内置算法不够:写 CustomLayoutAlgorithm,自己处理 measure/layout。
  • 像素绘制、图表和特效:走 Canvas / RenderNode,不要塞进布局算法。

HarmonyOS 自定义 UI 能力分在几层:组合组件负责结构封装,AttributeModifier / @Extend 处理属性复用,DynamicLayout / CustomLayoutAlgorithm 处理布局策略,Canvas / RenderNode 处理绘制。迁移或做组件治理时,先判断属于哪一类问题,再选方法。

参考材料

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

上一篇