1. 学习目标

完成本章学习后,你应当能够:
2. 使用 Image、Text 和 TextInput 构建农业设备界面。
3. 为组件设置常用属性,处理图片显示、文字样式和输入类型。
4. 使用 onClick 等事件完成按钮交互。
5. 理解页面路由与交互流程之间的关系。
6. 区分关系型数据库 RelationalStore 和偏好型数据库 Preferences。
7. 根据数据复杂度选择合适的本地存储方式。
8. 设计农业设备本地数据表和键值配置。
9. 使用事务、查询、插入、更新和删除等基本数据库操作思路。
10. 使用 @State 等状态机制驱动 UI 数据刷新。
11. 处理加载、空数据、错误和保存成功等界面状态。
12. 对比“重新查询刷新”“局部状态更新”“定时刷新”“事件触发刷新”等策略。
13. 编写简短技术分析报告,说明实践中的问题、取舍与收获。
14. 在数据安全、权限、设备控制和工程伦理方面保持责任意识。

2.图片组件 Image

2.1 组件作用

Image 用于显示本地图片、资源图片或符合要求的网络图片。常见用途:
• 元服务卡片图标;
• 农业设备类型图;
• 状态提示图;
• 空数据插图;
• 用户头像或设备照片;
• 背景和装饰图片。
基本示例:

Image($r('app.media.device_pump'))
  .width(64)
  .height(64)

资源引用的具体形式应以当前 DevEco Studio 工程模板为准。

2.2 图片来源

本地资源
本地资源适合:
• 应用图标;
• 固定的设备类型图;
• 空状态插图;
• 不需要联网的页面元素。
优点:
• 加载稳定;
• 不依赖网络;
• 响应速度快;
• 便于版本控制。
网络图片
网络图片适合:
• 设备现场照片;
• 远程图片;
• 动态更新的宣传图或缩略图。
使用网络图片时要考虑:
• 网络失败;
• 加载时间;
• 图片尺寸过大;
• 缓存;
• 访问权限;
• 用户隐私;
• 图片地址是否可信;
• 是否需要占位图和错误图。
网络图片不应直接被视为可信内容。来源不明的图片地址可能导致隐私泄露、恶意跳转或不必要的流量消耗。

2.3 图片显示模式

图片原始比例与容器比例不一致时,需要选择合适的显示策略。常见思路:
在这里插入图片描述
实际 API 名称和属性会随 SDK 版本变化,学习时重点掌握“保持比例、裁剪、填充”的设计原则。

2.4 图片加载状态

网络图片至少应考虑:
加载中 → 成功显示
└→ 失败显示占位图或错误提示
概念示例:

type ImageState =
  | { kind: 'loading' }
  | { kind: 'success'; source: string }
  | { kind: 'error' };

@Component
struct DeviceImage {
  @Prop state: ImageState;

  build() {
    if (this.state.kind === 'loading') {
      Text('图片加载中')
    } else if (this.state.kind === 'success') {
      Image(this.state.source)
    } else {
      Image($r('app.media.image_placeholder'))
    }
  }
}

对于卡片,应优先保证核心数据可读,图片加载失败不应导致整个卡片无法使用。

2.5 图片可用性与性能

• 使用适合显示尺寸的图片,避免把超大原图直接放入小卡片。
• 对网络图片设置缓存策略。
• 重要图片准备占位图。
• 图片无法加载时提供可理解的反馈。
• 图片旁边配合文字,不能只依靠颜色或图像传达关键状态。
• 设备照片涉及人员、地块和位置时,要遵守隐私和数据使用规定。

3.文本组件 Text

3.1 组件作用

Text 用于显示字符串内容,是界面中最基础的信息组件。常见用途:
• 页面标题;
• 设备名称;
• 状态文本;
• 数量和单位;
• 时间;
• 错误提示;
• 操作说明。
基本示例:

Text('智慧农场')
  .fontSize(20)
  .fontWeight(FontWeight.Bold)

3.2 常用文本属性

Text('在线设备:18')
  .fontSize(16)
  .fontColor('#087443')
  .fontWeight(FontWeight.Medium)
  .maxLines(1)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

