在这里插入图片描述
在这里插入图片描述

鸿蒙 ArkTS 中实现 Flutter 风格计算器键盘布局

—— SizedBox + Expanded 布局技术在 HarmonyOS 中的全面应用

目录

  1. 引言:问题的提出
  2. Flutter 布局核心概念回顾
    • 2.1 SizedBox —— 固定尺寸约束
    • 2.2 Expanded —— 弹性等分空间
    • 2.3 Flex 布局体系
  3. 鸿蒙 ArkTS 布局体系概览
    • 3.1 ArkUI 声明式范式
    • 3.2 Row / Column / Flex 容器
    • 3.3 .height() / .width() 尺寸方法
    • 3.4 .layoutWeight() 弹性权重
  4. Flutter → ArkTS 布局映射对照表
  5. 计算器键盘:需求分析与设计
    • 5.1 功能需求
    • 5.2 布局需求
    • 5.3 视觉设计
  6. 代码逐层深度解析
    • 6.1 项目文件结构
    • 6.2 资源文件配置(color.json / float.json)
    • 6.3 @Component 组件结构
    • 6.4 状态管理与数据流
    • 6.5 键盘数据定义
    • 6.6 UI 构建 —— 显示区域
    • 6.7 UI 构建 —— 键盘网格(核心布局技术)
    • 6.8 按钮点击交互逻辑
    • 6.9 四则运算引擎
    • 6.10 数字格式化
  7. 布局技术深度剖析
    • 7.1 SizedBox(height:60) → .height(60) 统一按钮高度
    • 7.2 Expanded → .layoutWeight(1) 按钮等宽
    • 7.3 Expanded(flex:2) → .layoutWeight(2) 双倍宽度
    • 7.4 间距与内边距的三种实现方式
    • 7.5 弹性空间分配与 flex 优先级
  8. 实战技巧与最佳实践
    • 8.1 使用资源引用替代硬编码
    • 8.2 颜色主题的模块化管理
    • 8.3 ForEach 动态渲染的性能考量
    • 8.4 高分辨率屏幕适配
    • 8.5 深色模式适配
  9. 完整代码清单
  10. 调试与验证
  11. 总结与展望

1. 引言:问题的提出

在移动端应用开发中,等高等宽按钮网格(Even Grid of Buttons) 是一种极其常见的 UI 模式。最典型的场景就是计算器键盘虚拟键盘。这类 UI 要求:

  • 所有按钮高度完全一致,视觉上整齐划一;
  • 同一行内按钮宽度完全相等,形成规整的网格;
  • 某些按钮可以跨列(如计算器中的 “0” 键占两列宽度);
  • 按钮之间有均匀的间距
  • 整个键盘区域应自适应屏幕宽度,而不是固定像素。

在 Flutter 中,这个问题有非常优雅的解决方案:

Row(
  children: [
    Expanded(child: SizedBox(height: 60, child: ElevatedButton(...))),
    Expanded(child: SizedBox(height: 60, child: ElevatedButton(...))),
    Expanded(child: SizedBox(height: 60, child: ElevatedButton(...))),
    Expanded(child: SizedBox(height: 60, child: ElevatedButton(...))),
  ],
)

其中 SizedBox(height: 60) 确保了每个按钮的高度统一,而 Expanded 让所有按钮在父容器的宽度方向上均匀等分

但当我们转向 HarmonyOS 的 ArkTS 开发时,问题来了:ArkTS 中有没有对应的布局机制? 答案是肯定的。ArkUI 作为 HarmonyOS 的原生声明式 UI 框架,提供了与 Flutter 高度相似但又有自身特点的布局能力。

本文将以一个完整的计算器键盘应用为载体,深入讲解如何运用 ArkTS 中的 .height(60).layoutWeight(1) 来实现 Flutter 中 SizedBox(height:60) + Expanded 同样的布局效果。全文将通过逐行代码解析布局机制剖析实战技巧分享三个维度,帮助读者彻底掌握这一布局技术。


2. Flutter 布局核心概念回顾

在深入 ArkTS 之前,有必要先回顾一下 Flutter 中与本文相关的几个核心布局 Widget。

2.1 SizedBox —— 固定尺寸约束

SizedBox 是 Flutter 中最基础的尺寸约束组件之一。它的作用是为子组件施加一个固定的宽度和/或高度约束

SizedBox(
  width: 200,
  height: 60,
  child: ElevatedButton(...),
)

关键特性:

  • 如果只指定 height,则宽度由父容器决定(或由 child 决定);
  • 如果只指定 width,则高度由父容器决定(或由 child 决定);
  • 如果同时指定 widthheight,则 child 被强制约束在这个尺寸内;
  • SizedBox 本身也是一个 Flex 中的布局单元,可以接受 Expanded 的弹性约束。

对于计算器键盘,我们只需要固定高度(height: 60),宽度则交给 Expanded 去弹性分配。

2.2 Expanded —— 弹性等分空间

Expanded 是 Flutter 中用于 Flex 布局体系RowColumnFlex)的关键组件。它将父容器在主轴方向上的剩余空间,按照所有 Expanded 子组件的 flex 权重比例进行分配。

Row(
  children: [
    Expanded(flex: 1, child: ...),   // 占 1/4
    Expanded(flex: 1, child: ...),   // 占 1/4
    Expanded(flex: 1, child: ...),   // 占 1/4
    Expanded(flex: 1, child: ...),   // 占 1/4
  ],
)

关键特性:

  • Expanded 必须位于 RowColumnFlex 的直接子级;
  • flex 参数默认为 1,表示按比例分配;
  • 当子组件需要占据双倍空间时,只需设置 flex: 2
  • ExpandedSizedBox 经常配合使用——Expanded 管宽度方向,SizedBox 管高度方向。

2.3 Flex 布局体系

