我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~

前言

我是真心受够了那种“手机一份布局、Pad 再来一份、横竖屏各再修修补补”的循环加班。后来把 ArkUI 的布局容器、Flex/Grid 和设备自适配这一套认真啃完,我的页面结构一下“通了气”,同一套代码在手机、平板、甚至可折叠设备上都能优雅伸缩
  这篇就按你给的大纲来:布局容器 → Flex/Grid → 响应式布局方案。技术点我会围绕 Row/Column、Flex 属性、deviceType 适配,并给出可直接照抄的 ArkTS/ArkUI 代码。别担心,我会把“工程里真正有用的细节”都点明白,顺便加点“半夜改 UI 的心路历程”的吐槽,提神不伤身。

一、布局容器:Row / Column 的“家常菜”,但要会“调味”

先把基础打牢:ArkUI 的布局容器以 Row、Column、Stack、Flex、Grid 为核心。Row/Column 是最轻最稳的日常主力。

1.1 Row / Column 的常用心法

  • Row:横向布局,常用于工具条、卡片行、标签行。
  • Column:纵向布局,页面骨架、列表项、弹窗内容。
  • Stack:叠放,徽标、悬浮按钮、角标最常用。
  • 优先用 Row/Column 搭骨架,Flex/Grid 负责“灵活度和密度”。

一个标准页面骨架(标题 + 筛选条 + 内容 + 底部安全区):

@Entry
@Component
struct AdaptiveScaffold {
  @State title: string = 'Dashboard'
  @State filtersVisible: boolean = true

  build() {
    Column({ space: 12 }) {
      // 顶栏
      Row() {
        Text(this.title).fontSize(22).fontWeight(FontWeight.Bold)
        Blank() // 占位,让右侧按钮贴边
        Button(this.filtersVisible ? 'Hide Filters' : 'Show Filters')
          .onClick(() => this.filtersVisible = !this.filtersVisible)
      }.height(56).padding({ left: 16, right: 16 })

      // 筛选条(可折叠)
      if (this.filtersVisible) {
        Row({ space: 12 }) {
          Text('Keyword').width(80)
          TextInput({ placeholder: 'Search…' }).layoutWeight(1)
          Button('Apply')
        }.padding(16)
      }

      // 内容区
      Column() {
        // 待会儿放 Flex/Grid 的自适应内容
        ContentPanel()
      }.layoutWeight(1)

      // 底部安全区
      Row() {
        Text('© 2025 Example, Inc.').fontSize(12).opacity(0.6)
      }.height(40).padding({ left: 16, right: 16 })
    }
    .padding({ top: 12, bottom: 12 })
    .width('100%').height('100%')
  }
}

小技巧

  • .layoutWeight(1) 可让某一块吃满剩余空间,特别适合内容区域。
  • Blank() 是个好东西,简单拉开左右端
  • 把“边距、间距”抽成常量/主题 Token,后期改版省心。

二、Flex/Grid:当页面需要“更灵活”和“更高密度”

Row/Column 是家常便饭;FlexGrid 是让你在不同尺寸里优雅换姿势的绝招。

2.1 Flex:从“盒子挤挤挨挨”到“会呼吸的行列”

容器属性(常用)

  • .direction(FlexDirection.Row|Column):主轴方向
  • .wrap(FlexWrap.Wrap|NoWrap):是否换行
  • .justifyContent(FlexAlign.Start|End|Center|SpaceBetween|SpaceAround|SpaceEvenly)
  • .alignItems(ItemAlign.Start|Center|End|Stretch)
  • .alignContent(FlexAlign.Start|SpaceBetween|...)(多行时整行对齐)

子项属性(常用)

  • .flexGrow(n):主轴方向扩张
  • .flexShrink(n):主轴方向收缩
  • .flexBasis(length):主轴初始尺寸
  • .alignSelf(FlexAlignSelf.Start|Center|End|Stretch):覆盖容器 align

