在这里插入图片描述

从零搭建「极简全能创作 App」—— HarmonyOS NEXT ArkTS 完整开发教程

目标读者:有 TypeScript 基础、第一次接触鸿蒙开发的开发者
SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发工具:DevEco Studio
源码文件CreativeApp.ets(910 行)、FlexCardLayout.ets(583 行)
核心覆盖:@Component / @State / @Builder / Canvas 2D / Tabs / Flex 布局 / pasteboard / ImagePacker


前言

本教程带领你从零开始,在 HarmonyOS NEXT 上用 ArkTS 构建一个包含「绘图、写文案、修照片」三大功能的创作 App。你不需要有任何鸿蒙开发经验,只要会 TypeScript 或 JavaScript 就能跟上。

我们将在 910 行代码内完成全部功能,真正做到小而全。每段代码都附有详细解释,确保你不仅「能用」,更「理解为什么这样用」。


第一章:准备工作与环境搭建

1.1 下载 DevEco Studio

DevEco Studio 是华为官方的鸿蒙开发 IDE,基于 IntelliJ IDEA 构建。前往华为开发者官网下载最新版本。

安装后打开,确保 SDK 管理器中已安装 HarmonyOS NEXT 6.1.1(API 24) 的 SDK。

1.2 创建新项目

打开 DevEco Studio,点击 Create Project

  • 选择模板:Empty Ability(空模板)
  • Project Name:CreativeApp
  • Bundle Name:com.example.creativeapp
  • Save Location:自定义
  • Compatible SDK:选择 API 24
  • Language:ArkTS

创建完成后你会看到项目结构:

CreativeApp/
├── entry/
│   └── src/main/
│       ├── ets/
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets    ← 应用入口
│       │   └── pages/
│       │       └── Index.ets           ← 默认首页
│       ├── resources/                  ← 资源目录
│       └── module.json5                ← 模块配置
├── build-profile.json5                 ← 构建配置
└── hvigorfile.ts                       ← 构建脚本

1.3 认识 ArkTS 文件结构

在鸿蒙开发中,一个页面就是一个 .ets 文件。每个页面的基本骨架如下:

@Entry                          // ← 标记这是一个页面入口
@Component                      // ← 标记这是一个组件
struct MyPage {                  // ← 结构体(类似 class)
  @State message: string = '';  // ← 响应式状态

  build() {                     // ← UI 描述(类似 React 的 render)
    Column() {                  // ← 垂直布局容器
      Text(this.message)        // ← 文本组件
        .fontSize(20)           // ← 链式 API 设置属性
    }
  }
}

对照 React 来理解:

  • @Componentfunction Component()
  • @StateuseState()
  • build()render() / return (JSX)
  • 链式 API .width() .height() ≈ JSX 的 style={{ width, height }}

第二章:应用入口 —— EntryAbility

2.1 UIAbility 生命周期

EntryAbility.ets 是应用的启动入口,继承自 UIAbility(类似 Android 的 Activity 或 iOS 的 UIApplication)。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
  // Ability 创建时调用 —— 适合初始化全局数据
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN, 'testTag', 'Ability onCreate');
  }

  // 窗口创建完成 —— 在此加载首页
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/CreativeApp', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag',
          'Failed to load content. Cause: %{public}s', JSON.stringify(err));
        return;
      }
    });
  }

  // Ability 销毁
  onDestroy(): void {
    hilog.info(DOMAIN, 'testTag', 'Ability onDestroy');
  }

  // 进入前台
  onForeground(): void {
    hilog.info(DOMAIN, 'testTag', 'Ability onForeground');
  }

  // 进入后台
  onBackground(): void {
    hilog.info(DOMAIN, 'testTag', 'Ability onBackground');
  }
}

loadContent('pages/CreativeApp', callback) 是关键一行。它告诉系统去加载 pages/ 目录下名为 CreativeApp.ets 的文件。路径中的 'pages/CreativeApp' 对应文件 pages/CreativeApp.ets,注意不需要写文件扩展名

2.2 loadContent 回调的妙用

回调函数接收 err 对象。如果加载失败(比如文件不存在、语法错误),err.code 不为 0。调试阶段养成检查 err.code 的习惯,能快速定位页面加载问题。


第三章:构建 Tabs 底部导航框架

3.1 设计思路

我们的 App 有三个独立功能模块:绘图、写文案、修照片。最适合这种场景的导航方式就是 底部 Tab 栏。ArkTS 的 Tabs 组件原生支持这种设计模式。

整体架构如下:

CreativeApp (主入口)
├── TopBar (标题栏)
└── Tabs (底部导航)
    ├── Tab 0: DrawPage (绘图)
    ├── Tab 1: WritePage (写文案)
    └── Tab 2: PhotoEditPage (修照片)

3.2 主入口组件

新建 pages/CreativeApp.ets,写入以下代码:

import { promptAction } from '@kit.ArkUI';
import { pasteboard } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';

@Entry
@Component
struct CreativeApp {
  @State private currentIndex: number = 0;

  build() {
    Column({ space: 0 }) {
      // ---- 顶栏 ----
      this.TopBar()

      // ---- Tab 内容区 ----
      Tabs({
        barPosition: BarPosition.End,   // 标签栏在底部
        index: this.currentIndex,       // 当前选中哪个 Tab(受控模式)
        onChange: (idx: number) => {    // Tab 切换回调
          this.currentIndex = idx;
        }
      }) {
        TabContent() { DrawPage() }
          .tabBar(this.TabBarItem('🎨', '绘图', 0))

        TabContent() { WritePage() }
          .tabBar(this.TabBarItem('✍️', '写文案', 1))

        TabContent() { PhotoEditPage() }
          .tabBar(this.TabBarItem('📷', '修照片', 2))
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F5F6FA')
    }
    .width('100%')
    .height('100%')
  }

  // ---- 顶栏 ----
  @Builder
  TopBar() {
    Row() {
      Text('✨ 极简创作')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')
      Blank()
      Text('新手友好 · 一应俱全')
        .fontSize(11)
        .fontColor('#AAA')
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 12, bottom: 8 })
    .backgroundColor('#FFFFFF')
  }