常见属性包括:
在这里插入图片描述
具体属性名称以当前 SDK 的类型提示为准。

3.3 文本层级设计

农业设备卡片可以采用:
一级信息:智慧农场
二级信息:在线设备数量
三级信息:最近更新时间
辅助信息:设备位置、说明文字
示例:

Column({ space: 4 }) {
  Text('东区智能灌溉泵')
    .fontSize(16)
    .fontWeight(FontWeight.Bold)

  Text('东区一号田')
    .fontSize(13)
    .opacity(0.65)

  Text('最近同步:08:30')
    .fontSize(12)
    .opacity(0.55)
}

不要让所有文字都使用同样的字号、颜色和粗细,否则用户很难判断重点。

3.4 长文本与异常数据

设备名称、位置和错误消息可能很长,应进行限制:

function displayName(name: string): string {
  const maxLength = 18;

  return name.length <= maxLength
    ? name
    : `${name.slice(0, maxLength - 1)}…`;
}

对外部数据还要考虑:
• 空字符串;
• null;
• 未知状态;
• 超大数字;
• 非法日期;
• 包含特殊字符的设备名称。
界面显示应使用安全的文本渲染方式,不要把外部输入当作可执行内容。

4.输入组件 TextInput

4.1 组件作用

TextInput 提供文本输入框功能,常用于:
• 搜索设备;
• 输入设备名称;
• 输入位置;
• 输入备注;
• 填写账号;
• 输入密码;
• 输入数字或编号。
示例:

@Component
struct EquipmentSearch {
  @State keyword: string = '';

  build() {
    TextInput({
      placeholder: '请输入设备名称或位置',
      text: this.keyword
    })
      .onChange((value: string) => {
        this.keyword = value;
      })
      .width('100%')
  }
}

具体参数名可能因 SDK 版本不同而变化,应参考 IDE 的类型提示。

4.2 输入类型

常见输入类型:
• 普通文本;
• 密码;
• 数字;
• 电话号码;
• 邮箱;
• 多行文本。
输入类型的选择应与业务匹配。例如:

设备名称 → 文本
设备编号 → 文本或数字,取决于编号规则
电量百分比 → 数字
登录密码 → 密码
设备备注 → 多行文本

不要把所有输入框都设置成普通文本后再依赖字符串处理,合理的输入类型可以改善键盘、校验和用户体验。

4.3 受控输入与状态

将输入值绑定到状态:

@State keyword: string = '';

输入变化时更新:

.onChange((value: string) => {
  this.keyword = value.trimStart();
})

界面使用:

Text(`当前搜索:${this.keyword}`)

数据流:

用户输入
  ↓
onChange 事件
  ↓
更新 @State
  ↓
绑定的 Text 或列表重新显示

4.4 输入校验

设备名称校验

function validateName(name: string): string | null {
  const value = name.trim();

  if (value.length === 0) {
    return '设备名称不能为空';
  }

  if (value.length > 50) {
    return '设备名称不能超过 50 个字符';
  }

  return null;
}
电量校验
function parseBattery(input: string): number | null {
  const value = Number(input);

  if (!Number.isInteger(value) || value < 0 || value > 100) {
    return null;
  }

  return value;
}

输入框的校验应在提交前再次执行,不能只依赖 UI 控件的输入类型。

4.5 防抖搜索

搜索输入每变化一次都发起请求,可能造成大量请求:

输 入 “ 水 泵 ”
 ↑  ↑  ↑
多次触发网络或数据库查询
可以采用防抖思路:
用户停止输入一小段时间
  ↓
只执行最后一次搜索

课程练习可以先实现本地过滤,再研究定时器和防抖。对于本地数组,数据量不大时不必过度复杂化。

4.6 输入安全

• 限制输入长度;
• 对设备名称和备注进行合法性检查;
• 不在日志中打印密码;
• 不把密码写入普通偏好配置;
• 外部输入显示时进行安全处理;
• 对数字输入检查范围和小数位;
• 对真实设备编号进行格式校验。

