0
0
0

鸿蒙造物者之路 06 · 北向初见

文章摘要
|

续写章节|沿用「造物者」系列的武侠讲解体,面向电子信息 / 嵌入式方向的新手

=================前情提要===============

南向五章打完地基:GPIO、UART、I2C、SPI、PWM、ADC、中断、并发。我能让板子读温湿度、闪灯、打日志。

但老陈一句话戳破了天花板——“它没有脸。”

他把跑着界面的 RK3568 推到我面前:一个圆形温度表盘,底下写着「客厅 · 26.5°C」。

“从今天起往北走。学 ArkTS 和 ArkUI——给你板子做一张脸。”

========================================


这一章你会得到什么

🎯 学完这一章,你应该能:

  • ☐ 装好 DevEco Studio,建出第一个项目并跑起来
  • ☐ 理解声明式 UI 和命令式的根本区别(这是全章最重要的转变)
  • ☐ 会用 @State / @Prop / @Link / @Observed 这一套状态管理
  • ☐ 会用 if / ForEach / LazyForEach 做渲染控制
  • ☐ 知道改了变量界面却不刷新时,该查哪三件事

**🧰 需要准备:**Windows 或 Mac + 8 GB 内存 + DevEco Studio(比南向环境好装得多)。

**⏱ 预计用时:**约 4 小时

📊 难度:★★★☆☆ 上手快,但思维方式要转弯


一、第一次打开 DevEco Studio

"北向的地盘在 Windows 上。"老陈把笔记本推给我,“装 DevEco Studio。”

我双击图标。启动画面闪过,界面弹出来——跟 Android Studio 长得几乎一模一样。

"我熟!"我脱口而出。

"熟就对了。"老陈说,“它本来就是拿 IntelliJ IDEA 改的,你用过的快捷键、菜单、调试器,全都能平移。”

1.1 创建第一个项目

File → New → Create Project
  → 选 "Application"(应用)或 "Atomic Service"(元服务)
  → 选模板 "Empty Ability"
  → 填配置:
       Project name:  MyFirstApp
       Bundle name:   com.example.myfirstapp   ← 应用唯一标识,反写域名
       Save location: ...
       Compatible SDK: 选默认的 API 版本
       Model:         Stage            ← 【重要】选 Stage,不要选 FA
  → Finish

“Model 选 Stage。“老陈特意强调,”**FA 模型是老的了,新项目一律 Stage。**这两个的区别我下一章整章讲。”

1.2 项目长什么样

MyFirstApp/
├── AppScope/
│   ├── app.json5                 应用级配置
│   └── resources/
├── entry/                        模块(默认叫 entry)
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets      ← 应用入口
│   │   │   └── pages/
│   │   │       └── Index.ets             ← 页面(你要写的地方)
│   │   ├── resources/
│   │   │   ├── base/element/string.json  字符串资源
│   │   │   ├── base/media/               图片
│   │   │   └── base/profile/
│   │   └── module.json5          模块配置
│   ├── build-profile.json5
│   └── oh-package.json5          依赖
└── build-profile.json5

“你 90% 的时间,都在 ets/pages/ 下写代码。”

1.3 生成的默认页面

我打开 Index.ets,看到系统自动生成的:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    Row() {
      Column() {
        Text(this.message)
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
    }
    .height('100%')
  }
}

我盯着这段代码看了十秒。

“……这是什么语法?”

老陈笑了:“欢迎来到声明式 UI。”

二、ArkTS:一个"被管教过的" TypeScript

"先说语言。"老陈说,“ArkTS 是TypeScript 的超集——你在 TS 里会的东西,大部分能直接用。”

“那为什么不用原生 TS?”

“因为 TS 太自由了,自由到编译器没法优化。”

2.1 为什么要"管教"它

"打个比方。"老陈在白板上写:

// TypeScript 允许的写法
let x: any = 10;
x = "hello";              // 运行时才知道 x 是什么类型
x = (() => { return {}; })();

function process(obj) {
    obj.newField = 123;   // 运行时给对象加属性
    delete obj.oldField;  // 运行时删属性
}