  // ---- 底部 Tab 图标 + 文字 ----
  @Builder
  TabBarItem(icon: string, label: string, index: number) {
    Column({ space: 2 }) {
      Text(icon).fontSize(22)
      Text(label)
        .fontSize(11)
        .fontColor(this.currentIndex === index ? '#6C63FF' : '#999')
    }
    .width('100%')
    .padding({ top: 6, bottom: 4 })
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
  }
}

3.3 Tabs 组件详解

barPosition: BarPosition.End

控制 Tab 栏的位置:

  • BarPosition.End → 底部 Tab(移动端最常见)
  • BarPosition.Start → 顶部 Tab

受控模式(Controlled Mode)

Tabs({
  index: this.currentIndex,       // 读取状态
  onChange: (idx) => {            // 写入状态
    this.currentIndex = idx;
  }
})

这里的 indexonChange 必须同时设置。只设置 index 而不处理 onChange,用户点击 Tab 时界面不会切换。这是 ArkTS 新手最容易踩的坑。

@State private currentIndex 是「受控状态」—— Tab 的选中状态不由 Tabs 内部管理,而是由父组件通过状态变量控制。

3.4 @Builder 构建器

@Builder 是 ArkTS 的一种轻量级 UI 构建函数,与 @Component 的区别在于:

特性 @Builder @Component
状态管理 无独立状态 有 @State
参数方式 独立参数 对象属性
复用范围 同一结构体内 全局
性能 零开销 轻微

我们定义了两个 @Builder

  • TopBar():顶部标题栏,无参数
  • TabBarItem(icon, label, index):底部 Tab 项目,三个参数

3.5 Tab 选中态高亮

.fontColor(this.currentIndex === index ? '#6C63FF' : '#999')

这是 ArkTS 中常见的「条件样式」写法:三元表达式直接在属性链中计算。当 currentIndex === index 时为紫色高亮,其余为灰色。

3.6 Column 与 Row 布局

ArkTS 最基本的两个布局容器:

  • Column:子元素垂直排列(类似 CSS flex-direction: column)
  • Row:子元素水平排列(类似 CSS flex-direction: row)

参数 { space: 10 } 控制子元素间距,等价于 CSS 的 gap: 10px。


第四章:模块一 —— 绘图页面(DrawPage)

4.1 功能需求

绘图页面需要支持:

  1. 手指在画布上绘画
  2. 12 种预设颜色可以切换
  3. 笔刷大小 1~20px 可调
  4. 支持撤销操作
  5. 一键清空画布
  6. 将画作保存为图片

4.2 Canvas 2D API 简介

ArkTS 的 CanvasRenderingContext2D 与 Web 标准 Canvas API 高度一致。如果你有 Web 开发经验,可以直接上手。核心方法对照:

Web Canvas ArkTS Canvas 说明
ctx.beginPath() 相同 开始一条新路径
ctx.moveTo() 相同 移动画笔到指定点
ctx.lineTo() 相同 画线到指定点
ctx.stroke() 相同 描边路径
ctx.fill() 相同 填充路径
canvas.getContext('2d') new CanvasRenderingContext2D(settings) 获取上下文
canvas.getBoundingClientRect() 不需要(触摸坐标已相对组件) 获取位置

4.3 DrawPage 状态定义

@Component
struct DrawPage {
  // Canvas 上下文(实例变量,不驱动 UI)
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  // @State:这些值变化时需要 UI 刷新
  @State private currentColor: string = '#6C63FF';
  @State private brushSize: number = 4;
  @State private isDrawing: boolean = false;

  // 笔画历史(不驱动 UI,用普通变量)
  private strokes: DrawPoint[] = [];
  private currentStroke: DrawPoint[] = [];

  // 颜色盘(不驱动 UI)
  private readonly presetColors: string[] = [
    '#1A1A2E', '#6C63FF', '#FF6B6B', '#FFD93D',
    '#6BCB77', '#4D96FF', '#FF6F91', '#845EC2',
    '#FF9671', '#00C9A7', '#FFC75F', '#F9F9F9',
  ];
  // ...
}

为什么有些变量用 @State,有些不用?

这是一个关键的设计决策:

  • @State 变量currentColorbrushSizeisDrawing):UI 直接依赖这些值。颜色选中态、滑条数值、工具栏文字都需要根据它们渲染。修改它们会触发 build() 重执行。
  • 普通变量strokescurrentStrokesettingscontext):UI 不直接展示笔画列表。修改它们不会触发 UI 刷新,而是通过 Canvas API 手动操作。

教训:不要把 Canvas 上下文或大型数据结构放在 @State 中。它们不需要驱动 UI,放进去反而会造成不必要的性能开销。

4.4 构建 UI

build() {
  Column({ space: 10 }) {
    this.Toolbar()          // 工具栏
    this.CanvasArea()       // 画布区
    this.ColorBrushBar()    // 颜色盘 + 笔刷
    this.ActionButtons()    // 操作按钮
  }
  .width('100%')
  .padding(16)
  .alignItems(HorizontalAlign.Center)
}

将 UI 拆分为四个 @Builder 方法,每个方法负责一块独立区域:

工具栏:显示当前笔触大小和颜色

@Builder
Toolbar() {
  Row({ space: 6 }) {
    Text('🖌️ 手指绘画').fontSize(15).fontWeight(FontWeight.Medium)
    Text(' | ').fontColor('#DDD')
    Text(`笔触 ${this.brushSize}px · 颜色 ${this.currentColor}`)
      .fontSize(12).fontColor('#AAA')
  }
  .width('100%')
  .padding({ left: 4, bottom: 4 })
}

画布区:核心绘图区域