示例:带换行的卡片瀑布(轻量版)

@Component
struct ProductCard {
  title: string
  price: string
  build() {
    Column({ space: 6 }) {
      // 这里用占位图示意
      Stack() {
        Rect().fill(Color.Grey).height(96).width('100%').borderRadius(12)
        Text('IMG').fontSize(12).opacity(0.4)
      }
      Text(this.title).maxLines(2).fontSize(14)
      Text(this.price).fontColor(Color.Red).fontWeight(FontWeight.Medium)
    }
    .padding(12)
    .borderRadius(12)
    .backgroundColor('#F7F7F7')
  }
}

@Component
struct FlexGallery {
  @State items: Array<{ title: string, price: string }> = new Array(20).fill(0).map((_, i) => ({
    title: `Gadget #${i + 1}`, price: `$ ${(i + 1) * 3}.99`
  }))

  build() {
    // 容器:横向、允许换行、行间距
    Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
      ForEach(this.items, (it, i) => {
        // 子项:设定“最小宽度 + 自适应扩张”
        Column() {
          ProductCard({ title: it.title, price: it.price })
        }
        .width('48%')        // 小屏两列(简单粗暴),后面我们用响应式自动算
        .flexGrow(1)         // 允许扩张
        .margin({ bottom: 12, right: 8, left: 8 })
      }, it => it.title)
    }
    .justifyContent(FlexAlign.SpaceBetween)
  }
}

理解要点

  • Flex 适合“不规则宽高 + 行列会自动换行”的场景,心态上把它当“自适应行列流”。
  • 注意 .flexBasis().width() 的组合,width 定边界,flex 决定伸缩
  • 复杂网格(固定列数、跨行列)还是交给 Grid 更稳。

2.2 Grid:当你需要“正儿八经的网格系统”

ArkUI 的 Grid 更接近 CSS Grid 的思路:模板定义列/行间距网格项。最稳的“卡片宫格、仪表盘、多列信息流”,用它就完事。

容器属性(核心)

  • .columnsTemplate('1fr 1fr 1fr'):三等分列
  • .rowsTemplate('auto'):行高自动
  • .columnsGap(12) / .rowsGap(12):网格缝隙
  • .edgeEffect(EdgeEffect.None):滚动边缘效果(可选)

Grid + GridItem 示例:

@Component
struct DashboardGrid {
  @State cols: number = 2 // 默认两列,小屏友好

  private columnsTemplateFor(n: number): string {
    // 生成 "1fr 1fr ..." 模板字符串
    return new Array(n).fill('1fr').join(' ')
  }

  build() {
    Grid() {
      // 12 个假数据模块
      ForEach(new Array(12).fill(0).map((_, i) => i), (i: number) => {
        GridItem() {
          Column() {
            Text(`Module #${i + 1}`).fontWeight(FontWeight.Medium)
            Text('some metrics…').opacity(0.6).fontSize(12)
            Spacer()
            // 这里可以放图表、统计卡、微交互
          }
          .padding(16)
          .height(120)
          .borderRadius(16)
          .backgroundColor('#FAFAFA')
        }
      })
    }
    .columnsTemplate(this.columnsTemplateFor(this.cols))
    .rowsTemplate('auto')
    .columnsGap(12)
    .rowsGap(12)
    .padding(12)
  }
}

Grid VS Flex 怎么选?

  • 固定列数/需要跨行跨列/信息密度高 → 用 Grid
  • 尺寸弹性大/希望自然换行/项宽不一致 → 用 Flex
  • 绝大多数仪表盘、宫格、商详 SKU 区、媒体卡板 → Grid 更可控。

三、响应式布局方案:尺寸、方向、设备类型“三板斧”

真正的“自适应”,不是把 .width('48%') 改成 .width('33%') 就完了。要同时考虑

  1. 屏幕宽度断点(breakpoints);2) 横竖屏;3) 设备类型(deviceType)