Flutter 的 Flex 布局体系包括三个层次:

  1. Flex 容器Row(水平方向)、Column(垂直方向)、Flex(自定义方向)
  2. 弹性子项Expanded(按比例分配剩余空间)、Flexible(可收缩但不强制填满)
  3. 非弹性子项:普通 Widget,按自身大小占据空间

整个体系的核心公式是:

子项_i 的宽度 = 父容器宽度 × (flex_i / ∑所有 flex)

这就是计算器键盘等宽布局的数学基础。


3. 鸿蒙 ArkTS 布局体系概览

3.1 ArkUI 声明式范式

HarmonyOS 的 ArkUI 使用 ArkTS(基于 TypeScript 扩展)作为开发语言,采用声明式 UI 范式。一个典型的 ArkTS 组件结构如下:

@Component
struct MyComponent {
  @State count: number = 0;

  build() {
    Column() {
      Text('Hello')
        .fontSize(20)
      Button('Click')
        .onClick(() => { this.count++ })
    }
    .width('100%')
    .height('100%')
  }
}

与 Flutter 的声明式语法非常相似:

  • Flutter 使用 child: / children: 参数嵌套;
  • ArkTS 使用花括号块 { } 嵌套子组件;
  • Flutter 用 . 链式调用设置属性;
  • ArkTS 同样用 . 链式调用设置属性。

3.2 Row / Column / Flex 容器

ArkTS 同样提供了三种 Flex 布局容器:

容器 主轴方向 对应 Flutter
Row() 水平(从左到右) Row
Column() 垂直(从上到下) Column
Flex() 自定义方向 Flex

基本用法:

Row({ space: 8 }) {
  Button('1').layoutWeight(1)
  Button('2').layoutWeight(1)
  Button('3').layoutWeight(1)
}
.width('100%')
.height(60)
  • space 参数设置子组件之间的间距,相当于 Flutter 的 SizedBox(width: 8) 间隔;
  • layoutWeight(1) 是 ArkTS 中实现弹性等分的关键方法。

3.3 .height() / .width() 尺寸方法

在 ArkTS 中,每个组件都可以通过链式方法设置尺寸:

Button('AC')
  .height(60)      // 固定高度 60vp
  .width(80)       // 固定宽度 80vp
  .width('100%')   // 占父容器 100% 宽度
  .constraintSize({ minHeight: 48, maxHeight: 72 })  // 尺寸范围约束
  • 数值:表示 vp(虚拟像素)值;
  • 字符串百分比:如 '100%',表示占父容器尺寸的百分比;
  • Resource 引用:如 $r('app.float.calc_btn_height'),引用资源文件中的定义。

3.4 .layoutWeight() 弹性权重

layoutWeight(n) 是 ArkTS 中实现弹性布局的核心方法,它直接对应 Flutter 的 Expanded(flex: n)

Row() {
  Button('1').layoutWeight(1)
  Button('2').layoutWeight(2)
  Button('3').layoutWeight(1)
}

在上面的例子中,如果 Row 的宽度是 400vp,那么:

  • 按钮 ‘1’ 的宽度 = 400 × (1 / (1+2+1)) = 100vp
  • 按钮 ‘2’ 的宽度 = 400 × (2 / 4) = 200vp
  • 按钮 ‘3’ 的宽度 = 400 × (1 / 4) = 100vp

关键特性:

  • layoutWeight 只对父容器是 RowColumnFlex 时生效;
  • 默认值为 0(不参与弹性分配);
  • 权重为 0 的组件按自身内容大小或设置的固定尺寸显示;
  • 所有权重值必须为正整数。

4. Flutter → ArkTS 布局映射对照表

下表是本文涉及的 Flutter 布局概念在 ArkTS 中的完整映射:

Flutter ArkTS 说明
SizedBox(height: 60) .height(60) 固定高度
SizedBox(width: 200) .width(200) 固定宽度
Expanded(flex: 1) .layoutWeight(1) 等宽弹性分配
Expanded(flex: 2) .layoutWeight(2) 双倍弹性分配
Row(children: [...]) Row() { ... } 水平弹性容器
Column(children: [...]) Column() { ... } 垂直弹性容器
SizedBox(width: 8) Row({ space: 8 }) 的 space 子组件间距
EdgeInsets.all(8) .padding(8) 内边距
EdgeInsets.only(left: 16, right: 16) .padding({ left: 16, right: 16 }) 指定方向内边距
Text('Hello', textAlign: TextAlign.end) Text('Hello').textAlign(TextAlign.End) 文本对齐
Container(color: Colors.blue) .backgroundColor(...) 背景色
Container(decoration: BoxDecoration(borderRadius: ...)) .borderRadius(...) 圆角(Button 直接支持)
Flexible(flex: 1) .layoutWeight(1) + .constraintSize() 弹性 + 尺寸约束
MediaQuery.of(context).size.width 不需要,父容器自动传递宽度 自适应屏幕宽度
const Color(0xFF2D2D44) $r('app.color.calc_btn_number') 资源引用的颜色

最重要的两个对应关系:

  1. 统一按钮高度
    Flutter: SizedBox(height: 60, child: Button(...))
    ArkTS: Button(...).height(60)

  2. 等宽分布
    Flutter: Expanded(child: Button(...))
    ArkTS: Button(...).layoutWeight(1)


5. 计算器键盘:需求分析与设计

5.1 功能需求

本项目实现一个标准计算器,支持以下功能:

功能 实现方式
数字输入(0-9) 点击数字键追加到显示文本
小数点(.) 防止重复输入小数点
加(+) 二目运算符,累加
减(−) 二目运算符,累减
乘(×) 二目运算符,累乘
除(÷) 二目运算符,除零保护
等号(=) 执行当前运算并显示结果
清零(AC) 重置所有状态
取反(±) 切换正负号
百分比(%) 除以 100
表达式预览 显示上一次运算的完整表达式
结果格式化 去除多余小数尾随零

5.2 布局需求

