在这里插入图片描述

一、引言

画板是用户创意表达的工具。虽然 ArkTS 原生支持 Canvas 组件用于自由绘图,但本应用采用了一种更轻量、更声明式的方式——使用 Grid 网格布局模拟 10×10 像素画板,每个格子是一个独立的 GridItem

从技术角度来看,这个应用覆盖了 ArkTS 中几个独特的模式:

  1. Grid 的精确网格控制columnsTemplate('1fr × 10') 创建 10×10 的规整像素网格
  2. 状态驱动的像素着色:每个格子的颜色由数组状态管理,点击即改变
  3. 模式切换(画图/擦除):通过 Button 的选中态装饰器切换两种交互模式
  4. 颜色预设选择:7 种预设颜色,选中态高亮显示
  5. 模拟像素艺术:在无 Canvas 的情况下实现类像素画的交互体验

本文将从网格布局的数学原理出发,深入到 ArkTS 中用数组驱动 UI 渲染的核心思想。


二、完整源码

// index16.ets
@Entry
@Component
struct Index16 {
  @State grid: number[] = new Array(100).fill(0);
  @State currentColor: string = '#007AFF';
  @State isEraseMode: boolean = false;

  private colors: string[] = ['#FF3B30', '#FF9500', '#FFCC00',
    '#34C759', '#007AFF', '#AF52DE', '#000000'];

  paintCell(index: number): void {
    this.grid[index] = this.isEraseMode ? 0 : 1;
  }

  clearAll(): void {
    this.grid = new Array(100).fill(0);
  }

  getCellColor(value: number): string {
    return value === 1 ? this.currentColor : '#F0F0F0';
  }

  build() {
    Column() {
      Text('🎨 简易画板').fontSize(28).fontWeight(FontWeight.Bold)
        .margin({ top: 40, bottom: 5 })
      Text('点击格子涂色,10×10 像素画板').fontSize(13)
        .fontColor('#8E8E93').margin({ bottom: 15 })

      // 颜色预设
      Row({ space: 6 }) {
        ForEach(this.colors, (color: string) => {
          Column()
            .width(28).height(28).backgroundColor(color).borderRadius(14)
            .border({
              width: this.currentColor === color && !this.isEraseMode ? 3 : 1,
              color: this.currentColor === color && !this.isEraseMode ?
                '#333333' : '#DDDDDD'
            })
            .onClick(() => {
              this.currentColor = color;
              this.isEraseMode = false;
            })
        }, (color: string) => color)
      }.margin({ bottom: 10 })

      // 模式切换
      Row({ space: 12 }) {
        Button('✏️ 画图').height(36).fontSize(13)
          .backgroundColor(this.isEraseMode ? '#E5E5EA' : '#007AFF')
          .fontColor(this.isEraseMode ? '#333333' : '#FFFFFF')
          .borderRadius(18)
          .onClick(() => { this.isEraseMode = false; })

        Button('🧹 擦除').height(36).fontSize(13)
          .backgroundColor(this.isEraseMode ? '#FF3B30' : '#E5E5EA')
          .fontColor(this.isEraseMode ? '#FFFFFF' : '#333333')
          .borderRadius(18)
          .onClick(() => { this.isEraseMode = true; })

        Button('🗑 清空').height(36).fontSize(13)
          .backgroundColor('#8E8E93').fontColor('#FFFFFF')
          .borderRadius(18)
          .onClick(() => { this.clearAll(); })
      }.margin({ bottom: 15 })

      // 10×10 像素网格
      Grid() {
        ForEach(this.grid, (value: number, index: number) => {
          GridItem() {
            Column()
              .width('100%').height('100%')
              .backgroundColor(this.getCellColor(value))
              .border({ width: 1, color: '#E0E0E0' })
              .onClick(() => { this.paintCell(index); })
          }
        }, (value: number, index: number) => index.toString())
      }
      .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr')
      .rowsGap(0).columnsGap(0)
      .width(280).height(280)

      Text('提示: 点击格子填充颜色,支持画图/擦除模式切换')
        .fontSize(12).fontColor('#AAAAAA').margin({ top: 20 })
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Start)
    .alignItems(HorizontalAlign.Center)
  }
}