@Builder
CanvasArea() {
  Stack() {
    Canvas(this.context)
      .width('100%')
      .height(420)
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetY: 4 })
      .onReady(() => { this.drawGrid(); })           // Canvas 就绪后画网格
      .onTouch((event: TouchEvent) => {               // 触摸事件
        this.handleCanvasTouch(event);
      })
  }
  .width('100%')
  .aspectRatio(1.1)
}

Canvas 组件通过 CanvasRenderingContext2D 对象连接。四个事件回调:

回调 触发时机 用途
onReady Canvas 首次加载完成 初始化绘制
onTouch 触摸事件(Down/Move/Up) 手指绘画
onAreaChange Canvas 尺寸变化 响应式适配
onKeyEvent 键盘事件 快捷键

4.5 触摸事件处理 —— 手指绘画的核心

private handleCanvasTouch(event: TouchEvent): void {
  // 在 ArkUI 中,touches[0].x / .y 已是相对于组件的坐标
  const x = event.touches[0].x;
  const y = event.touches[0].y;

  switch (event.type) {
    case TouchType.Down:            // 手指按下:开始一笔
      this.isDrawing = true;
      this.currentStroke = [{ x, y }];     // 记录起点
      this.context.beginPath();
      this.context.moveTo(x, y);
      break;

    case TouchType.Move:            // 手指移动:持续画线
      if (!this.isDrawing) return;
      this.context.lineTo(x, y);
      this.context.strokeStyle = this.currentColor;
      this.context.lineWidth = this.brushSize;
      this.context.lineCap = LineCap.Round;     // 圆头端点
      this.context.lineJoin = LineJoin.Round;   // 圆角连接
      this.context.stroke();
      this.currentStroke.push({ x, y });         // 记录路径点
      break;

    case TouchType.Up:              // 手指抬起:结束一笔
      this.isDrawing = false;
      if (this.currentStroke.length > 0) {
        this.strokes.push([...this.currentStroke]);  // 保存整笔
        this.currentStroke = [];
      }
      break;
  }
}
坐标说明

在 ArkUI 中,event.touches[0].x 返回的坐标已经是相对于触发触摸的组件的坐标。不需要像 Web 开发那样调用 getBoundingClientRect() 来转换。

笔画记录机制

我们用一个「笔画历史栈」来支持撤销:

触摸 Down → 新建 currentStroke 数组
触摸 Move → 每次往 currentStroke 里 push 一个点
触摸 Up → currentStroke 完整一笔保存到 strokes 数组
撤销 → strokes.pop() 然后清空画布从零重绘

这个「三阶段记录法」把连续的触摸事件拆分成离散的「笔画」单元,每笔是一个独立的 DrawPoint 数组,撤销时退回的就是一整个笔画。

4.6 绘制背景网格

private drawGrid(): void {
  const ctx = this.context;
  const w = ctx.canvas.width;
  const h = ctx.canvas.height;
  ctx.save();
  ctx.strokeStyle = '#F0F0F5';
  ctx.lineWidth = 0.5;
  for (let i = 0; i < w; i += 30) {
    ctx.beginPath();
    ctx.moveTo(i, 0);
    ctx.lineTo(i, h);
    ctx.stroke();
  }
  for (let i = 0; i < h; i += 30) {
    ctx.beginPath();
    ctx.moveTo(0, i);
    ctx.lineTo(w, i);
    ctx.stroke();
  }
  ctx.restore();
}

save() / restore() 是 Canvas 的状态保存与恢复机制。save() 保存当前绘图状态(线条颜色、宽度、样式等)到栈中,restore() 恢复之前保存的状态。使用这对方法可以确保绘制网格时修改的样式不会污染后续绘画。

4.7 颜色盘与笔刷滑条

颜色盘:用 Flex + FlexWrap.Wrap 实现流式排列,12 种颜色自动折行显示:

@Builder
ColorBrushBar() {
  Column({ space: 8 }) {
    // ---- 颜色盘 ----
    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
      ForEach(this.presetColors, (color: string) => {
        Row() {
          if (color === this.currentColor) {
            Text('✓').fontSize(14)
              .fontColor(color === '#F9F9F9' ? '#333' : '#FFF')
              .fontWeight(FontWeight.Bold)
          }
        }
        .width(32).height(32)
        .backgroundColor(color)
        .borderRadius(16)
        .border({
          width: color === this.currentColor ? 2.5 : 1,
          color: color === this.currentColor ? '#6C63FF' : '#E8E8E8',
        })
        .margin(4)
        .justifyContent(FlexAlign.Center)
        .alignItems(VerticalAlign.Center)
        .onClick(() => { this.currentColor = color; })
      })
    }
    .width('100%')

    // ---- 笔刷滑条 ----
    Row({ space: 10 }) {
      Text('笔刷').fontSize(13).fontColor('#666')
      Slider({
        value: this.brushSize, min: 1, max: 20, step: 1,
        style: SliderStyle.OutSet,
      })
      .width('70%').trackThickness(4)
      .blockColor('#6C63FF').trackColor('#E0E0FF')
      .selectedColor('#6C63FF')
      .onChange((val: number) => { this.brushSize = val; })
      Text(`${this.brushSize}`)
        .fontSize(12).fontColor('#6C63FF')
        .fontWeight(FontWeight.Bold)
    }
  }
  .width('100%')
  .padding(12)
  .backgroundColor(Color.White)
  .borderRadius(16)
  .shadow({ radius: 4, color: 'rgba(0,0,0,0.04)', offsetY: 2 })
}