需求 实现手段
所有按钮高度统一为 60vp .height(60) 应用于每个按钮
同行按钮宽度等分 .layoutWeight(1) 应用于每个按钮
“0” 键占双倍宽度 .layoutWeight(2) 应用于 “0” 按钮
按钮间有 6vp 均匀间距 Row({ space: 6 })Column({ space: 6 })
键盘区域有 8vp 内边距 .padding(8)
键盘自适应屏幕宽度 父容器 width('100%') + 子组件 layoutWeight
显示区域弹性占满剩余空间 .layoutWeight(1) 在 Column 中
按钮圆角 .borderRadius(12)

5.3 视觉设计

配色方案采用深色计算器风格:

元素 颜色 色值
背景 深黑蓝 #0d0d1a
显示区背景 深蓝黑 #1a1a2e
主显示文本 白色 #FFFFFF
结果预览文本 橙色 #ff9f0a
数字按钮 深灰蓝 #2d2d44
数字按钮文字 白色 #FFFFFF
运算符按钮 橙色 #ff9f0a
运算符按钮文字 白色 #FFFFFF
功能按钮 浅灰 #a5a5a5
功能按钮文字 黑色 #000000

6. 代码逐层深度解析

6.1 项目文件结构

d38/
├── entry/
│   └── src/main/
│       ├── ets/pages/
│       │   └── Index.ets          ← 主页面(计算器键盘)
│       └── resources/base/element/
│           ├── color.json          ← 颜色资源定义
│           └── float.json          ← 尺寸资源定义
├── AppScope/                       ← 应用级配置
├── build-profile.json5             ← 构建配置
└── hvigor/                         ← 构建工具配置

6.2 资源文件配置(color.json / float.json)

color.json 定义了所有颜色常量:

{
  "color": [
    {"name": "calc_display_bg",       "value": "#1a1a2e"},
    {"name": "calc_display_text",     "value": "#FFFFFF"},
    {"name": "calc_btn_number",       "value": "#2d2d44"},
    {"name": "calc_btn_operator",     "value": "#ff9f0a"},
    {"name": "calc_btn_function",     "value": "#a5a5a5"},
    {"name": "calc_background",       "value": "#0d0d1a"},
    {"name": "calc_result_text",      "value": "#ff9f0a"}
  ]
}

通过 $r('app.color.calc_btn_number') 在代码中引用。

float.json 定义了所有尺寸常量:

{
  "float": [
    {"name": "calc_btn_height",      "value": "60vp"},
    {"name": "calc_btn_font_size",   "value": "28fp"},
    {"name": "calc_gap",             "value": "4vp"},
    {"name": "calc_btn_radius",      "value": "12vp"}
  ]
}

通过 $r('app.float.calc_btn_height') 引用。

  • vp(virtual pixel):虚拟像素,适配不同屏幕密度;
  • fp(font pixel):字体像素,跟随系统字体缩放设置。

为什么使用资源文件而非硬编码?

  1. 主题管理:所有颜色/尺寸集中管理,修改一处全局生效;
  2. 多设备适配:可以在不同设备类型(phone / tablet / wearable)的 resources 目录下提供不同的值;
  3. 国际化:支持多语言资源;
  4. 构建时校验:资源引用错误会在编译时被发现。

6.3 @Component 组件结构

@Entry
@Component
struct Index {
  // 状态变量
  @State displayText: string = '0';
  @State resultText: string = '';
  @State isNewInput: boolean = true;

  private firstOperand: number = 0;
  private operator: string = '';
  private waitingForSecond: boolean = false;

  // 键盘数据
  private btnRows: string[][] = [ ... ];

  build() { ... }

  // 业务方法
  onBtnClick(label: string): void { ... }
  calculate(): void { ... }
  formatNumber(n: number): string { ... }
  getBtnType(label: string): string { ... }
  getBtnColor(label: string): ResourceColor { ... }
}

关键注解解释:

  • @Entry:标记此组件为页面入口(类似 Flutter 的 runApp 中的根组件);
  • @Component:声明这是一个可复用的 UI 组件(类似 Flutter 的 StatefulWidget);
  • @State:标记状态变量,当其值变化时自动触发 UI 重绘(类似 Flutter 的 setState)。

6.4 状态管理与数据流

计算器使用六个状态变量来管理全部交互逻辑:

变量 类型 初始值 作用
displayText string '0' 主显示区的当前数字文本
resultText string '' 表达式预览文本(如 “12 + 34 =”)
isNewInput boolean true 标记下次输入是否覆盖当前显示
firstOperand number 0 保存第一个操作数
operator string '' 保存当前运算符
waitingForSecond boolean false 标记是否在等待第二个操作数输入

状态流转图:

用户点击数字 → isNewInput? → 是:覆盖显示 → 否:追加显示
用户点击运算符 → firstOperand = displayText → waitingForSecond = true
用户点击 = → 执行运算 → 显示结果 → 更新 firstOperand
用户点击 AC → 全部重置为初始值

6.5 键盘数据定义

private btnRows: string[][] = [
  ['AC', '±', '%', '÷'],
  ['7', '8', '9', '×'],
  ['4', '5', '6', '−'],
  ['1', '2', '3', '+'],
  ['0', '.', '=']
];

这是一个二维数组,每一行对应键盘的一行。设计考虑:

  1. 数据驱动 UI:通过 ForEach 遍历数组自动渲染,新增/修改键盘布局只需改数据,无需改 UI 代码;
  2. 前 4 行:4 列 × 4 行 = 16 个按钮,每个按钮 layoutWeight(1)
  3. 第 5 行:特殊行,“0” 占 layoutWeight(2),“.” 和 “=” 各占 layoutWeight(1)

6.6 UI 构建 —— 显示区域