我们来做一套通用响应式工具,让页面自动选择列数、边距、布局形态

3.1 拿到屏幕信息 & 设备类型

  • @ohos.display 获取宽高与像素密度;
  • @ohos.deviceInfo 获取 deviceType(如 phonetablet2in1tvwearable)。
// responsive/env.ts
import display from '@ohos.display';
import deviceInfo from '@ohos.deviceInfo';

export type DeviceKind = 'phone' | 'tablet' | '2in1' | 'tv' | 'wearable' | 'car' | 'unknown'

export function getDeviceKind(): DeviceKind {
  const t = (deviceInfo.deviceType || '').toLowerCase()
  if (t.includes('phone')) return 'phone'
  if (t.includes('tablet')) return 'tablet'
  if (t.includes('2in1')) return '2in1'
  if (t.includes('tv')) return 'tv'
  if (t.includes('wear')) return 'wearable'
  if (t.includes('car')) return 'car'
  return 'unknown'
}

export function getViewportVp() {
  // ArkUI 使用 vp 为布局单位;display 返回 px,记得除以 density
  const d = display.getDefaultDisplaySync()
  const vpW = d.width / d.densityPixels
  const vpH = d.height / d.densityPixels
  const landscape = vpW > vpH
  return { widthVp: vpW, heightVp: vpH, landscape }
}

注意:不同设备密度差很大,断点最好用 vp,别直接拿 px。


3.2 断点设计(Breakpoints):先定“规则”,再写“代码”

别怕“拍脑袋”,先定个够用的四档断点:

  • xs< 360vp(极小屏/小窗口)
  • sm360–599vp(常见手机竖屏)
  • md600–1023vp(横屏手机/小平板)
  • lg>= 1024vp(平板/桌面大窗)
// responsive/breakpoints.ts
export type BP = 'xs' | 'sm' | 'md' | 'lg'

export function resolveBP(widthVp: number): BP {
  if (widthVp < 360) return 'xs'
  if (widthVp < 600) return 'sm'
  if (widthVp < 1024) return 'md'
  return 'lg'
}

3.3 自适应列数 & 间距策略:Grid/Flex 两个方向都安排上

// responsive/layout.ts
import { resolveBP, BP } from './breakpoints'
import { getViewportVp, getDeviceKind } from './env'

export function gridColumns(): number {
  const { widthVp } = getViewportVp()
  const bp = resolveBP(widthVp)
  const device = getDeviceKind()

  // Pad/桌面更激进;穿戴/车载减配
  if (device === 'wearable') return 1
  if (device === 'tv') return 4

  switch (bp) {
    case 'xs': return 1
    case 'sm': return 2
    case 'md': return 3
    case 'lg': return device === 'tablet' || device === '2in1' ? 4 : 3
  }
}

export function gutters(): number {
  const { widthVp } = getViewportVp()
  const bp = resolveBP(widthVp)
  switch (bp) {
    case 'xs': return 6
    case 'sm': return 8
    case 'md': return 12
    case 'lg': return 16
  }
}

// Flex 卡片的“最小宽度”参考
export function cardMinWidthVp(): number {
  const { widthVp } = getViewportVp()
  const bp = resolveBP(widthVp)
  switch (bp) {
    case 'xs': return 160
    case 'sm': return 180
    case 'md': return 220
    case 'lg': return 260
  }
}

3.4 把响应式接到 Grid:模板字符串动态生成

// pages/ResponsiveGrid.ets
import { gridColumns, gutters } from '../responsive/layout'

@Component
struct ResponsiveGrid {
  @State cols: number = gridColumns()
  @State gap: number = gutters()

  aboutToAppear() {
    // 监听显示变化(如横竖屏切换/窗口变化),轻量轮询或注册系统回调
    // 简化起见,这里用定时刷新策略(工程里可用 display.on('change', ...))
    setInterval(() => {
      const c = gridColumns()
      const g = gutters()
      if (c !== this.cols || g !== this.gap) {
        this.cols = c; this.gap = g
      }
    }, 500)
  }