“这些写法的代价是什么?”

我想了想:“运行时得一直检查类型?”

"对。运行时必须保留类型信息、必须做类型推演、必须能随时改变对象结构。"老陈说,“这些都会拖慢执行,而且让 AOT(提前编译)几乎不可能。”

“ArkTS 的做法是——把上面这些全禁掉。”

2.2 ArkTS 的主要限制

限制 具体表现 为什么
不能用 any / unknown 所有类型必须明确 编译器才能确定布局
不能运行时改对象结构 不能动态加/删属性 对象布局编译期固定
对象字面量要标类型 let p: Point = {x:1, y:2} 同上
用名义类型(nominal typing) 结构相同但类名不同 = 不同类型 简化类型判定
不能用 var 只能 let / const 作用域更明确
函数内 this 受限 只能用成员函数/箭头函数 避免歧义

“看个具体例子:”

// ❌ ArkTS 不允许
let data: any = getData();
data.whatever = 1;

// ✅ 正确的写法
interface SensorData {
  temp: number;
  humi: number;
}
let data: SensorData = { temp: 26.5, humi: 60 };
data.temp = 27.0;          // ✅ 改已有属性,可以
data.pressure = 1013;      // ❌ 加新属性,不允许

“规则很简单:一个对象的’形状’,在你写下它的那一刻就定死了。”

“那我要动态数据怎么办?”

"用类(class),或者用 Map / 数组。"老陈说,“真需要灵活结构,就用 Map<string, Object> 或者 Record。”

2.3 还是熟悉的那些东西

“除了上面这些限制,剩下的基本就是你认识的 TS:”

// 基础类型
let count: number = 0;
let name: string = '阿粽';
let isOn: boolean = true;
let list: number[] = [1, 2, 3];
let map: Map<string, number> = new Map();

// 类
class Sensor {
  private addr: number = 0x44;
  public name: string = 'SHT30';

  read(): number {
    return 26.5;
  }
}

// 接口
interface Device {
  id: string;
  connect(): boolean;
}

// 枚举
enum LedState { Off, On, Blink }

// 箭头函数、解构、展开、模板字符串 —— 都在
const greet = (n: string): string => `你好,${n}`;

“所以你不用重学一门语言,只需要记住那几条’不许’。”

三、声明式 UI:最大的思维转变

"现在讲最要紧的。"老陈坐直了。

“你写过安卓的界面吧?”

“写过,XML 布局 + findViewById。”

"那是命令式。"老陈说,“我先画好界面,然后拿到那个控件的引用,然后命令它:setText、setVisibility、setBackgroundColor。”

他写下对比:

【命令式 —— 安卓 / 传统方式】
  1. 写一个 XML,描述界面长什么样
  2. 在代码里 findViewById 拿到控件
  3. 数据变了 → 手动调 setText() 更新界面
  4. 界面变了 → 手动读值、手动同步到数据
        ↑
  你要自己维护"数据"和"界面"的一致性

【声明式 —— ArkUI】
  1. 写一段代码,描述"当状态是 X 时,界面应该是 Y"
  2. 状态变了 → 框架自动重新计算界面
        ↑
  你只描述"结果",框架负责"怎么变"

"这就是核心区别。"老陈说,“命令式是’你去改那个控件’,声明式是’状态变了,界面自己就知道该长什么样’。”

3.1 三个关键词

“ArkUI 的骨架,就三个东西:”

@Entry              // ① 入口:这是一个页面
@Component          // ② 组件:这是一个可复用的 UI 单元
struct Index {      //   注意:是 struct,不是 class
  @State count: number = 0;      // ③ 状态:会触发界面刷新的变量

  build() {         //   构建:描述界面长什么样
    // UI 描述写在这里
  }
}

“struct,不是 class?”

"对,ArkUI 用 struct 定义组件。"老陈说,“这是 ArkTS 的语法。你把它当成一个特殊的类就行,里面可以有成员变量和方法,只是不能继承。”

3.2 build():描述"界面长什么样"

“build() 里写的,是界面应该长什么样,而不是’怎么把界面变成那样’。”