三、核心技术解析

3.1 数组状态驱动 UI:100 个像素的数据结构

画板的核心数据是一个长度为 100 的数字数组

@State grid: number[] = new Array(100).fill(0);

每个元素代表一个像素格子的状态:

  • 0:空白(默认颜色 #F0F0F0
  • 1:已着色(使用当前选中的颜色)

new Array(100).fill(0) 一次性创建 100 个元素并初始化为 0。这是 JavaScript 创建固定大小数组并填充默认值的最简洁方式。

数组索引 → 屏幕坐标的映射

索引 i  →  行 = Math.floor(i / 10)
         列 = i % 10

例如 i = 23:
  行 = Math.floor(23 / 10) = 2
  列 = 23 % 10 = 3
  → 第 3 行第 4 列(从 0 开始编号)

这种一维数组映射二维网格的技术在游戏开发(如贪吃蛇、2048、扫雷)中广泛使用。

单元格颜色读取函数

getCellColor(value: number): string {
  return value === 1 ? this.currentColor : '#F0F0F0';
}

这个函数的精妙之处在于:当 value = 1 时,它返回的是当前的 currentColor。这意味着如果用户先画了一些蓝色格子,然后切换颜色为红色,之前画的蓝色格子仍然是蓝色(因为 grid[i] 的值保持为 1,而 getCellColor 返回的是当前的 currentColor……等等,这其实是一个 bug!)

发现问题了吗?value = 1 时,getCellColor 总是返回当前的 currentColor,而不是"格子被涂色时的颜色"。这意味着:

  1. 用户用蓝色画了几个格子(grid[0]=1, grid[1]=1
  2. 用户切换到红色(currentColor = '#FF3B30'
  3. 之前蓝色的格子变成了红色——因为 getCellColor(1) 总是返回 this.currentColor

修复方案:将 grid 改为存储具体的颜色值,而非简单的 0/1 标记:

@State grid: string[] = new Array(100).fill('');

paintCell(index: number): void {
  this.grid[index] = this.isEraseMode ? '' : this.currentColor;
}

getCellColor(value: string): string {
  return value || '#F0F0F0';  // 空字符串 → 底色,#XXXXXX → 该格子的颜色
}

这样每个格子独立存储自己的颜色值,切换颜色后不会影响已画好的格子。这是原始代码的一个设计缺陷,也是从 Demo 代码生产级代码需要跨越的鸿沟。

3.2 Grid 的 columnsTemplate 精确控制

columnsTemplateGrid 组件最强大的布局属性:

Grid()
  .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr')
  .rowsGap(0)
  .columnsGap(0)

columnsTemplate 的语法类似 CSS Grid 的 grid-template-columns

语法 含义 示例
1fr 比例单位(fraction) 3 个 1fr = 三等分
px 固定像素 50px = 固定 50 像素
% 百分比 20% = 父容器宽度的 20%
repeat(n, 1fr) 重复 部分版本支持

'1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr' 表示 10 列等宽的网格。当 Grid 总宽度为 280px 时,每列宽度为 28px,每个格子是一个 28×28 的正方形。

rowsGap(0)columnsGap(0) 将间距设为 0,格子之间仅通过 1px 的 border 分隔。使用 border 而非 gap 的原因在于:边框在每个格子自己的区域内,不影响整体布局的精确计算。

3.3 画图/擦除模式切换

画板支持两种交互模式,通过布尔值 isEraseMode 控制:

@State isEraseMode: boolean = false;

paintCell(index: number): void {
  this.grid[index] = this.isEraseMode ? 0 : 1;
}

模式切换的 UI 表现

画图模式:isEraseMode = false

  • "画图"按钮:蓝色(#007AFF),白色文字——表示当前激活
  • "擦除"按钮:灰色(#E5E5EA),深色文字——表示非激活

擦除模式:isEraseMode = true

  • "画图"按钮:灰色,深色文字
  • "擦除"按钮:红色(#FF3B30),白色文字——表示当前激活
Button('✏️ 画图')
  .backgroundColor(this.isEraseMode ? '#E5E5EA' : '#007AFF')
  .fontColor(this.isEraseMode ? '#333333' : '#FFFFFF')

Button('🧹 擦除')
  .backgroundColor(this.isEraseMode ? '#FF3B30' : '#E5E5EA')
  .fontColor(this.isEraseMode ? '#FFFFFF' : '#333333')

这是互斥按钮组的典型实现方式——用一个状态变量控制两个按钮的样式。

3.4 颜色预设选择

画板提供了 7 种预设颜色,使用 ForEach 渲染:

private colors: string[] = ['#FF3B30', '#FF9500', '#FFCC00',
  '#34C759', '#007AFF', '#AF52DE', '#000000'];

颜色预设的选中态通过边框宽度和颜色来实现:

Column()
  .width(28).height(28)
  .backgroundColor(color)
  .borderRadius(14)
  .border({
    width: this.currentColor === color && !this.isEraseMode ? 3 : 1,
    color: this.currentColor === color && !this.isEraseMode ?
      '#333333' : '#DDDDDD'
  })

当某个颜色是当前选中色且不在擦除模式时,该颜色圆点显示 3px 深色边框;否则显示 1px 浅色边框。

&& 条件this.currentColor === color && !this.isEraseMode 确保当用户切换到擦除模式时,所有颜色预设都不显示"选中"状态——因为擦除模式下选色是无效的。


四、Grid 组件与其他布局的对比

在 ArkTS 中创建网格布局有多种方式:

方法 优点 缺点 适用场景
Grid + columnsTemplate 精确控制行列,自动换行 需要指定列数 像素画板、九宫格、图片墙
Column + 多个 Row 每行可不同布局 手动管理行数 计算器按键、表单
Flex wrap 自动换行 控制不够精细 标签、徽章

对于画板这种规整的网格(每行每列数量固定、格子大小完全一致),Grid 是最佳选择。


五、清空操作:数组替换 vs 逐元素赋值

clearAll(): void {
  this.grid = new Array(100).fill(0);
}

这里通过整体替换数组(而不是 for 循环逐个赋值)来实现清空。这是有意为之的:

整体替换

this.grid = new Array(100).fill(0);  // ✅ 触发 @State 更新

逐个赋值(不可行):

for (let i = 0; i < 100; i++) {
  this.grid[i] = 0;  // ⚠️ 100次触发更新,性能极差
}
// 或者
this.grid.fill(0);  // ⚠️ 部分 ArkTS 版本不支持

ArkTS 的 @State 对数组的代理支持直接索引赋值(this.grid[i] = x),但 100 次 for 循环赋值会导致 100 次 UI 更新,性能开销巨大。整体替换一次触发一次更新,效率极高。


六、扩展方向

  1. 支持 Canvas 自由绘制:集成 Canvas 组件,支持手指拖拽连续绘制而非逐格点击
  2. 填充工具:实现"油漆桶"功能,点击后填充相邻同色区域(Flood Fill 算法)
  3. 保存与分享:将像素画板内容导出为图片,通过分享 Kit 分享
  4. 撤销/重做:保存操作栈(Command Pattern),支持 Ctrl+Z 撤销
  5. 多种笔刷大小:支持 1×1、2×2、3×3 笔刷
  6. 对称绘制:水平/垂直对称模式,画一笔出现对称的两笔
  7. 导入模板:加载预设图案模板(爱心、笑脸、星星等)

七、本章小结

知识点 掌握程度
一维数组映射二维网格 ✅ 理解 Math.floor(i/w) + i%w 映射
columnsTemplate('1fr ...') ✅ 掌握 Grid 精确列控制
画图/擦除模式切换 ✅ 掌握互斥按钮的设计模式
颜色预设选中态高亮 ✅ 理解条件边框样式
数组整体替换 vs 逐元素赋值 ✅ 理解性能差异
new Array(100).fill(0) ✅ 掌握固定大小数组的创建

简易画板展示了 ArkTS 中用数据驱动网格 UI的经典模式,以及模式切换的设计思想。虽然在真正的绘图应用中 Canvas 更为合适,但 Grid 方式在学习数组状态管理和网格布局方面有独特的教学价值。

Logo

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

更多推荐