  private colsTpl(n: number): string {
    return new Array(n).fill('1fr').join(' ')
  }

  build() {
    Grid() {
      ForEach(new Array(20).fill(0).map((_, i) => i), (i: number) => {
        GridItem() {
          Column() {
            Text(`Tile #${i + 1}`).fontWeight(FontWeight.Medium)
            Spacer()
            Text('…content…').opacity(0.5)
          }
          .padding(16)
          .height(120)
          .borderRadius(12)
          .backgroundColor('#F3F4F6')
        }
      })
    }
    .columnsTemplate(this.colsTpl(this.cols))
    .rowsTemplate('auto')
    .columnsGap(this.gap)
    .rowsGap(this.gap)
    .padding(this.gap)
  }
}

效果

  • 小屏 1–2 列,转横屏或到 Pad 自动增长列数;
  • 间距随断点扩张,信息密度与可读性都照顾

3.5 把响应式接到 Flex:卡片“最小宽度 + 自适应扩张”

// pages/ResponsiveFlex.ets
import { cardMinWidthVp, gutters } from '../responsive/layout'

@Component
struct ResponsiveFlex {
  @State base: number = cardMinWidthVp()
  @State gap: number = gutters()

  aboutToAppear() {
    setInterval(() => {
      const b = cardMinWidthVp()
      const g = gutters()
      if (b !== this.base || g !== this.gap) { this.base = b; this.gap = g }
    }, 500)
  }

  build() {
    Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
      ForEach(new Array(30).fill(0).map((_, i) => i), (i: number) => {
        Column() {
          ProductCard({ title: `Item ${i + 1}`, price: `$ ${(i + 1) * 2}.99` })
        }
        .width(this.base)  // 最小卡片宽度(断点决定)
        .flexGrow(1)       // 有空间就扩张
        .margin({ right: this.gap / 2, left: this.gap / 2, bottom: this.gap })
      })
    }
    .padding(this.gap)
    .justifyContent(FlexAlign.Start)
    .alignItems(ItemAlign.Stretch)
  }
}

理解

  • width(this.base) 给卡片“底线”;.flexGrow(1) 允许放大填空;
  • 整体视觉来自 “最小宽度 + 行内流动”,比 Grid 更“自然”。

3.6 deviceType 适配:不仅是“列数”,是“布局形态”

手机上“单栏滚动”,到了平板就不该还挤成一坨。我们来做一个双栏主从布局:左边列表、右边详情;在手机上自动收敛为单页导航。

// pages/MasterDetail.ets
import { getDeviceKind, getViewportVp } from '../responsive/env'
import { resolveBP } from '../responsive/breakpoints'

@Component
struct MasterDetail {
  @State selectedId: number | null = null

  private isTwoPane(): boolean {
    const { widthVp } = getViewportVp()
    const bp = resolveBP(widthVp)
    const device = getDeviceKind()
    // 平板/大窗 或 2in1 横屏 → 双栏
    return bp === 'lg' || device === 'tablet' || device === '2in1'
  }

  build() {
    if (this.isTwoPane()) {
      // 双栏
      Row() {
        // 左:列表
        List() {
          ForEach(new Array(50).fill(0).map((_, i) => i), (i: number) => ListItem() {
            Row() {
              Text(`Item ${i + 1}`)
              Blank()
              if (this.selectedId === i) Text('●').fontColor(Color.Red)
            }.onClick(() => this.selectedId = i)
             .padding(12)
          })
        }.width('36%')

        // 右:详情
        Column() {
          if (this.selectedId == null) {
            Text('Select an item').opacity(0.5).fontSize(16)
          } else {
            Text(`Detail of Item ${this.selectedId + 1}`).fontSize(20).fontWeight(FontWeight.Bold)
            Text('…long content…').margin({ top: 12 })
          }
        }.padding(16).layoutWeight(1)
      }
      .height('100%')
    } else {
      // 单页:点击跳详情
      List() {
        ForEach(new Array(50).fill(0).map((_, i) => i), (i: number) => ListItem() {
          Row() {
            Text(`Item ${i + 1}`)
            Blank()
            Button('Open').onClick(() => router.pushUrl({ url: 'pages/Detail', params: { id: i } }))
          }.padding(12)
        })
      }
    }
  }
}