build() {
  Column() {                              // 纵向排列容器
    Text('温度')                            // 文字
      .fontSize(16)
      .fontColor('#888888')

    Text('26.5°C')                        // 文字
      .fontSize(48)
      .fontWeight(FontWeight.Bold)

    Button('刷新')                          // 按钮
      .onClick(() => {                    // 点击事件
        this.refresh();
      })
  }
  .width('100%')
  .padding(20)
}

“注意链式调用的写法——.fontSize().fontColor().onClick()。这是 ArkUI 的风格,写完组件后面一串点,把属性和事件都挂上去。”

3.3 状态驱动:核心中的核心

“现在看这个:”

@Entry
@Component
struct Counter {
  @State count: number = 0;      // ← 注意 @State

  build() {
    Column() {
      Text(`点击了 ${this.count} 次`)
        .fontSize(24)

      Button('点我 +1')
        .onClick(() => {
          this.count++;          // ← 只改变量,不碰 UI
        })
    }
  }
}

"注意 this.count++ 这一行。"老陈敲了敲屏幕,“我没有调用任何 setText、没有刷新任何控件。我只是把变量加了 1,界面上的数字就自己变了。”

“……为什么?”

"因为 @State。"老陈说,“被 @State 标记的变量,框架会监听它。一旦它的值变了,框架就知道’用到这个变量的界面要重新算一遍’,然后自动刷新。”

他画了个图:

    用户点击
       │
       ▼
  this.count++  ────►   @State 检测到变化
                              │
                              ▼
                    框架找到"依赖 count 的 UI"
                              │
                              ▼
                    重新执行 build() 的相关部分
                              │
                              ▼
                        界面自动更新

“这就是声明式的全部魔法:你只管改数据,UI 自己跟着走。”

我沉默了一会儿。

“……这也太爽了吧。”

"是爽。"老陈说,“但代价是——你得接受一个观念转变:你不再’操作 UI’了,你只’描述 UI’。”

“什么转变?”

"你看你南向写的代码——IoTGpioSetOutputVal(9, 1),这是命令:去,把那根引脚拉高。你控制一切。

“但在北向,你写 Text(this.count.toString()),这是声明:这里应该显示 count 的值。你不控制它什么时候刷新,框架控制。”

老陈看着我:“从’我控制一切’到’我只描述结果’——这个弯,很多人转不过来。”

四、状态管理装饰器全家桶

"一个 @State 不够用。"老陈说,“真实项目里,数据要在组件之间传递。ArkUI 给了一套装饰器,每种管一种场景。”

