鸿蒙造物者之路 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 什么时候生、什么时候死、什么时候切后台,就写不对任何需要保存状态的功能。
助理:比如?
导演:比如用户切到后台再回来,你的数据还在不在? 比如来了个电话,你的视频该不该暂停?再比如你那个温湿度卡片,凭什么每次打开都能接着上次的数据?
助理:……确实都是真问题。
导演:所以下一章——
助理:《舞台》。
========================================
未完待续。。。