颜色盘的设计细节

  • 每个色块是一个 32x32 的圆形(borderRadius(16)
  • 当前选中的颜色高亮边框(#6C63FF,宽度 2.5px)
  • 选中态显示白色 标记
  • #F9F9F9(白色)色块的特殊处理:文字颜色改为 #333

Slider 组件关键属性

  • value:当前值(双向绑定)
  • min / max:取值范围
  • step:步进值(本例 step=1,每次变化 1px)
  • styleSliderStyle.OutSet(凸起风格)或 SliderStyle.InSet(内嵌风格)
  • blockColor / trackColor / selectedColor:滑块三色定制

4.8 撤销功能

private undo(): void {
  if (this.strokes.length === 0) {
    promptAction.showToast({ message: '⚠️ 没有可撤销的笔画', duration: 1200 });
    return;
  }
  this.strokes.pop();        // 弹出最后一笔
  this.redrawAll();          // 清空并重绘所有剩余笔画
  promptAction.showToast({ message: '↩ 已撤销上一步', duration: 1000 });
}

private redrawAll(): void {
  const ctx = this.context;
  const w = ctx.canvas.width;
  const h = ctx.canvas.height;
  ctx.clearRect(0, 0, w, h);    // 清空画布
  this.drawGrid();              // 重绘网格

  for (const stroke of this.strokes) {
    if (stroke.length < 2) continue;  // 单点无效
    ctx.beginPath();
    ctx.moveTo(stroke[0].x, stroke[0].y);
    for (let i = 1; i < stroke.length; i++) {
      ctx.lineTo(stroke[i].x, stroke[i].y);
    }
    ctx.strokeStyle = this.currentColor;
    ctx.lineWidth = this.brushSize;
    ctx.lineCap = LineCap.Round;
    ctx.lineJoin = LineJoin.Round;
    ctx.stroke();
  }
}

撤销的算法可以概括为:「栈弹出 + 全量重绘」。这不是最高效的方式,但胜在逻辑简单、易于理解。对于本应用的规模(几十到上百笔),性能完全够用。

4.9 保存画作为图片

这是 ArkTS Canvas 的特色功能 —— getPixelMap() 方法将 Canvas 当前帧转为 PixelMap,再通过 image.ImagePacker 编码为 PNG 二进制数据:

import { image } from '@kit.ImageKit';

private saveDrawing(): void {
  const ctx = this.context;
  // 1. Canvas → PixelMap
  const pixelMap = ctx.getPixelMap(0, 0, ctx.canvas.width, ctx.canvas.height);
  if (!pixelMap) {
    promptAction.showToast({ message: '⚠️ 保存失败,请重试', duration: 1500 });
    return;
  }

  // 2. PixelMap → PNG ArrayBuffer
  const packer: image.ImagePacker = image.createImagePacker();
  const packOpts: image.PackingOption = { format: 'image/png', quality: 100 };
  const arrayBuffer: ArrayBuffer = packer.packing(pixelMap, packOpts);
  packer.release();    // 释放编码器资源

  // 3. 显示文件大小
  const sizeKB: number = Math.round(arrayBuffer.byteLength / 1024);
  promptAction.showToast({
    message: `✅ 画作已保存!(${sizeKB}KB)`,
    duration: 2000,
  });
}

三步流程

Canvas 当前帧
    ↓ getPixelMap(x, y, width, height)
PixelMap(像素图)
    ↓ image.ImagePacker.packing(pixelMap, options)
ArrayBuffer(PNG 二进制数据)
    ↓ fileIo / mediaLibrary(写入文件系统)
图片文件

本示例只走到 ArrayBuffer 并以 Toast 显示大小。实际如需写入相册,可以使用 @ohos.file.fs@ohos.multimedia.mediaLibrary


第五章:模块二 —— 写文案页面(WritePage)

5.1 功能需求

  1. 文字输入与编辑
  2. 实时字数统计
  3. 字号可调(12~28px)
  4. 一键复制到剪贴板
  5. 6 种快捷模板一键填充

5.2 状态与 UI 结构

@Component
struct WritePage {
  @State private content: string = '';         // 文案内容
  @State private fontSize: number = 16;         // 字号
  @State private templateIndex: number = -1;    // 当前选中的模板索引

  private readonly templates: string[] = [
    '✨ 生活的诗意,藏在每一个认真的日子里。\n—— 极简创作 · 每日一句',
    '🎯 新品上市!限时特惠,错过等一年~\n点击链接立即抢购 👉',
    '📸 镜头定格的瞬间,是时光写给未来的情书。',
    '💪 自律不是束缚,而是通往自由的钥匙。\n今天也要加油鸭! 🦆',
    '🌅 早安!新的一天,新的可能。\n愿你心中有光,脚下有路。',
    '🎉 感恩遇见,感谢相伴。\n这份心意,只给最特别的你。',
  ];
}

5.3 TextArea 多行文本输入

TextArea({
  text: this.content,     // 绑定 @State 变量
  placeholder: '在这里输入文案……\n💡 点击下方「快捷模板」一键填充',
})
.width('100%')
.height(240)
.fontSize(this.fontSize)  // 字号跟随 @State
.backgroundColor(Color.White)
.borderRadius(16)
.padding(14)
.placeholderFont({ size: 13, color: '#CCC' })
.onChange((val: string) => { this.content = val; })

TextArea vs TextInput

  • TextArea:多行文本,适合段落式文案
  • TextInput:单行文本,适合用户名/搜索框

双向绑定text: this.content + onChange 组合实现了类似 React 受控组件的效果:

  • 用户输入 → onChange 触发 → this.content 更新 → UI 重新渲染
  • @State content 被修改 → 界面上文字内容同步更新

5.4 实时字数统计

Row() {
  Text('✍️ 开始创作你的文案').fontSize(15).fontWeight(FontWeight.Medium)
  Blank()                             // 占据剩余空间,将右侧内容推到末尾
  Text(`${this.content.length}`)   // 利用 @State content 实时计算
    .fontSize(12)
    .fontColor('#AAA')
}

${this.content.length} 字 是一个计算属性表达式。每次 this.content 变化(用户输入、点击模板)时,content.length 重新计算,UI 自动刷新。这就是声明式 UI 的魔力——你不需要手动更新「字数」这个 DOM 节点,框架会自动追踪依赖并执行最小粒度的更新。

5.5 系统剪贴板

import { pasteboard } from '@kit.BasicServicesKit';

private copyToClipboard(): void {
  if (!this.content.trim()) {
    promptAction.showToast({ message: '⚠️ 还没有内容可以复制', duration: 1200 });
    return;
  }
  try {
    // 1. 创建纯文本 PasteData
    const data: pasteboard.PasteData =
      pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.content);
    // 2. 获取系统剪贴板并写入
    pasteboard.getSystemPasteboard().setPasteData(data);
    promptAction.showToast({ message: '✅ 文案已复制到剪贴板', duration: 1500 });
  } catch (err) {
    promptAction.showToast({ message: '⚠️ 复制失败,请手动复制', duration: 1500 });
  }
}

为什么要用 try-catch?

系统 API 在不同设备、不同权限配置下可能抛出异常。例如用户在设置中关闭了剪贴板读取权限。用 try-catch 包裹系统调用,可以在异常时给出友好的提示,而不是让应用崩溃。

剪贴板 API 流程

1. createData(mimeType, text)  →  构造剪贴板数据对象
2. getSystemPasteboard()       →  获取系统剪贴板实例(单例)
3. setPasteData(data)          →  写入剪贴板

pasteboard.MIMETYPE_TEXT_PLAIN 表示纯文本类型。还支持 MIMETYPE_TEXT_HTML 等格式。

5.6 快捷模板

模板按钮采用胶囊式设计(borderRadius(20)),用 Flex + wrap 实现自动折行:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
  ForEach(this.templates, (tpl: string, idx: number) => {
    Row() {
      Text(tpl.substring(0, 18) + (tpl.length > 18 ? '…' : ''))
        .fontSize(12).fontColor('#555')
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(idx === this.templateIndex ? '#6C63FF' : '#F0F0FF')
    .borderRadius(20).margin(4)
    .onClick(() => {
      this.templateIndex = idx;
      this.content = tpl;       // 填充模板内容到编辑区
    })
  })
}

关键设计

  • 模板文字超过 18 个字截断加 ,保持按钮整洁
  • 选中态背景紫色(#6C63FF),非选中淡紫(#F0F0FF
  • 点击时同时更新 templateIndex(选中态)和 content(编辑区内容)

第六章:模块三 —— 修照片页面(PhotoEditPage)

6.1 功能需求

  1. 加载示例图片(无需外部文件,Canvas 绘制生成)
  2. 6 种滤镜效果:原图 / 黑白 / 怀旧 / 反转 / 冷色 / 暖色
  3. 亮度调节(30%~200%)
  4. 对比度调节(30%~200%)
  5. 重置为原图
  6. 保存修图结果

6.2 像素滤镜原理

修照片功能的技术核心是 Canvas 像素级操作。一张数字图片由百万个像素组成,每个像素有四个通道值:R(红)、G(绿)、B(蓝)、A(透明度),取值范围 0~255。

滤镜的本质就是修改每个像素的 RGB 值

读取像素 → 数学变换 → 写回像素
[原始像素]       [滤镜公式]        [处理后像素]
 R=200, G=100     黑白滤镜         R=128, G=128
 B=50, A=255      ----→           B=128, A=255

6.3 状态定义

@Component
struct PhotoEditPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private isReady: boolean = false;

  @State private hasImage: boolean = false;
  @State private currentFilter: string = '原图';
  @State private brightness: number = 100;
  @State private contrast: number = 100;
  @State private sampleImageLoaded: boolean = false;

  private originalData: ImageData | null = null;   // 原始像素数据缓存
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;

  private readonly filters: FilterDef[] = [
    { name: '原图', icon: '🟦' },
    { name: '黑白', icon: '⬛' },
    { name: '怀旧', icon: '🟫' },
    { name: '反转', icon: '🔄' },
    { name: '冷色', icon: '🔵' },
    { name: '暖色', icon: '🟠' },
  ];
}

6.4 加载示例图片

我们不需要外部图片文件,直接用 Canvas API 绘制一幅完整的彩色风景图:

private loadSampleImage(): void {
  if (!this.isReady) return;
  const ctx = this.context;
  const w = ctx.canvas.width;
  const h = ctx.canvas.height;
  this.canvasWidth = w;
  this.canvasHeight = h;

  // ---- 1. 渐变天空 ----
  const grad = ctx.createLinearGradient(0, 0, w, h);
  grad.addColorStop(0, '#FF6B6B');
  grad.addColorStop(0.3, '#FFD93D');
  grad.addColorStop(0.6, '#6BCB77');
  grad.addColorStop(1, '#4D96FF');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, w, h);

  // ---- 2. 太阳(带阴影辉光) ----
  ctx.beginPath();
  ctx.arc(w * 0.3, h * 0.35, w * 0.12, 0, Math.PI * 2);
  ctx.fillStyle = '#FFD93D';
  ctx.fill();
  ctx.shadowColor = 'rgba(255,217,61,0.4)';
  ctx.shadowBlur = 30;
  ctx.fill();       // 第二次填充,带辉光效果
  ctx.shadowBlur = 0;

  // ---- 3. 山峦 ----
  ctx.beginPath();
  ctx.moveTo(0, h * 0.75);
  ctx.lineTo(w * 0.25, h * 0.45);
  ctx.lineTo(w * 0.5, h * 0.7);
  ctx.lineTo(w * 0.75, h * 0.4);
  ctx.lineTo(w, h * 0.65);
  ctx.lineTo(w, h);
  ctx.lineTo(0, h);
  ctx.closePath();
  ctx.fillStyle = '#2D3436';
  ctx.globalAlpha = 0.3;
  ctx.fill();
  ctx.globalAlpha = 1;

  // ---- 4. 树木(5 棵三角形树) ----
  for (let i = 0; i < 5; i++) {
    const tx = w * (0.1 + i * 0.2);
    const ty = h * (0.5 + Math.sin(i * 1.5) * 0.08);
    // 树冠(三角形)
    ctx.beginPath();
    ctx.moveTo(tx, ty);
    ctx.lineTo(tx - w * 0.04, ty + h * 0.15);
    ctx.lineTo(tx + w * 0.04, ty + h * 0.15);
    ctx.closePath();
    ctx.fillStyle = '#6BCB77';
    ctx.globalAlpha = 0.5;
    ctx.fill();
    // 树干
    ctx.fillStyle = '#8B4513';
    ctx.globalAlpha = 0.4;
    ctx.fillRect(tx - 3, ty + h * 0.15, 6, h * 0.05);
  }
  ctx.globalAlpha = 1;

  // ---- 5. 水印文字 ----
  ctx.font = `bold ${Math.round(w * 0.06)}px sans-serif`;
  ctx.fillStyle = 'rgba(255,255,255,0.8)';
  ctx.textAlign = 'center';
  ctx.fillText('🌄 示例风景', w / 2, h * 0.92);

  // ---- 6. 保存原始像素 ----
  this.originalData = ctx.getImageData(0, 0, w, h);

  this.hasImage = true;
  this.sampleImageLoaded = true;
  this.currentFilter = '原图';
  this.brightness = 100;
  this.contrast = 100;
}