要点

  • deviceType + bp 双重判断,比“只看宽度”更贴近用户心智。
  • 双栏不是把两个 Column 硬塞在 Row 里那么简单,要给列表和详情合理的宽度比例,常见 3040% : 6070%。

四、把这三件事揉到一个“可复用”的自适应页

下面是一个集合页:顶部工具条 + 响应式 Grid 主体 + deviceType 驱动的侧边栏(Pad 上显示,手机上隐藏)。拎出去随用随贴。

// pages/AdaptiveDashboard.ets
import { gridColumns, gutters } from '../responsive/layout'
import { getDeviceKind, getViewportVp } from '../responsive/env'
import { resolveBP } from '../responsive/breakpoints'

@Component
struct AdaptiveDashboard {
  @State cols: number = gridColumns()
  @State gap: number = gutters()
  @State showFilters: boolean = true

  private isSidePanelVisible(): boolean {
    const { widthVp } = getViewportVp()
    const bp = resolveBP(widthVp)
    const device = getDeviceKind()
    return device === 'tablet' || device === '2in1' || bp === 'lg'
  }

  aboutToAppear() {
    setInterval(() => {
      const c = gridColumns(), g = gutters()
      if (c !== this.cols || g !== this.gap) { this.cols = c; this.gap = g }
    }, 500)
  }

  private colsTpl(n: number) { return new Array(n).fill('1fr').join(' ') }

  build() {
    Row() {
      // 侧栏(Pad/大屏)
      if (this.isSidePanelVisible()) {
        Column({ space: 12 }) {
          Text('Filters').fontWeight(FontWeight.Medium)
          TextInput({ placeholder: 'Keyword' })
          Button('Apply')
        }
        .width(240)
        .padding(12)
        .backgroundColor('#F5F5F5')
      }

      // 主体
      Column({ space: 12 }) {
        Row() {
          Text('Overview').fontSize(20).fontWeight(FontWeight.Bold)
          Blank()
          if (!this.isSidePanelVisible()) {
            Button(this.showFilters ? 'Hide Filters' : 'Show Filters')
              .onClick(() => this.showFilters = !this.showFilters)
          }
        }.padding({ left: this.gap, right: this.gap, top: 8 })

        if (!this.isSidePanelVisible() && this.showFilters) {
          Row({ space: 8 }) {
            Text('Keyword').width(80)
            TextInput({ placeholder: '...' }).layoutWeight(1)
            Button('Apply')
          }.padding({ left: this.gap, right: this.gap })
        }

        Grid() {
          ForEach(new Array(16).fill(0).map((_, i) => i), (i: number) => {
            GridItem() {
              Column() {
                Text(`Card ${i + 1}`).fontWeight(FontWeight.Medium)
                Spacer()
                Text('metrics…').opacity(0.5)
              }
              .padding(16)
              .height(120)
              .borderRadius(12)
              .backgroundColor('#FFFFFF')
              .shadow({ radius: 6, color: '#00000022', offsetX: 0, offsetY: 2 })
            }
          })
        }
        .columnsTemplate(this.colsTpl(this.cols))
        .rowsTemplate('auto')
        .columnsGap(this.gap)
        .rowsGap(this.gap)
        .padding(this.gap)
        .layoutWeight(1)
      }
      .layoutWeight(1)
    }
    .height('100%')
    .backgroundColor('#FAFAFC')
  }
}