5.点击事件 onClick

5.1 基本写法

Button('刷新')
  .onClick(() => {
    this.refreshData();
  })

也可以将事件处理函数单独定义:

@Component
struct RefreshControl {
  @State loading: boolean = false;

  private handleRefresh = (): void => {
    if (this.loading) {
      return;
    }

    void this.refreshData();
  };

  private async refreshData(): Promise<void> {
    this.loading = true;

    try {
      // 读取数据
    } finally {
      this.loading = false;
    }
  }

  build() {
    Button(this.loading ? '刷新中' : '刷新')
      .onClick(this.handleRefresh)
  }
}

5.2 点击事件的完整流程

一个可靠的点击操作通常包含:

用户点击
  ↓
检查当前状态
  ├── 不允许操作 → 提示原因
  └── 允许操作
        ↓
显示处理中
        ↓
执行同步/数据库操作
        ↓
成功 → 更新状态并反馈
失败 → 显示错误并允许重试

5.3 防止重复点击

@State saving: boolean = false;

private async saveEquipment(): Promise<void> {
  if (this.saving) {
    return;
  }

  this.saving = true;

  try {
    await insertEquipment();
  } catch (error: unknown) {
    console.error(`保存失败:${String(error)}`);
  } finally {
    this.saving = false;
  }
}

需要注意:前端防重复点击只是第一道保护。服务层也应考虑幂等性和重复请求。

5.4 不同按钮的责任

在这里插入图片描述
涉及删除和远程控制的按钮,需要比普通查看按钮更严格的确认和权限检查。

6.页面路由与交互流程

本章培养能力中包含页面路由。即使当前重点是基础组件,也应理解组件交互与页面跳转的关系。

6.1 为什么需要路由

当页面内容变复杂时,通常会拆成:

设备首页
  ├── 设备列表
  ├── 设备详情
  ├── 新增设备
  └── 设置页面

路由负责在这些页面之间切换,并传递必要参数。

6.2 路由参数

进入详情页时通常需要传递设备 ID:

设备列表 → 设备详情
             └── equipmentId = 1001
             

建议传递稳定的业务 ID,而不是完整对象:

推荐:传 equipmentId
谨慎:传整个设备对象

原因:
• 详情页可以重新读取最新数据;
• 避免列表对象与详情对象不同步;
• 参数更小;
• 页面恢复时更可靠。

6.3 路由错误处理

点击详情时可能出现:
• 设备已删除;
• ID 无效;
• 本地数据库读取失败;
• 页面不存在;
• 权限不足。
详情页应准备:

加载中
设备存在 → 显示详情
设备不存在 → 显示“设备不存在”
读取失败 → 显示错误和重试

7.关系型数据库 RelationalStore

7.1 适用场景

关系型数据库适合保存结构化、数量较多、需要查询和关联的数据,例如:
• 农业设备列表;
• 设备状态历史;
• 传感器采样记录;
• 告警记录;
• 操作日志;
• 用户与设备的关系;
• 分页、排序和条件筛选数据。
课程材料将 RelationalStore 与 SQLite 结构化存储联系起来。不同系统版本的 API 初始化方式、配置字段和异步接口可能不同,实际编码应以当前 HarmonyOS SDK 文档和 IDE 模板为准。

7.2 数据库基本概念

  1. 数据库
    存放一个应用或模块的数据集合。

  2. 按照主题组织数据,例如:
equipment:设备表
equipment_log:设备日志表
alarm:告警表

  1. 表中的一条记录,对应一台设备或一条日志。

  2. 记录的字段,例如:
id、name、status、location、battery、updated_at
  1. 主键
    唯一标识一行数据,设备表中的 id 通常可以作为主键。
  2. 索引
    帮助提高查询速度,但索引越多,写入和存储开销越大。

7.3 农业设备表设计

示例字段:
在这里插入图片描述
设计原则:
• 主键必须唯一;
• 状态和类别值要有约束;
• 电量应限制在合理范围;
• 时间格式统一;
• 不要把多个无关值拼在一个字段中;
• 设备详情和日志记录需要时可以拆成不同表。