Canvas 绘图技巧

shadowBlur + shadowColor 实现发光效果。太阳绘制两次:第一次是常规填充,第二次带阴影辉光。记得在辉光填充后将 shadowBlur 重置为 0,避免后续绘制受影响。

globalAlpha 控制全局透明度。绘制山峦时设为 0.3(半透明),绘制树木时设为 0.5,绘制结束后恢复为 1。

6.5 像素滤镜算法

这是全应用技术含量最高的部分。我们逐行解析:

private applyCurrentFilter(): void {
  if (!this.hasImage || !this.sampleImageLoaded || !this.originalData) return;

  const ctx = this.context;
  const w = this.canvasWidth;
  const h = this.canvasHeight;

  // 从原始像素数据拷贝,避免累积失真
  const srcData = new Uint8ClampedArray(this.originalData.data);
  const imgData = new ImageData(srcData, w, h);
  const pixels = imgData.data;

  const bFactor = this.brightness / 100;   // 亮度系数
  const cFactor = this.contrast / 100;     // 对比度系数
  const cMid = 128;                        // RGB 中点值

  // 遍历每个像素(每 4 个值为一组:R, G, B, A)
  for (let i = 0; i < pixels.length; i += 4) {
    let r = pixels[i];
    let g = pixels[i + 1];
    let b = pixels[i + 2];

    // ========== 滤镜计算 ==========
    switch (this.currentFilter) {

      case '黑白': {
        // 灰度公式:根据人眼对 RGB 的敏感度加权平均
        const gray = 0.299 * r + 0.587 * g + 0.114 * b;
        r = gray; g = gray; b = gray;
        break;
      }

      case '怀旧': {
        // Sepia 棕褐色调矩阵
        r = 0.393 * r + 0.769 * g + 0.189 * b;
        g = 0.349 * r + 0.686 * g + 0.168 * b;
        b = 0.272 * r + 0.534 * g + 0.131 * b;
        break;
      }

      case '反转': {
        // 取反色
        r = 255 - r;
        g = 255 - g;
        b = 255 - b;
        break;
      }

      case '冷色': {
        // 削弱红/绿,增强蓝
        r = r * 0.7;
        b = Math.min(255, b * 1.3);
        g = g * 0.9;
        break;
      }

      case '暖色': {
        // 增强红,削弱蓝
        r = Math.min(255, r * 1.3);
        b = b * 0.7;
        g = g * 0.95;
        break;
      }
    }

    // ========== 亮度调节(乘法) ==========
    r = r * bFactor;
    g = g * bFactor;
    b = b * bFactor;

    // ========== 对比度调节(以中点 128 为支点拉伸/压缩) ==========
    r = (r - cMid) * cFactor + cMid;
    g = (g - cMid) * cFactor + cMid;
    b = (b - cMid) * cFactor + cMid;

    // ========== 钳位到合法范围 [0, 255] ==========
    pixels[i]     = Math.max(0, Math.min(255, r));
    pixels[i + 1] = Math.max(0, Math.min(255, g));
    pixels[i + 2] = Math.max(0, Math.min(255, b));
    // alpha (pixels[i+3]) 保持不变
  }

  // 写回 Canvas
  ctx.putImageData(imgData, 0, 0);
}
逐行算法详解