看点

  • 手机上:过滤器以“折叠条”形式出现;
  • 平板上:过滤器变成侧栏“常驻”,主体 Grid 自调列数;
  • 代码逻辑集中在 isSidePanelVisible / gridColumns / gutters 这三处,改断点不改页面

五、常见坑位 & 处理建议(都是血泪史)

  1. Flex 子项宽度同时设置了 widthflexBasis

    • 建议:选一个主语。多数场景**定 width + flexGrow(1)**就够用。
  2. Grid 列数随便写死

    • 建议:抽成函数 columnsTemplateFor(n),列数走 gridColumns()
  3. 只看屏幕宽度,忽略 deviceType

    • 建议:折叠屏、可穿戴、车机的交互心智完全不同,至少做一下类型分流
  4. 横竖屏切换没有刷新 Layout

    • 建议:示例里用 setInterval 简化;工程中优先监听系统 display/window 变化事件,只在变化时刷新
  5. Row/Column 深层嵌套过多导致层级爆炸

    • 建议:抽组件 + 适时引入 Grid 把“多列”收拾干净;Stack 代替多余的装饰 Row。
  6. 把“样式变量”写死到处都是

    • 建议:把边距/圆角/阴影抽成主题常量,响应式只改一个源

六、工程落地清单(你可以直接拿这页做项目模板)

  • /responsive

    • env.ts:deviceType + viewport(vp)
    • breakpoints.ts:断点
    • layout.ts:列数/间距/卡片最小宽度
  • /pages

    • AdaptiveScaffold.ets:骨架
    • ResponsiveGrid.ets / ResponsiveFlex.ets:两种布局
    • MasterDetail.ets:双栏主从
    • AdaptiveDashboard.ets:侧栏 + 响应式 Grid 一体页
  • /components

    • ProductCard.etsMetricCard.ets 等卡片
  • /theme

    • spacing.tsradius.tsshadow.ts(按断点导出 token)

七、给未来的自己(和同事)的三句话

  1. Row/Column 打基础,Flex/Grid 做形变:别用一个容器打天下。
  2. 断点先定规则,再写代码:可维护性来自“抽象的地方”,不是页面里到处的 if/else。
  3. deviceType 不是摆设:平板与手机的交互诉求天差地别,敢于在布局形态上“做加法”。

八、附:快速参考手册(贴墙上就行)

Row / Column

  • Row({ space })Column({ space })
  • .justifyContent(主轴对齐)、.alignItems(交叉轴对齐)
  • .layoutWeight(1):吃满剩余空间

Flex(容器)

  • .direction(FlexDirection.Row|Column)
  • .wrap(FlexWrap.Wrap)
  • .justifyContent(FlexAlign.XXX)
  • .alignItems(ItemAlign.XXX)

Flex(子项)

  • .flexGrow(n).flexShrink(n).flexBasis(len)
  • .alignSelf(FlexAlignSelf.XXX)

Grid

  • .columnsTemplate('1fr 1fr ...')
  • .rowsTemplate('auto')
  • .columnsGap(n) / .rowsGap(n)
  • GridItem():网格项容器

响应式工具

  • getViewportVp():拿 vp 的宽高与方向
  • getDeviceKind()phone/tablet/...
  • resolveBP(widthVp)xs/sm/md/lg
  • gridColumns() / gutters() / cardMinWidthVp()

结语:自适应不是“加班的借口”,是“下班的资本”

一旦你把 Row/Column → Flex/Grid → 响应式规则 这条链路理顺,ArkUI 的页面就会从“靠堆条件分支”变成“靠规则自动伸缩”。同样的设计稿,在手机上“轻快不拥挤”,在平板上“信息密度恰到好处”,在可折叠设备上“转身不丢脸”。
  下次产品说“咱顺手适配下 Pad 吧”,你不需要叹气,只需要微微一笑:“早就适配好了~”

(未完待续)

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