// ---- 显示区域 ----
Column() {
  // 结果预览行(上栏)
  Row() {
    Text(this.resultText)
      .fontSize($r('app.float.calc_result_font_size'))
      .fontColor($r('app.color.calc_result_text'))
      .textAlign(TextAlign.End)
      .width('100%')
  }
  .width('100%')
  .layoutWeight(1)
  .alignItems(VerticalAlign.Bottom)
  .padding({ bottom: 4 })

  // 主显示行(下栏)
  Row() {
    Text(this.displayText)
      .fontSize($r('app.float.calc_display_font_size'))
      .fontColor($r('app.color.calc_display_text'))
      .textAlign(TextAlign.End)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')
  }
  .width('100%')
  .layoutWeight(1)
  .alignItems(VerticalAlign.Bottom)
}
.width('100%')
.layoutWeight(1)        // 显示区域弹性占满剩余空间
.padding({ left: 16, right: 16, bottom: 8, top: 16 })
.backgroundColor($r('app.color.calc_display_bg'))

布局要点:

  1. 弹性填充:显示区域的 Column 设置了 .layoutWeight(1),意味着它占据键盘区域之上剩余空间的全部(键盘区域没有 layoutWeight,因此按自身内容高度 5×60+间距 占据底部,显示区域弹性撑满剩余空间)。

  2. 双行显示

    • 上栏(resultText):显示表达式,字号 40fp,橙色,右对齐;
    • 下栏(displayText):显示当前数字,字号 56fp,白色,右对齐;
    • 两行各占显示区域的一半(各 layoutWeight(1))。
  3. 溢出处理.maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) 确保数字过长时自动省略,而不是换行破坏布局。

  4. 对齐方式.alignItems(VerticalAlign.Bottom) 让文本在 Row 中底部对齐,模拟计算器显示器的底部对齐效果。

6.7 UI 构建 —— 键盘网格(核心布局技术)

这是本文的核心部分,完整展示了如何用 .height(60) + .layoutWeight(1) 实现等高等宽按钮网格。

// ---- 键盘区域 ----
Column({ space: 6 }) {
  // 前4行:4个等宽按钮
  ForEach(this.btnRows.slice(0, 4), (row: string[]) => {
    Row({ space: 6 }) {
      ForEach(row, (label: string) => {
        Button(label)
          .height(60)                    // ← ★ 统一按钮高度(SizedBox)
          .layoutWeight(1)               // ← ★ 按钮等宽(Expanded)
          .fontSize($r('app.float.calc_btn_font_size'))
          .fontColor(this.getBtnType(label) === 'function'
            ? $r('app.color.calc_btn_function_text')
            : $r('app.color.calc_btn_operator_text'))
          .backgroundColor(this.getBtnColor(label))
          .borderRadius($r('app.float.calc_btn_radius'))
          .onClick(() => { this.onBtnClick(label); })
      }, (label: string) => label)
    }
    .width('100%')
    .height(60)                          // ← ★ 统一行高
  }, (row: string[]) => row[0])

  // 第5行(特殊行)
  Row({ space: 6 }) {
    Button('0')
      .height(60)
      .layoutWeight(2)                   // ← ★ 双倍宽度
      ...
    Button('.')
      .height(60)
      .layoutWeight(1)
      ...
    Button('=')
      .height(60)
      .layoutWeight(1)
      ...
  }
  .width('100%')
  .height(60)
}
.width('100%')
.padding(8)
.backgroundColor($r('app.color.calc_background'))

逐层布局展开:

第一层:Column({ space: 6 }) —— 垂直容器

  • 主轴方向:垂直(从上到下);
  • space: 6:每行按钮之间的垂直间距为 6vp;
  • 子组件:5 个 Row(每行键盘);
  • 自身宽度:width('100%'),占满父容器宽度。

第二层:Row({ space: 6 }) —— 水平容器(每行)

  • 主轴方向:水平(从左到右);
  • space: 6:每个按钮之间的水平间距为 6vp;
  • 子组件:多个 Button,每个都设置了 .layoutWeight(1)
  • 自身高度:.height(60),统一行高 60vp。

第三层:Button.label —— 最终按钮

  • .height(60):固定按钮高度 60vp(等价于 Flutter 的 SizedBox(height: 60));
  • .layoutWeight(1):按钮在 Row 中等宽分布(等价于 Flutter 的 Expanded)。

布局计算公式:

假设屏幕宽度为 W,Row 的 space 为 6,每行有 4 个按钮:

可用宽度 = W - 左右 padding(8×2) - 3个间隙(6×3) = W - 34
每个按钮宽度 = 可用宽度 / 4

对于第 5 行(“0” 占双倍宽度):

可用宽度 = W - 34(同上)
"0" 宽度 = 可用宽度 × (2/4) = 可用宽度 / 2
"." 宽度 = 可用宽度 × (1/4)
"=" 宽度 = 可用宽度 × (1/4)

这种布局方式完美适配任何屏幕宽度,无需写任何媒体查询代码。

6.8 按钮点击交互逻辑

onBtnClick(label: string): void {
  switch (label) {
    case 'AC':
      // 重置所有状态
      this.displayText = '0';
      this.resultText = '';
      this.firstOperand = 0;
      this.operator = '';
      this.waitingForSecond = false;
      this.isNewInput = true;
      break;

    case '±':
      // 取反:切换正负号
      if (this.displayText !== '0') {
        this.displayText = this.displayText.startsWith('-')
          ? this.displayText.substring(1)
          : '-' + this.displayText;
      }
      break;

    case '%':
      // 百分比:除以 100
      { const val = parseFloat(this.displayText) / 100;
        this.displayText = this.formatNumber(val); }
      break;

    case '÷': case '×': case '−': case '+':
      // 运算符:保存第一个操作数和运算符
      if (this.operator !== '' && !this.waitingForSecond) {
        this.calculate();  // 连续运算
      }
      this.firstOperand = parseFloat(this.displayText);
      this.operator = label;
      this.waitingForSecond = true;
      this.isNewInput = true;
      break;

    case '=':
      // 等号:执行运算
      if (this.operator !== '') {
        this.calculate();
        this.operator = '';
        this.waitingForSecond = false;
        this.isNewInput = true;
      }
      break;

    case '.':
      // 小数点:防止重复输入
      if (!this.displayText.includes('.')) {
        this.displayText += '.';
      }
      break;

    default:  // 数字 0-9
      if (this.isNewInput || this.waitingForSecond) {
        this.displayText = label;
        this.isNewInput = false;
        this.waitingForSecond = false;
      } else {
        this.displayText = this.displayText === '0'
          ? label
          : this.displayText + label;
      }
      break;
  }
}