7.4 建表语句示意

CREATE TABLE equipment (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  category TEXT NOT NULL,
  status TEXT NOT NULL,
  location TEXT NOT NULL,
  battery INTEGER NOT NULL,
  updated_at TEXT NOT NULL,
  created_at TEXT NOT NULL
);

说明:这是数据库设计示意。实际 RelationalStore 的建库、建表和执行 SQL API 需要按照当前 SDK 的接口完成。

7.5 CRUD 操作

CRUD 是数据库最基本的四类操作:
在这里插入图片描述

  1. 新增
INSERT INTO equipment
(id, name, category, status, location, battery, updated_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);

不要把用户输入直接拼接到 SQL 字符串中,应使用参数绑定,避免 SQL 注入和引号处理错误。
2. 查询

SELECT id, name, category, status, location, battery, updated_at
FROM equipment
ORDER BY updated_at DESC;
  1. 条件查询
SELECT *
FROM equipment
WHERE status = ? AND name LIKE ?;
  1. 更新
UPDATE equipment
SET status = ?, battery = ?, updated_at = ?
WHERE id = ?;
  1. 删除
DELETE FROM equipment
WHERE id = ?;

删除前应进行确认,并根据业务需要记录删除操作日志。

7.6 事务

事务用于保证一组数据库操作“要么全部成功,要么全部失败”。
例如新增设备时,同时写入设备表和初始化日志表:

开始事务
  ↓
写入 equipment
  ↓
写入 equipment_log
  ├── 全部成功 → 提交事务
  └── 任一步失败 → 回滚事务

适合使用事务的场景:
• 多表写入;
• 批量新增或更新;
• 设备状态和日志必须同步;
• 删除设备及相关数据;
• 导入一批设备数据。
事务并不是越多越好。只把必须保持一致的一组操作放在同一事务中,事务时间过长会影响并发和性能。

7.7 查询结果与类型转换

数据库返回的数据需要转换为业务对象:

interface Equipment {
  id: string;
  name: string;
  category: EquipmentCategory;
  status: EquipmentStatus;
  location: string;
  battery: number;
  updatedAt: string;
}

转换时检查:
• 字段是否存在;
• 数字是否真的为数字;
• 状态是否属于允许集合;
• 时间是否可以解析;
• 空值是否符合业务规则。
不要认为“数据库字段有类型”就可以跳过应用层检查。

8.偏好型数据库 Preferences

8.1 适用场景

Preferences 以键值对形式保存轻量配置,适合:
• 用户选择的主题;
• 上次选择的筛选条件;
• 是否显示引导页;
• 最近一次打开的页面;
• 卡片刷新偏好;
• 简单的用户设置;
• 小规模、结构简单的本地配置。
示意:

"theme" → "dark"
"pageSize" → 20
"showOfflineOnly" → true
"lastLocation" → "东区一号田"

8.2 不适合保存什么

不建议使用 Preferences 保存:
• 大量设备记录;
• 需要复杂条件查询的数据;
• 设备状态历史;
• 多表关联数据;
• 大量图片或二进制内容;
• 需要事务保证的数据;
• 明文密码和敏感凭证。

8.3 读写思路

概念示例:

interface UserPreferences {
  theme: 'light' | 'dark';
  pageSize: number;
  lastStatus: EquipmentStatus | 'all';
}

保存:

const preferences: UserPreferences = {
  theme: 'dark',
  pageSize: 20,
  lastStatus: 'offline'
};

// 使用当前 SDK 的 Preferences API 保存键值
读取:

const theme = 'light'; // 读取不到时使用默认值

实际 API 名称、实例获取方式和异步调用方式应以当前 SDK 文档为准。学习重点是“按 key 保存小型配置,读取时提供默认值”。

8.4 默认值策略

配置读取失败或 key 不存在时,不应让页面直接崩溃:

function normalizePageSize(value: unknown): number {
  if (
    typeof value !== 'number' ||
    !Number.isInteger(value) ||
    value < 1 ||
    value > 100
  ) {
    return 20;
  }

  return value;
}