灰度公式

Gray = 0.299 × R + 0.587 × G + 0.114 × B

这是 ITU-R BT.601 国际标准的亮度公式。人眼对绿色最敏感(权重 0.587),对蓝色最不敏感(权重 0.114)。这比简单平均((R+G+B)/3)更符合人眼感知。

怀旧(Sepia)矩阵

R' = 0.393R + 0.769G + 0.189B
G' = 0.349R + 0.686G + 0.168B
B' = 0.272R + 0.534G + 0.131B

这是一个 3×3 颜色矩阵变换。R 通道获得来自 G 和 B 的贡献(0.769G + 0.189B),B 通道被显著衰减(系数仅 0.131),整体呈现暖黄色调。

对比度公式

R' = (R - 128) × cFactor + 128

以 128 为「支点」:

  • cFactor > 1(对比度 > 100%):小于 128 的值向 0 偏移,大于 128 的值向 255 偏移 → 对比度增强
  • cFactor < 1(对比度 < 100%):所有值向 128 靠拢 → 对比度降低
  • cFactor = 1(对比度 = 100%):不变

钳位操作

Math.max(0, Math.min(255, value))

RGB 值必须严格在 0~255 之间。亮度、对比度、滤镜计算都可能产生超出范围的值(如 r * 1.3 > 255),钳位操作确保像素数据合法。

6.6 避免累积失真

每次应用滤镜时,必须从原始数据重新计算,而不是在上次结果上叠加:

// ✅ 正确:从原始数据拷贝
const srcData = new Uint8ClampedArray(this.originalData.data);
const imgData = new ImageData(srcData, w, h);

// ❌ 错误:从当前 Canvas 读取(会累积失真)
// const currentData = ctx.getImageData(0, 0, w, h);
// const pixels = currentData.data;  // 如果在已处理过的像素上再次计算,误差会越来越大

为什么不能累积?

每次滤镜计算涉及浮点运算和钳位,都存在精度损失。如果在已处理过的像素上再次套用滤镜,每次的微小误差会不断累积放大。几次操作后,图片就会严重失真。解决方案就是始终保留一份原始数据副本。


第七章:通用组件与类型定义

7.1 类型定义

/** 绘图的单个点 */
interface DrawPoint {
  x: number;
  y: number;
}

/** 一笔完整的笔画 */
type DrawStroke = DrawPoint[];

/** 滤镜定义 */
interface FilterDef {
  name: string;
  icon: string;
}

ArkTS 中 interfacetype 的行为与标准 TypeScript 一致。DrawStroke 作为 DrawPoint[] 的类型别名,可以让代码语义更清晰。

7.2 ActionButton 通用按钮组件

@Component
struct ActionButton {
  private label: string = '';
  private bgColor: string = '#6C63FF';
  private onClickAction: () => void = () => {};

  build() {
    Button(this.label)
      .fontSize(13)
      .fontWeight(FontWeight.Medium)
      .fontColor('#FFFFFF')
      .backgroundColor(this.bgColor)
      .borderRadius(20)
      .height(36)
      .padding({ left: 18, right: 18 })
      .onClick(() => { this.onClickAction(); })
  }
}

设计亮点

  • 胶囊形状borderRadius(20)height(36) 配合,两端形成完美半圆
  • 事件委托:通过 onClickAction 属性将点击事件交给父组件处理
  • 颜色可定制bgColor 默认紫色,父组件可自由替换

使用示例:

Row({ space: 14 }) {
  ActionButton('↩ 撤销', '#FF6B6B', () => this.undo())
  ActionButton('🗑 清空', '#845EC2', () => this.clearCanvas())
  ActionButton('💾 保存', '#6C63FF', () => this.saveDrawing())
}

第八章:Flex 响应式卡片流布局(FlexCardLayout)

8.1 为什么需要响应式卡片流

在移动端开发中,商品列表、图片墙的自适应排列是一个高频需求。传统做法需要:

  1. 监听屏幕宽度变化
  2. 计算每行显示多少列
  3. 为不同宽度编写不同的样式

在 ArkTS 中,Flex + wrap + layoutWeight 的组合可以零代码实现完全自适应的卡片流布局。

8.2 Flex 核心代码

Flex({
  wrap: FlexWrap.Wrap,           // 超出换行
  direction: FlexDirection.Row,  // 水平排列
  justifyContent: FlexAlign.SpaceBetween,
  alignItems: ItemAlign.Start,
}) {
  ForEach(this.commodities, (item: Commodity, index: number) => {
    this.CommodityCard(item, index)
  }, (item: Commodity) => item.id.toString())
}
.width('100%')

FlexWrap.Wrap 的作用:当子元素的总宽度超过容器宽度时,自动折行。这是「流式布局」的基石。

8.3 layoutWeight 响应式等分

每张卡片设置 .layoutWeight(1),表示所有卡片权重相等,行内剩余空间被均分给每张卡片:

@Builder
CommodityCard(item: Commodity, index: number) {
  Column({ space: 6 }) {
    Row() {
      Text(item.icon).fontSize(32)
    }
    .width('100%').aspectRatio(1.0)
    .backgroundColor(item.imageColor)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    .alignItems(VerticalAlign.Center)

    Text(item.name)
      .fontSize(13).maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    Text(`¥${item.price}`)
      .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF6B81')
  }
  .layoutWeight(1)                                   // ← 核心:等分
  .constraintSize({ minWidth: 100, maxWidth: 200 })  // ← 边界保护
  .margin(6)
  .backgroundColor(Color.White)
  .borderRadius(14)
  .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetY: 3 })
}

layoutWeight 的工作机制

Flex 容器宽度 = 375dp
卡片 1 宽度 = 150dp(由 layoutWeight 计算得到)
卡片 2 宽度 = 150dp
剩余宽度 75dp → 按权重均分 → 每张卡片再分 37.5dp
最终每张卡片 = 187.5dp

加上 constraintSize({ minWidth, maxWidth }) 的限制:

设备宽度 每行列数 排列效果
360dp(手机) 3 列 三列紧凑排列
600dp(大屏) 4~5 列 宽松排列
800dp+(平板) 5~6 列 充分利用宽屏

完全不需要编写任何断点。这是 ArkTS Flex 布局优于传统 CSS Grid/Flexbox 的地方——盒模型是原生的响应式单位。


第九章:@State 响应式数据流深入理解

9.1 声明式 UI 的核心思想

ArkTS 的响应式数据流遵循一个简单的闭环:

@State 变量被修改
       ↓
框架自动追踪依赖该变量的组件
       ↓
组件重新执行 build()
       ↓
UI 更新
       ↓
等待下一次 @State 修改

开发者只需要修改 @State 变量,框架负责 UI 更新。这比命令式编程(document.getElementById('xxx').innerText = newValue)效率更高、代码更简洁。

9.2 @State 数组的特殊行为

ArkTS 对 @State 数组进行了深度优化。直接调用数组的变异方法可以触发 UI 刷新:

@State private items: string[] = ['A', 'B', 'C'];

// 以下操作都会触发 UI 刷新:
this.items.push('D');         // ✅ 新增
const last = this.items.pop(); // ✅ 删除
this.items[0] = 'X';           // ✅ 修改索引位
this.items.sort();             // ✅ 排序

这与 React 的 useState 不同:

// React 中:数组变异方法不会触发重渲染
items.push('D');           // ❌ 不会更新 UI
setItems([...items, 'D']); // ✅ 必须创建新数组

ArkTS 在编译阶段检测 @State 数组的 pushpopsplice 等调用,自动插入响应式更新代码。这使得代码更接近直觉——直接修改数据即可。

9.3 本项目中的 @State 使用

组件 @State 变量 驱动内容
CreativeApp currentIndex Tab 切换
DrawPage currentColor, brushSize, isDrawing 颜色盘高亮、笔刷滑条值、工具栏文字
WritePage content, fontSize, templateIndex 编辑区文字、字数统计、模板选中态
PhotoEditPage hasImage, currentFilter, brightness, contrast 占位图显示隐藏、滤镜选中态、滑条值

第十章:构建与运行

10.1 构建命令

在项目根目录打开终端,使用 hvigorw 构建:

# 完整构建(生成 HAP 包)
hvigorw assembleHap --no-daemon --mode module -p module=entry

# 清理构建缓存
hvigorw clean --no-daemon

10.2 构建输出解读

> CompileArkTS... after 3 s 159 ms    ← ArkTS 编译通过
> PackageHap... after 457 ms          ← 打包成功
> BUILD SUCCESSFUL in 8 s 157 ms      ← 构建成功
WARN: Will skip sign 'hos_hap'.       ← 签名警告(调试环境正常)
No signingConfigs profile is configured.

签名警告:调试阶段可以忽略。发布应用时需在 build-profile.json5 中配置正式签名信息。

10.3 常见错误及解决

错误 可能原因 解决
CompileArkTS failed 语法错误 查看具体错误行号
Cannot find name 'XXX' 类型未导入 添加 import 语句
@Builder parameter error @Builder 使用了解构参数 改为独立参数形式
Task not found 构建命令错误 检查 task 名称拼写
Module not found 导入路径错误 检查文件路径和模块名

第十一章:最佳实践与常见陷阱

11.1 最佳实践

1. @Builder 参数使用独立形式参

// ✅ 正确
@Builder Card(name: string, price: number) { ... }
this.Card('商品', 99)

// ❌ 错误(新版本不支持)
@Builder Card({ name, price }: { name: string, price: number }) { ... }
this.Card({ name: '商品', price: 99 })

2. ForEach 必须提供 keyGenerator

// keyGenerator(第三个参数)帮助框架追踪列表项,避免全量重渲染
ForEach(list, (item) => { Item(item) }, (item) => item.id.toString())

3. 非 UI 数据不放 @State

不需要驱动 UI 刷新的数据(Canvas 上下文、临时缓存)声明为普通私有变量。

4. 系统 API 用 try-catch 包裹

剪贴板、文件系统等系统 API 可能因权限或设备差异抛出异常。

5. Tabs 受控模式双向绑定

Tabs({ index: this.currentIndex, onChange: (idx) => { this.currentIndex = idx } })
// index 和 onChange 必须同时设置

11.2 常见陷阱

陷阱 1:混淆 @Builder 和 @Component 的传参方式

// @Builder 调用传递独立参数
this.TabBarItem('🎨', '绘图', 0)

// @Component 调用传递对象属性
SectionHeader({ title: '标题', subtitle: '副标题' })

陷阱 2:Canvas 坐标未转换

在 ArkUI 中,event.touches[0].x 已相对于组件本身,不需要额外转换。

陷阱 3:像素滤镜未从原始数据拷贝

每次应用滤镜必须从 originalData 新建 Uint8ClampedArray 拷贝,避免多次叠加导致的精度损失。

陷阱 4:@State 对象属性修改

// 推荐的做法:整体替换对象
this.user = { ...this.user, name: 'NewName' };

// 直接修改属性在某些场景可能不触发刷新
// this.user.name = 'NewName'; // 不推荐

第十二章:扩展方向

本教程构建的应用已具备完整的三大功能,以下是一些扩展方向供你继续探索:

绘图模块扩展

  • 保存画作到系统相册(使用 @ohos.multimedia.mediaLibrary
  • 每笔独立记录颜色和笔刷大小
  • 插入背景图片
  • 文字工具
  • 导出为 SVG 矢量格式

文案模块扩展

  • 多文档管理(类似标签页)
  • 字体选择(使用系统字体列表)
  • 排版工具(居中对齐、列表格式)
  • 版本历史(撤销/恢复)
  • 接入大模型 API 智能生成文案

修图模块扩展

  • 从系统相册读取图片(使用 photoAccessHelper
  • 自定义色温/饱和度调节
  • 裁剪工具(在 Canvas 上绘制裁剪框)
  • 贴纸叠加
  • 滤镜预设组合

全局功能

  • 暗色模式适配(通过 @Styles@Extend 管理主题)
  • 国际化多语言
  • 手势返回(结合 onBackPress
  • 分享功能(通过 @ohos.want 拉起系统分享)

结语

通过本教程,你从零构建了一个包含绘图、文案、修图三大功能的鸿蒙原生应用,掌握了 ArkTS 的核心概念:

概念 理解程度
@Entry / @Component 能创建页面和组件
@State 响应式数据 理解数据驱动 UI 的闭环
@Builder 构建函数 能封装可复用的 UI 片段
Tabs 底部导航 掌握受控模式
Canvas 2D 绘图 能实现手指绘画和像素滤镜
Flex + wrap + layoutWeight 能构建自适应响应式布局
pasteboard 剪贴板 能读写系统剪贴板
image.ImagePacker 能编码 Canvas 为图片

910 行代码,三大创作功能,零外部依赖——这就是 HarmonyOS NEXT ArkTS 的开发效率。希望这份教程能成为你鸿蒙开发之路的起点。


本文所有代码基于 HarmonyOS NEXT 6.1.1(API 24)编写。源码位于项目 entry/src/main/ets/pages/CreativeApp.ets(910 行)及 FlexCardLayout.ets(583 行)。

Logo

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

更多推荐