4.1 一张图看懂

                    数据流向
    ┌──────────────────────────────────────┐
    │                                      │
  父组件 ────► 子组件                        │
    │                                      │
    ├─ @Prop        单向:父 → 子(子改了不回传)
    ├─ @Link        双向:父 ↔ 子(子改了父也变)
    ├─ @Provide     跨层级下发(祖先 → 所有后代)
    │  @Consume     跨层级接收
    │
  组件内部
    └─ @State       自己管自己

  对象 / 嵌套
    └─ @Observed + @ObjectLink    观察对象内部属性的变化

  全局
    ├─ LocalStorage   页面级共享
    └─ AppStorage     应用级共享
    """

4.2 逐个数

@State —— 组件自己的状态

@Component
struct MyCard {
  @State isExpanded: boolean = false;

  build() {
    Column() {
      if (this.isExpanded) {
        Text('这里是展开后的详细内容')
      }
      Button(this.isExpanded ? '收起' : '展开')
        .onClick(() => { this.isExpanded = !this.isExpanded; })
    }
  }
}

“@State 的变量必须有初始值,而且它的变化会触发本组件刷新。”

@Prop —— 单向同步(父 → 子)

@Component
struct Child {
  @Prop title: string;        // 从父组件传进来

  build() {
    Text(this.title)
  }
}

@Entry
@Component
struct Parent {
  @State msg: string = '你好';

  build() {
    Column() {
      Child({ title: this.msg })     // 传进去
      Button('改')
        .onClick(() => { this.msg = '再见'; })   // 父改了,子跟着变
    }
  }
}

“@Prop 是单向的。子组件改了 @Prop 的值,不会传回父组件。”

@Link —— 双向同步(父 ↔ 子)

@Component
struct Switch {
  @Link isOn: boolean;        // 双向绑定

  build() {
    Toggle({ type: ToggleType.Switch, isOn: this.isOn })
      .onChange((v: boolean) => { this.isOn = v; })   // 子改了,父的也变
  }
}

@Entry
@Component
struct Parent {
  @State lightOn: boolean = false;

  build() {
    Column() {
      Switch({ isOn: $lightOn })          // ← 注意 $ 符号!
      Text(this.lightOn ? '灯开着' : '灯关了')
    }
  }
}

“注意 $ 符号。“老陈强调,”**传 @Link 参数要用 $变量名,不是直接写变量名。**这是新手最常忘的语法。”

@Provide / @Consume —— 跨层级

“如果组件嵌套很深,一层层传参数很烦,用这对:”

@Entry
@Component
struct GrandParent {
  @Provide themeColor: string = '#0F9D58';   // 提供

  build() {
    Column() {
      Middle()      // 中间层不用传参
    }
  }
}

@Component
struct Middle {
  build() { Column() { Leaf() } }      // 完全不关心 themeColor
}

@Component
struct Leaf {
  @Consume themeColor: string;         // 直接消费

  build() {
    Text('我是深层组件').fontColor(this.themeColor)
  }
}

“**中间的组件完全不用知道这件事。**这在深层嵌套的界面里特别省事。”

@Observed / @ObjectLink —— 观察对象内部

"最后这个是最容易踩坑的。"老陈说。

// ❌ 问题:@State 观察不到对象内部属性的变化
class Device {
  name: string;
  online: boolean;
  constructor(name: string) {
    this.name = name;
    this.online = false;
  }
}

@Entry
@Component
struct Bad {
  @State dev: Device = new Device('客厅灯');

  build() {
    Column() {
      Text(this.dev.online ? '在线' : '离线')
      Button('切换')
        .onClick(() => {
          this.dev.online = !this.dev.online;   // ❌ 可能不刷新!
        })
    }
  }
}

“为什么可能不刷新?”

"因为 @State 只观察’引用有没有变’。"老陈说,“你改的是对象内部的属性,引用没变,框架可能察觉不到。”

“怎么解决?”

“**加 @Observed 和 @ObjectLink:”

@Observed                        // ← 标记这个类可被观察
class Device {
  name: string;
  online: boolean;
  constructor(name: string) {
    this.name = name;
    this.online = false;
  }
}

@Component
struct DeviceItem {
  @ObjectLink dev: Device;       // ← 用 @ObjectLink 接收

  build() {
    Row() {
      Text(this.dev.name)
      Text(this.dev.online ? '在线' : '离线')
    }
    .onClick(() => {
      this.dev.online = !this.dev.online;    // ✅ 现在能刷新了
    })
  }
}

@Entry
@Component
struct Good {
  @State devices: Device[] = [new Device('客厅灯'), new Device('卧室灯')];

  build() {
    List() {
      ForEach(this.devices, (item: Device) => {
        DeviceItem({ dev: item })
      }, (item: Device) => item.name)
    }
  }
}

“口诀:类要 @Observed,接收要用 @ObjectLink,数组里的对象才能被正确观察。”

4.3 装饰器速查表

装饰器 方向 用途 传参写法
@State 自身 组件内部状态 —
@Prop 父 → 子(单向) 子组件只读展示 Child({ title: this.msg })
@Link 父 ↔ 子(双向) 子组件可修改并回传 Child({ val: $myVal })
@Provide / @Consume 祖先 → 后代 跨层级传递 无需显式传参
@Observed + @ObjectLink — 观察对象内部属性 Child({ obj: item })
@StorageLink / @StorageProp 全局 AppStorage 应用级共享 —
@LocalStorageLink / @LocalStorageProp 页面内 LocalStorage 页面级共享 —
@Builder — 抽出可复用的 UI 片段 this.myBuilder()
@BuilderParam — 给组件留"插槽" —

五、渲染控制:条件、循环、懒加载

5.1 if / else

build() {
  Column() {
    if (this.isLoading) {
      LoadingProgress().width(40).height(40)
    } else if (this.errorMsg !== '') {
      Text(this.errorMsg).fontColor(Color.Red)
    } else {
      Text(`温度:${this.temp}°C`).fontSize(24)
    }
  }
}

“就是普通的 if/else,只不过写在 UI 描述里。”

5.2 ForEach:渲染列表

@State devices: Device[] = [...];

build() {
  List() {
    ForEach(
      this.devices,                              // ① 数据源
      (item: Device, index: number) => {         // ② 每一项怎么渲染
        ListItem() {
          Row() {
            Text(item.name)
            Text(item.online ? '在线' : '离线')
          }
        }
      },
      (item: Device, index: number) => item.id   // ③ key 生成函数
    )
  }
}

"第三个参数(key)非常重要。"老陈强调,“框架靠 key 判断’哪一项变了’。key 必须唯一且稳定——用 id,别用 index。”

“用 index 会怎样?”

"列表插入/删除时会错乱。"老陈说,“比如你在中间删了一项,后面所有项的 index 都变了,框架会以为全都变了,导致错误的刷新和动画。”

5.3 LazyForEach:长列表必备

“列表有一万条怎么办?”

“用 LazyForEach,它只渲染看得见的那几项。”

// 1. 先实现数据源
class DeviceDataSource implements IDataSource {
  private list: Device[] = [];

  totalCount(): number { return this.list.length; }

  getData(index: number): Device { return this.list[index]; }

  registerDataChangeListener(listener: DataChangeListener): void {}
  unregisterDataChangeListener(listener: DataChangeListener): void {}
}

// 2. 使用
build() {
  List() {
    LazyForEach(this.dataSource, (item: Device) => {
      ListItem() { Text(item.name) }
    }, (item: Device) => item.id)
  }
  .cachedCount(3)      // 上下各缓存 3 项,滑动更顺
}

“**ForEach 一次性全渲染,LazyForEach 按需渲染。**超过一屏的列表就用 Lazy。”

六、做一个真东西:温湿度卡片

"光说不练。"老陈说,“把你南向读到的温湿度,做个界面出来。”

// pages/SensorCard.ets

@Observed
class SensorReading {
  temp: number = 0;
  humi: number = 0;
  updatedAt: string = '';

  constructor(temp: number, humi: number) {
    this.temp = temp;
    this.humi = humi;
    this.updatedAt = new Date().toLocaleTimeString();
  }
}

@Entry
@Component
struct SensorPage {
  @State reading: SensorReading = new SensorReading(0, 0);
  @State isLoading: boolean = false;
  @State isOnline: boolean = true;

  // 模拟从设备读数据(后面会换成真实的网络/NAPI 调用)
  private async refresh(): Promise<void> {
    this.isLoading = true;
    // 假装请求了 800ms
    await new Promise<void>(r => setTimeout(r, 800));
    this.reading = new SensorReading(
      26.5 + Math.random() * 2 - 1,      // 温度带点随机
      60 + Math.random() * 10 - 5        // 湿度带点随机
    );
    this.isLoading = false;
  }

  // 温度颜色
  private tempColor(t: number): string {
    if (t > 30) return '#E74C3C';      // 热 → 红
    if (t < 15) return '#3498DB';      // 冷 → 蓝
    return '#0F9D58';                   // 舒适 → 绿
  }

  aboutToAppear(): void {
    this.refresh();      // 页面出现时读一次
  }

  build() {
    Column({ space: 20 }) {
      // —— 标题栏 ——
      Row() {
        Text('客厅 · 传感器')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
        Blank()                                    // 撑开中间
        Circle({ width: 10, height: 10 })
          .fill(this.isOnline ? '#0F9D58' : '#CCCCCC')
      }
      .width('100%')

      // —— 主卡片 ——
      Column({ space: 8 }) {
        if (this.isLoading) {
          LoadingProgress().width(50).height(50)
          Text('读取中...').fontSize(14).fontColor('#999999')
        } else {
          Text(`${this.reading.temp.toFixed(1)}°C`)
            .fontSize(56)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.tempColor(this.reading.temp))

          Text(`湿度 ${this.reading.humi.toFixed(0)}%`)
            .fontSize(16)
            .fontColor('#666666')

          Text(`更新于 ${this.reading.updatedAt}`)
            .fontSize(12)
            .fontColor('#AAAAAA')
        }
      }
      .width('100%')
      .height(200)
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .justifyContent(FlexAlign.Center)
      .shadow({ radius: 12, color: '#1A000000', offsetY: 2 })

      // —— 刷新按钮 ——
      Button('刷新数据')
        .width('60%')
        .onClick(() => { this.refresh(); })
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .backgroundColor('#F5F5F5')
  }
}

"跑一下。"老陈说。

我点开右侧的 Previewer,界面几秒后刷出来了:一张白卡片,中间一个大大的绿色数字「26.3°C」,下面一行「湿度 61%」,最底下「更新于 16:42:15」。

我点了一下"刷新数据",数字变了。

“……这也太快了。”

"这就是预览器的价值。"老陈说,“改一行代码,几百毫秒后界面就更新了,不用编译、不用部署、不用重启。”

“南向编一次要几分钟,这个几秒钟?”

"所以北向的开发效率比南向高一个数量级。"老陈说,“代价是——你离硬件远了。”

=================小剧场模式=================

我:老师,我承认,这个写起来确实爽。但有个地方我一直不适应。

老陈:哪儿?

我:我不知道界面"什么时候"刷新。 南向的时候,GpioWrite 一调用,引脚立刻变高,我知道它发生了。现在我改个 this.count++,界面到底什么时候变?立刻?下一帧?会不会漏?

老陈:你不需要知道。

我:(急)但我习惯知道啊!

老陈:这就是我要你转的那个弯。 你在南向是"指挥官",每一个动作都是你下的命令。在北向,你是"描述者"——你只负责说"状态是 3 的时候,显示 3",至于框架什么时候刷、怎么刷、刷哪一块,是它的事。

我:那我要是想知道呢?

老陈:可以学,但不是现在。 框架内部有脏检查、有差分更新、有渲染树复用——那是进阶内容。你现在要做的,是相信它。

我:……相信它。

老陈:对。 就像你相信编译器会把你的 C 代码变成正确的机器码一样。你不检查每一条汇编,对吧?

我:……确实不检查。

老陈:同一个道理。 先把注意力放在"我要什么结果"上,别纠结"它怎么做到"。

========================================

七、响应式布局:一次开发,多端部署

"对了,"老陈像是想起了什么,“第 01 章我跟你吹过’一次开发,多端部署’。现在该兑现了。”

“怎么做到的?”

“靠三样东西:自适应布局、断点、栅格。”

7.1 第一招:自适应布局属性

“最简单的——不要写死尺寸,让布局自己撑。”

属性 作用 例子
百分比 按父容器比例 .width('100%')、.width('50%')
layoutWeight 按比例瓜分剩余空间 .layoutWeight(1)
aspectRatio 锁定宽高比 .aspectRatio(1) 正方形
displayPriority 空间不够时按优先级隐藏 .displayPriority(2)
Blank() 撑开中间的空白 Row(){ Text(); Blank(); Text() }
flexGrow/flexShrink Flex 弹性伸缩 —
// ❌ 写死尺寸 —— 换设备就废了
Column() {
  Text('标题').width(360).height(48)
}

// ✅ 自适应 —— 手机、平板、车机都能看
Column() {
  Text('标题')
    .width('100%')
    .height(48)
}
Row() {
  Text('左侧').layoutWeight(1)     // 占 1 份
  Text('右侧').layoutWeight(2)     // 占 2 份(是左边的两倍宽)
}
.width('100%')

7.2 第二招:断点(Breakpoint)

“**断点就是’屏幕宽度分档’。**系统按当前窗口宽度,把你归到某一档。”

档位 窗口宽度 典型设备
xs < 320 vp 极小屏、手表
sm 320 ~ 600 vp 手机竖屏
md 600 ~ 840 vp 手机横屏、折叠屏展开、小平板
lg ≥ 840 vp 平板、智慧屏、车机
import mediaQuery from '@ohos.mediaquery';

@Entry
@Component
struct ResponsivePage {
  @State bp: string = 'sm';      // 当前断点
  private listenerSm = mediaQuery.matchMediaSync('(320vp<=width<600vp)');
  private listenerLg = mediaQuery.matchMediaSync('(840vp<=width)');

  aboutToAppear(): void {
    this.listenerSm.on('change', (res: mediaQuery.MediaQueryResult) => {
      if (res.matches) { this.bp = 'sm'; }
    });
    this.listenerLg.on('change', (res: mediaQuery.MediaQueryResult) => {
      if (res.matches) { this.bp = 'lg'; }
    });
  }

  aboutToDisappear(): void {
    this.listenerSm.off('change');      // ← 记得注销
    this.listenerLg.off('change');
  }

  build() {
    // 小屏单列,大屏双列
    if (this.bp === 'lg') {
      Row({ space: 16 }) {        // 大屏:并排
        Text('温度卡片').layoutWeight(1)
        Text('湿度卡片').layoutWeight(1)
      }.width('100%').padding(24)
    } else {
      Column({ space: 16 }) {     // 小屏:竖排
        Text('温度卡片')
        Text('湿度卡片')
      }.width('100%').padding(16)
    }
  }
}

“vp 是什么单位?”

"虚拟像素(Virtual Pixel)。"老陈说,“不同屏幕密度不一样,用物理像素写会出乱子。vp 会自动按屏幕密度换算,你在不同设备上看到的物理大小基本一致。 字体用 fp,逻辑一样。”

7.3 第三招:栅格系统

“如果你要做规整的多列布局,用栅格更省事。”

// 栅格:一行 12 格,按比例分配
GridRow({ columns: 12, gutter: 12 }) {
  GridCol({ span: { sm: 12, md: 6, lg: 4 } }) {    // 手机占满12格,平板占6格,大屏占4格
    Text('卡片 1').width('100%').height(120).backgroundColor('#FFF').borderRadius(12)
  }
  GridCol({ span: { sm: 12, md: 6, lg: 4 } }) {
    Text('卡片 2').width('100%').height(120).backgroundColor('#FFF').borderRadius(12)
  }
  GridCol({ span: { sm: 12, md: 6, lg: 4 } }) {
    Text('卡片 3').width('100%').height(120).backgroundColor('#FFF').borderRadius(12)
  }
}
效果:
手机(sm,12格):         平板(md,6格):        大屏(lg,4格):
┌──────────────┐         ┌───────┬───────┐       ┌────┬────┬────┐
│    卡片 1     │         │卡片1  │卡片2  │       │卡1 │卡2 │卡3 │
├──────────────┤         ├───────┴───────┤       └────┴────┴────┘
│    卡片 2     │         │    卡片 3      │
├──────────────┤         └───────────────┘
│    卡片 3     │
└──────────────┘

“注意 span 的写法:{ sm: 12, md: 6, lg: 4 } —— 不同断点给不同的格数。一份代码,三种设备三种排布。”

7.4 资源限定词

“最后一招:不同设备放不同的资源。”

resources/
├── base/                 ← 默认资源
│   ├── element/string.json
│   └── media/icon.png
├── phone/                ← 手机专用
│   └── element/string.json
├── tablet/               ← 平板专用
│   └── element/string.json
├── wearable/             ← 手表专用
│   └── element/string.json
└── en_US/                ← 英文
    └── element/string.json

“**系统会自动挑最合适的那份。**你代码里只写 $r('app.string.title'),不用管它拿的是哪一份。”

"这就是’一次开发多端部署’的全部家底。"老陈说,“自适应 + 断点 + 栅格 + 资源限定。没有魔法,全是工程手段。”

八、常见坑

现象 原因 解法
改了变量界面不刷新 ① 没加 @State
② 改的是对象内部属性,没用 @Observed/@ObjectLink
③ 数组直接 push,引用没变
① 加装饰器
② 类加 @Observed,用 @ObjectLink
③ 重新赋值整个数组:this.list = [...this.list, newItem]
子组件改了值,父组件没变 用的是 @Prop(单向) 换成 @Link,传参加 $
传 @Link 报错 忘了 $ 符号 Child({ val: $myVal })
列表刷新错乱 ForEach 的 key 用了 index key 用唯一 id
长列表卡顿 用了 ForEach 渲染全部 换 LazyForEach + cachedCount
预览器不刷新 语法错误 / 需要保存 保存文件(Ctrl+S);看右下角错误提示
编译报类型错 用了 any / 动态加属性 明确类型,用类或 Map

“最后一条最重要——“老陈圈出第一行,”‘改了变量界面不刷新’,95% 都是这三个原因。”

九、本章速查

【组件骨架】
@Entry @Component struct Xxx {
  @State 变量: 类型 = 初值;
  build() { ... }
}

【常用容器】
Column()     纵向      Row()    横向
Stack()      层叠      Flex()   弹性
List()       列表      Grid()   网格
Swiper()     轮播      Tabs()   页签

【常用组件】
Text()      Image()     Button()      TextInput()
Toggle()    Slider()    Progress()    LoadingProgress()

【状态装饰器】
@State        自身
@Prop / @Link 父子(单向 / 双向)
@Provide / @Consume    跨层级
@Observed / @ObjectLink   对象内部

【渲染控制】
if / else if / else
ForEach(数据源, 渲染函数, key函数)
LazyForEach(数据源, 渲染函数, key函数)    长列表

【生命周期】
aboutToAppear()     组件即将出现
aboutToDisappear()  组件即将销毁
onPageShow()        页面显示(仅 @Entry)
onPageHide()        页面隐藏(仅 @Entry)
onBackPress()       返回键(仅 @Entry)

"这一章你只需要记住一句话。"老陈收拾东西。

“哪句?”

“你只描述结果,框架负责过程。”

“那接下来?”

老陈指着我写的那个温湿度卡片。

“你现在做的这个页面,是’死的’——它不会转场、不会保存状态、按下返回键就退出。”

“那要怎么让它’活’起来?”

"给它一个舞台。"老陈说,“下一章讲 Stage 模型——应用的生命周期、Ability、页面路由、Want 传参。”

"到那时候,"他笑了笑,“你写的就不只是一个页面,而是一个真正的应用。”


本章自检

过一遍下面的问题。答不上来的,回去翻对应小节——那些就是你这个章的欠账。

  • ☐ 命令式和声明式的核心区别是什么?各举一个例子。
  • ☐ @Prop 和 @Link 的区别?传 @Link 参数时忘了写 $ 会怎样?
  • ☐ 为什么改了对象内部的属性,界面可能不刷新?怎么解决?
  • ☐ ForEach 的 key 为什么不能用 index?
  • ☐ 一万个条目的列表,该用什么?
  • ☐ 说出一句总结:北向开发里,你负责什么,框架负责什么?

**➡️ 下一站:**第 07 章 · 舞台 —— 给你的应用装上骨架。


=================剧情片段=================

导演:卡。北向第一讲,主角转过弯来了。

助理:从"我控制一切"到"我只描述结果"——这个转变讲得挺透的。

导演:这是北向和南向最本质的区别。 讲不透这个,后面学什么都是死记硬背。

助理:下一章是 Stage 模型?

导演:对。Ability、生命周期、Want、路由。

助理:听起来有点枯燥。

导演:是有点。 但它是应用的骨架——**你现在写的所有页面,都跑在一个 Ability 里。**你不知道 Ability 什么时候生、什么时候死、什么时候切后台,就写不对任何需要保存状态的功能。

助理:比如?

导演:比如用户切到后台再回来,你的数据还在不在? 比如来了个电话,你的视频该不该暂停?再比如你那个温湿度卡片,凭什么每次打开都能接着上次的数据?

助理:……确实都是真问题。

导演:所以下一章——

助理:《舞台》。

========================================

未完待续。。。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论

欢迎来到我的博客!