交互设计要点:

  1. 连续运算支持:点击 5 + 3 - 2 =,会依次执行 5+3=8,然后 8-2=6;
  2. 防重复小数点.includes('.') 检查,防止输入 “3.14.”;
  3. 新输入覆盖:运算完成后,isNewInput = true,用户直接输入数字会覆盖旧结果,而不是追加;
  4. 前导零消除:如果当前是 ‘0’,再按数字时替换为按下的数字,而非追加成 ‘05’。

6.9 四则运算引擎

calculate(): void {
  const second = parseFloat(this.displayText);
  let result: number = 0;

  switch (this.operator) {
    case '÷':
      result = second !== 0 ? this.firstOperand / second : 0;
      break;
    case '×':
      result = this.firstOperand * second;
      break;
    case '−':
      result = this.firstOperand - second;
      break;
    case '+':
      result = this.firstOperand + second;
      break;
    default:
      result = second;
      break;
  }

  this.resultText = this.formatNumber(this.firstOperand)
    + ' ' + this.operator + ' '
    + this.formatNumber(second) + ' =';
  this.displayText = this.formatNumber(result);
  this.firstOperand = result;
}

关键实现细节:

  1. 除零保护second !== 0 ? ... : 0,防止 divide by zero 导致应用崩溃;
  2. 表达式预览:运算后将完整表达式(如 “12 + 34 =”)保存到 resultText 供显示;
  3. 结果累积:将 firstOperand 更新为本次运算结果,支持连续运算;
  4. 格式化输出:所有数字都通过 formatNumber 处理后再显示。

6.10 数字格式化

formatNumber(n: number): string {
  if (Number.isInteger(n) && Math.abs(n) < 1e15) {
    return n.toString();
  }
  const s = n.toFixed(8);
  let trimmed = s;
  while (trimmed.includes('.') && (trimmed.endsWith('0') || trimmed.endsWith('.'))) {
    trimmed = trimmed.substring(0, trimmed.length - 1);
  }
  return trimmed;
}

设计考量:

  1. 整数优化Number.isInteger(n) 判断,如果是整数直接返回(如 5 而不是 5.00000000);
  2. 大数保护Math.abs(n) < 1e15 限制范围,避免科学计数法;
  3. 尾零修剪while 循环去掉小数末尾多余的零(如 3.140000003.14);
  4. 小数点修剪:如果结果是整数但带有小数点(如 4.000000004.4)。

7. 布局技术深度剖析

7.1 SizedBox(height:60) → .height(60) 统一按钮高度

Flutter 中的 SizedBox:

SizedBox(
  height: 60,
  child: ElevatedButton(
    onPressed: () {},
    child: Text('7'),
  ),
)

在 Flutter 中,SizedBox 是一个独立的 Widget,它包裹着子组件,为其施加尺寸约束。SizedBox(height: 60) 的含义是:

  • 在垂直方向上强制约束子组件高度为 60px;
  • 在水平方向上不施加约束,由父容器或子组件自身决定。

ArkTS 中的等价实现:

Button('7')
  .height(60)

在 ArkTS 中,.height(60) 直接设置在 Button 上,不需要额外的容器 Widget。这意味着:

  • Button 自身的高度被固定为 60vp;
  • 水平方向由父容器(Row)通过 layoutWeight 机制分配。

区别与考量:

维度 Flutter SizedBox ArkTS .height()
实现层 独立的约束 Widget 组件链式方法
层级深度 增加一层嵌套 不增加额外层级
灵活性 可包裹任意 Widget 只影响当前组件
可读性 显式声明意图 链式调用,随组件定义

ArkTS 的 .height() 方法更加简洁——不需要为了固定高度而引入额外的容器组件,减少了不必要的嵌套层级。

7.2 Expanded → .layoutWeight(1) 按钮等宽

Flutter 中的 Expanded:

Row(
  children: [
    Expanded(child: Button('7')),
    Expanded(child: Button('8')),
    Expanded(child: Button('9')),
    Expanded(child: Button('÷')),
  ],
)

Expanded 是一个布局占位组件,它本身不渲染任何东西,只是告诉 Flutter 的 Flex 布局算法如何分配空间。

ArkTS 中的等价实现:

Row({ space: 6 }) {
  Button('7').layoutWeight(1)
  Button('8').layoutWeight(1)
  Button('9').layoutWeight(1)
  Button('÷').layoutWeight(1)
}

区别与考量:

维度 Flutter Expanded ArkTS .layoutWeight()
实现层 独立的布局 Widget 组件链式方法
层级深度 增加一层嵌套 不增加额外层级
默认 flex flex: 1 无默认值,需显式指定
适用范围 Row/Column/Flex Row/Column/Flex
非弹性子项 普通 Widget 共存 设置 layoutWeight(0) 或省略

在 ArkTS 中,组件默认不参与弹性分配(相当于 layoutWeight(0)),只有显式设置了 .layoutWeight(n) 的组件才会按比例分配。这比 Flutter 的所有子组件默认都被挤占空间的设计更灵活——可以混合固定宽度和弹性宽度的子组件。

7.3 Expanded(flex:2) → .layoutWeight(2) 双倍宽度

在计算器键盘中,“0” 按钮需要占据两列宽度。这是通过 layoutWeight(2) 实现的:

Row({ space: 6 }) {
  Button('0')
    .layoutWeight(2)        // 占 2/4 = 50% 宽度
    .height(60)
  Button('.')
    .layoutWeight(1)        // 占 1/4 = 25% 宽度
    .height(60)
  Button('=')
    .layoutWeight(1)        // 占 1/4 = 25% 宽度
    .height(60)
}

权重分配计算:

Row 可用宽度 = 父容器宽度 - 左右 padding(16) - 2个间隙(6×2)
              = W - 28

"0" 宽度 = (W - 28) × (2/4) = (W - 28) / 2
"." 宽度 = (W - 28) × (1/4)
"=" 宽度 = (W - 28) × (1/4)

与 Flutter 的区别:
在 Flutter 中,Expanded(flex: 2) 需要嵌套两层:

Row(
  children: [
    Expanded(flex: 2, child: SizedBox(height: 60, child: Button('0'))),
    Expanded(flex: 1, child: SizedBox(height: 60, child: Button('.'))),
    Expanded(flex: 1, child: SizedBox(height: 60, child: Button('='))),
  ],
)

ArkTS 的链式调用方式减少了 50% 的嵌套深度。

7.4 间距与内边距的三种实现方式

在 ArkTS 布局中,间距可以通过三种方式实现:

方式一:容器的 space 属性(推荐)

Row({ space: 6 }) { ... }       // 子组件水平间距 6vp
Column({ space: 6 }) { ... }    // 子组件垂直间距 6vp
  • 最简洁的方式;
  • 只能应用于 Row / Column / Flex 的直接子级;
  • 类似于 Flutter 中在 children 之间插入 SizedBox。

方式二:容器的 padding 属性

Row({ space: 6 })
  .padding(8)                    // 四周内边距 8vp
  .padding({ left: 16, right: 16, top: 8, bottom: 8 })  // 指定方向
  • 控制容器内部边缘与子组件之间的间距;
  • 支持统一设置和分方向设置。

方式三:组件的 margin 属性(ArkTS 通过外层容器实现)

Row() {
  // 用一个空白组件模拟 margin
  Row().width(6)                 // 等效于 Flutter 的 SizedBox(width: 6)
  Button('7')
  ...
}

在本文的计算器中,我们综合使用了前两种方式:

键盘 Column:     .padding(8)       ← 键盘区域四周留白
Row 每行:        space: 6          ← 按钮水平间距
Column 整体:     space: 6          ← 行垂直间距

7.5 弹性空间分配与 flex 优先级

当 Row/Column 中混合了弹性子项非弹性子项时,空间分配遵循以下规则:

  1. 非弹性子项优先:先分配非弹性子项(没有 layoutWeight)所需的空间;
  2. 剩余空间再分配:将剩余空间按照弹性子项的权重比例分配;
  3. 最小尺寸保护:即使设置 layoutWeight(0),组件也会根据内容保持最小尺寸。

示例:

Row({ space: 0 }) {
  Text('AC')
    .height(60)                    // 非弹性:按内容宽度显示
  Button('÷')
    .layoutWeight(1)               // 弹性:占据剩余所有空间
    .height(60)
}

在这个例子中,Text('AC') 按自身内容的宽度显示,Button('÷') 占据 Row 中剩下的所有宽度。


8. 实战技巧与最佳实践

8.1 使用资源引用替代硬编码

不推荐:

Button('7')
  .height(60)
  .fontSize(28)
  .borderRadius(12)
  .backgroundColor(Color.parse('#2d2d44'))

推荐:

Button('7')
  .height($r('app.float.calc_btn_height'))
  .fontSize($r('app.float.calc_btn_font_size'))
  .borderRadius($r('app.float.calc_btn_radius'))
  .backgroundColor($r('app.color.calc_btn_number'))

资源引用的优势:

  1. 一致性:所有按钮引用同一个资源,确保全局统一;
  2. 可维护性:修改在资源文件中一处完成,无需搜索替换代码;
  3. 多设备适配:可以在 resources/en/resources/zh/resources/tablet/ 等目录下定义不同值;
  4. 编译期校验:资源不存在时编译报错,而非运行时崩溃。

8.2 颜色主题的模块化管理

在大型应用中,推荐按组件模块组织颜色资源:

{
  "color": [
    // 全局颜色
    {"name": "start_window_background", "value": "#FFFFFF"},

    // 计算器模块
    {"name": "calc_background",         "value": "#0d0d1a"},
    {"name": "calc_display_bg",         "value": "#1a1a2e"},
    {"name": "calc_btn_number",         "value": "#2d2d44"},
    {"name": "calc_btn_operator",       "value": "#ff9f0a"},

    // TODO: 后续新增模块
    // {"name": "chat_bubble_user", ...}
    // {"name": "chat_bubble_bot", ...}
  ]
}

命名规范建议:

  • 使用模块前缀(如 calc_chat_nav_);
  • 使用组件名(如 btn_display_bg_);
  • 使用语义(如 numberoperatorfunction)而非视觉描述(如 blueround)。

8.3 ForEach 动态渲染的性能考量

ForEach(this.btnRows.slice(0, 4), (row: string[]) => {
  Row({ space: 6 }) {
    ForEach(row, (label: string) => {
      Button(label)
        ...
    }, (label: string) => label)
  }
}, (row: string[]) => row[0])

性能优化要点:

  1. key 生成器ForEach 的第三个参数是 key 生成函数 (item: T) => string,用于标识列表项的身份,帮助 ArkUI 进行最小化重绘;

    • 每行 key:row[0](以每行第一个按钮文本作为行标识,如 ‘AC’、‘7’、‘4’、‘1’);
    • 每个按钮 key:label(按钮文本本身唯一标识)。
  2. 使用 slice 分离特殊行this.btnRows.slice(0, 4) 将前 4 行和第 5 行分离渲染,因为第 5 行的布局不同(没有 ForEach,手动创建)。

  3. 避免在 build 中创建复杂对象btnRows 定义为 private 成员而非在 build() 中创建,避免每次重绘时重新创建数组。