默认值应:
• 符合业务规则;
• 对用户可理解;
• 不造成高风险操作;
• 与页面初始状态一致。
8.5 Preferences 安全注意事项
• 不要保存明文密码。
• 不要把访问令牌当普通偏好项处理。
• 不要在日志中打印全部键值。
• 需要敏感数据时使用符合系统要求的安全存储方案。
• 用户退出账号后清理与账号相关的设置。
• 版本升级时考虑配置迁移。

9.RelationalStore 与 Preferences 对比

在这里插入图片描述
选择原则:
需要列表查询、条件筛选、排序或多条记录
→ RelationalStore

只是保存几个简单配置项
→ Preferences
两者可以同时使用:

RelationalStore:保存设备和日志
Preferences:保存用户上次使用的筛选条件

10.UI 数据刷新机制

10.1 为什么需要刷新

数据发生变化后,界面必须同步反映新结果。例如:

数据库中的设备状态由 offline 改为 online
  ↓
页面仍显示 offline
  ↓
用户看到过期信息

因此需要建立:

数据源变化 → 更新状态 → UI 重新显示

10.2 使用 @State 驱动刷新

@Component
struct EquipmentListPage {
  @State items: Equipment[] = [];
  @State loading: boolean = false;
  @State errorMessage: string = '';

  build() {
    Column() {
      if (this.loading) {
        Text('正在加载')
      } else if (this.errorMessage.length > 0) {
        Text(this.errorMessage)
      } else {
        ForEach(
          this.items,
          (item: Equipment) => {
            Text(item.name)
          },
          (item: Equipment) => item.id
        )
      }
    }
  }
}

当执行:
this.items = newItems;
绑定 items 的 UI 会根据新数据刷新。

10.3 不要只修改数据库而不更新界面

错误流程:

点击保存
  ↓
写入数据库
  ↓
结束

此时数据库是新数据,页面可能仍然是旧数据。
更完整的流程:

点击保存
  ↓
校验输入
  ↓
写入数据库
  ↓
重新查询或更新内存状态
  ↓
更新 @State
  ↓
显示成功反馈

10.4 重新查询策略

保存后重新查询:

private async saveAndReload(): Promise<void> {
  await saveToDatabase();
  const latestItems = await queryEquipments();
  this.items = latestItems;
}

优点:
• 页面与数据库最终结果一致;
• 适合复杂触发器、默认值和多表变更;
• 不容易漏掉服务端或数据库自动修改的字段。
缺点:
• 多一次查询;
• 数据量大时成本更高;
• 需要处理查询失败。

10.5 局部状态更新策略

保存成功后直接修改状态:

private updateLocalItem(
  id: string,
  status: EquipmentStatus
): void {
  this.items = this.items.map(item =>
    item.id === id
      ? { ...item, status }
      : item
  );
}

优点:
• 响应快;
• 减少查询;
• 适合简单、明确的局部变化。
缺点:
• 如果数据库还有其他自动变化,页面可能不完整;
• 多个页面之间可能不同步;
• 需要确保本地更新逻辑与持久化逻辑一致。

10.6 刷新策略对比

在这里插入图片描述
本章要求学生对这些策略进行比较,并结合智慧农业场景说明选择理由。

10.7 加载、空数据与错误状态

type ViewState<T> =
  | { kind: 'loading' }
  | { kind: 'success'; data: T }
  | { kind: 'empty'; message: string }
  | { kind: 'error'; message: string };

渲染时:

function getViewMessage<T>(state: ViewState<T>): string {
  switch (state.kind) {
    case 'loading':
      return '正在加载设备数据……';
    case 'success':
      return '数据加载成功';
    case 'empty':
      return state.message;
    case 'error':
      return state.message;
  }
}

页面应区分:
• “正在加载”;
• “加载成功但没有数据”;
• “筛选后没有结果”;
• “请求失败”;
• “保存失败”;
• “权限不足”。
这些状态不能都显示成“暂无数据”,否则用户无法判断下一步。

Logo

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

更多推荐