8.4 高分辨率屏幕适配

本文的布局天然支持不同屏幕尺寸,但还需要注意:

  1. 使用 vp 而非 px:所有尺寸使用 vp(虚拟像素),系统自动适配屏幕密度;
  2. 使用 fp 而非 sp:字体使用 fp(字体像素),跟随系统字体缩放;
  3. 避免固定宽度:永远使用 layoutWeight + width('100%'),而非固定像素宽度;
  4. 触摸目标大小:60vp 的按钮高度在大多数设备上约为 15-18mm,符合人机工学要求(推荐触摸目标 ≥ 9mm)。

8.5 深色模式适配

HarmonyOS 支持深色模式自动切换。通过在 resources/dark/ 目录下放置替代资源文件,可以实现深色主题的自动适配:

// resources/base/element/color.json(浅色模式)
{
  "color": [
    {"name": "calc_background", "value": "#f0f0f0"}
  ]
}

// resources/dark/element/color.json(深色模式)
{
  "color": [
    {"name": "calc_background", "value": "#0d0d1a"}
  ]
}

当系统切换到深色模式时,字体等也需相应调整:

// resources/base/element/float.json
{
  "float": [
    {"name": "calc_btn_font_size", "value": "28fp"}
  ]
}

9. 完整代码清单

9.1 Index.ets —— 主页面

@Entry
@Component
struct Index {
  @State displayText: string = '0';
  @State resultText: string = '';
  @State isNewInput: boolean = true;

  private firstOperand: number = 0;
  private operator: string = '';
  private waitingForSecond: boolean = false;

  private btnRows: string[][] = [
    ['AC', '±', '%', '÷'],
    ['7', '8', '9', '×'],
    ['4', '5', '6', '−'],
    ['1', '2', '3', '+'],
    ['0', '.', '=']
  ];

  build() {
    Stack() {
      Column() {
        // --- 显示区域 ---
        Column() {
          Row() {
            Text(this.resultText)
              .fontSize($r('app.float.calc_result_font_size'))
              .fontColor($r('app.color.calc_result_text'))
              .textAlign(TextAlign.End)
              .width('100%')
          }
          .width('100%')
          .layoutWeight(1)
          .alignItems(VerticalAlign.Bottom)
          .padding({ bottom: 4 })

          Row() {
            Text(this.displayText)
              .fontSize($r('app.float.calc_display_font_size'))
              .fontColor($r('app.color.calc_display_text'))
              .textAlign(TextAlign.End)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .width('100%')
          }
          .width('100%')
          .layoutWeight(1)
          .alignItems(VerticalAlign.Bottom)
        }
        .width('100%')
        .layoutWeight(1)
        .padding({ left: 16, right: 16, bottom: 8, top: 16 })
        .backgroundColor($r('app.color.calc_display_bg'))

        // --- 键盘区域 ---
        Column({ space: 6 }) {
          // 前 4 行:等宽按钮
          ForEach(this.btnRows.slice(0, 4), (row: string[]) => {
            Row({ space: 6 }) {
              ForEach(row, (label: string) => {
                Button(label)
                  .height(60)
                  .layoutWeight(1)
                  .fontSize($r('app.float.calc_btn_font_size'))
                  .fontColor(this.getBtnType(label) === 'function'
                    ? $r('app.color.calc_btn_function_text')
                    : $r('app.color.calc_btn_operator_text'))
                  .backgroundColor(this.getBtnColor(label))
                  .borderRadius($r('app.float.calc_btn_radius'))
                  .onClick(() => { this.onBtnClick(label); })
              }, (label: string) => label)
            }
            .width('100%')
            .height(60)
          }, (row: string[]) => row[0])

          // 第 5 行:特殊行
          Row({ space: 6 }) {
            Button('0')
              .height(60)
              .layoutWeight(2)
              .fontSize($r('app.float.calc_btn_font_size'))
              .fontColor($r('app.color.calc_btn_number_text'))
              .backgroundColor($r('app.color.calc_btn_number'))
              .borderRadius($r('app.float.calc_btn_radius'))
              .onClick(() => { this.onBtnClick('0'); })

            Button('.')
              .height(60)
              .layoutWeight(1)
              .fontSize($r('app.float.calc_btn_font_size'))
              .fontColor($r('app.color.calc_btn_number_text'))
              .backgroundColor($r('app.color.calc_btn_number'))
              .borderRadius($r('app.float.calc_btn_radius'))
              .onClick(() => { this.onBtnClick('.'); })

            Button('=')
              .height(60)
              .layoutWeight(1)
              .fontSize($r('app.float.calc_btn_font_size'))
              .fontColor($r('app.color.calc_btn_operator_text'))
              .backgroundColor($r('app.color.calc_btn_operator'))
              .borderRadius($r('app.float.calc_btn_radius'))
              .onClick(() => { this.onBtnClick('='); })
          }
          .width('100%')
          .height(60)
        }
        .width('100%')
        .padding(8)
        .backgroundColor($r('app.color.calc_background'))
      }
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.calc_background'))
  }

  // --- 按钮交互 ---
  onBtnClick(label: string): void { /* 见 6.8 节 */ }
  calculate(): void { /* 见 6.9 节 */ }
  formatNumber(n: number): string { /* 见 6.10 节 */ }
  getBtnType(label: string): string { /* 见 6.5 节 */ }
  getBtnColor(label: string): ResourceColor { /* 见 6.5 节 */ }
}

(完整代码请参见项目中的 Index.ets 文件)

9.2 color.json —— 颜色资源

{
  "color": [
    {"name": "calc_display_bg",       "value": "#1a1a2e"},
    {"name": "calc_display_text",     "value": "#FFFFFF"},
    {"name": "calc_btn_number",       "value": "#2d2d44"},
    {"name": "calc_btn_number_text",  "value": "#FFFFFF"},
    {"name": "calc_btn_operator",     "value": "#ff9f0a"},
    {"name": "calc_btn_operator_text","value": "#FFFFFF"},
    {"name": "calc_btn_function",     "value": "#a5a5a5"},
    {"name": "calc_btn_function_text","value": "#000000"},
    {"name": "calc_background",       "value": "#0d0d1a"},
    {"name": "calc_result_text",      "value": "#ff9f0a"}
  ]
}

9.3 float.json —— 尺寸资源

{
  "float": [
    {"name": "calc_btn_height",       "value": "60vp"},
    {"name": "calc_btn_font_size",    "value": "28fp"},
    {"name": "calc_display_font_size","value": "56fp"},
    {"name": "calc_result_font_size", "value": "40fp"},
    {"name": "calc_gap",              "value": "4vp"},
    {"name": "calc_padding",          "value": "8vp"},
    {"name": "calc_btn_radius",       "value": "12vp"}
  ]
}

10. 调试与验证

10.1 构建验证

使用 hvigor 命令行构建项目:

cd D:\hongmeng\d38
hvigorw assembleHap --mode module -p module=entry@default -p buildMode=debug

成功输出:

BUILD SUCCESSFUL in 4s 535ms

关键编译步骤及其耗时(验证布局代码正确编译):

编译步骤 耗时
CompileResource 238ms
CompileArkTS 2s 170ms
PackageHap 358ms
总计 4s 535ms

10.2 布局验证清单

部署到真机或模拟器后,验证以下布局要点:

检查项 预期结果
所有按钮高度 严格一致(60vp)
前 4 行按钮宽度 每行 4 个按钮完全等宽
“0” 按钮宽度 是 “.” 按钮的 2 倍
按钮间距 水平和垂直方向均匀 6vp
屏幕适配 旋转设备或在不同尺寸屏幕上均等宽
圆角 所有按钮圆角一致(12vp)
显示区域 弹性占满键盘以上空间
长数字溢出 自动省略号,不换行

10.3 常见问题排查

问题 1:按钮没有等宽

检查:

  • 父容器是否是 Row
  • 按钮是否都设置了 .layoutWeight(1)
  • 是否不小心给按钮设置了固定宽度(如 .width(80))。

问题 2:按钮高度不一致

检查:

  • 每个按钮是否都设置了 .height(60)
  • 行容器 Row 是否也设置了 .height(60)

问题 3:按钮之间没有间距

检查:

  • RowColumn 是否设置了 space 参数;
  • space 的单位是 vp,值是否过小。

问题 4:显示区域没有弹性展开

检查:

  • 显示区域的 Column 是否设置了 .layoutWeight(1)
  • 键盘区域是否意外也设置了 .layoutWeight(1)(导致显示区域被压缩)。

11. 总结与展望

11.1 核心结论

本文通过一个完整的计算器键盘应用,深入讲解了如何在 HarmonyOS ArkTS 中运用 Flutter 布局理念实现等高等宽按钮网格。核心结论如下:

  1. Flutter 布局思想在 ArkTS 中完全可迁移SizedBox(height:60).height(60)Expanded.layoutWeight(1),概念映射清晰直接。

  2. ArkTS 布局更加简洁:由于使用链式方法而非独立 Widget 包裹,ArkTS 的布局代码层级更浅(从 Flutter 的 4 层减少到 2 层)。

  3. 弹性布局是跨平台响应式的核心:不使用任何媒体查询,仅靠 layoutWeight + width('100%') 即可完美适配任何屏幕宽度。

  4. 资源文件管理是最佳实践:颜色和尺寸通过 color.json / float.json 集中管理,实现主题统一、多屏适配、编译期校验三大目标。

11.2 与 Flutter 布局的全面对比

维度 Flutter ArkTS
布局范式 Widget 树 组件树
尺寸约束 SizedBox + BoxConstraints .height() + .width() + .constraintSize()
弹性布局 Expanded + Flexible .layoutWeight()
间距 SizedBox(width/h) + Spacer space 参数 + .padding()
嵌套深度 较深(每个属性一个 Widget) 较浅(链式调用)
学习曲线 需理解 Widget 组合模式 更接近传统 UI 开发
性能 相同的声明式 diff 更新 相同的声明式 diff 更新

11.3 扩展应用场景

本文的布局模式不仅适用于计算器,还可以广泛运用于:

  1. 虚拟键盘:输入法键盘;
  2. 数字键盘:ATM/POS 机的数字输入界面;
  3. 游戏手柄:方向键 + 功能键的等距排列;
  4. 工具栏:底部操作栏的等宽按钮;
  5. 格言展示:图文网格布局;
  6. 选座界面:电影院/会议室的座位选择器(网格 + 不同权重混合)。

11.4 未来展望

随着 HarmonyOS Next 的不断发展,ArkUI 的布局能力将持续增强。值得关注的趋势包括:

  1. LazyGrid 网格布局:类似 Flutter 的 GridView.builder,支持大量按钮的懒加载渲染;
  2. CustomLayout 自定义布局:类似 Flutter 的 CustomMultiChildLayout,支持完全自定义的布局算法;
  3. 响应式断点:内置的断点系统,自动适配手机/折叠屏/平板不同形态;
  4. 布局动画:当按钮权重或尺寸变化时的平滑动画过渡。

11.5 致谢

本文是 HarmonyOS 开发社区 “Flutter 布局技术在 ArkTS 中的应用” 系列的一部分。感谢华为开发者联盟提供的技术文档和 DevEco Studio 开发工具,使得跨平台布局思想的迁移和实现成为可能。


项目源码位置: D:\hongmeng\d38\
主页面文件: entry/src/main/ets/pages/Index.ets
资源文件: entry/src/main/resources/base/element/color.jsonfloat.json
构建方式: DevEco Studio 打开项目根目录,或使用 hvigorw assembleHap 命令行构建


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