网络状态信息案例新特性接入

网络状态信息查询实战:基于状态管理 V2 的完整实现指南

摘要 :本文基于 HarmonyOS (API 23+)平台,使用 ArkTS 语言与状态管理 V2@ComponentV2@ObservedV2@Trace@Local@Computed)开发一个动态网络状态信息查询 应用。通过 @kit.NetworkKitconnection 模块获取网络带宽、网络类型、网卡名称、MTU、链路地址、路由信息等核心数据,并以现代化卡片式 UI 呈现。文章涵盖架构设计、核心 API 解析、分步实现、深色模式适配及最佳实践,适合 HarmonyOS 开发者参考学习。


效果

一、项目概述

1.1 功能说明

本案例实现一个网络状态信息查询工具,用户点击"查询网络状态"按钮后,应用实时获取并展示以下信息:

序号 信息项 说明
1 上/下行带宽 当前网络的上行/下行带宽(kb/s),0 表示无法评估
2 网络类型 蜂窝网络、Wi-Fi、以太网、VPN 等
3 网卡名称 网络接口名称(如 wlan0、rmnet0)
4 最大传输单元 MTU 值,单位为字节
5 链路信息 IP 地址列表(IPv4/IPv6)
6 路由网卡名称 路由绑定的网络接口
7 目的地址 路由目标网络地址
8 网关地址 路由网关 IP 地址
9 是否有网关 标识路由是否配置了网关
10 默认路由 标识是否为系统默认路由

当设备未连接网络时,显示"未连接"提示;查询出错时,展示错误信息。

1.2 技术亮点

  • 状态管理 V2 全面应用 :使用 @ComponentV2@ObservedV2@Trace@Local@Computed 等 V2 装饰器
  • 细粒度响应式更新@ObservedV2 + @Trace 确保只有变化的属性触发 UI 刷新
  • @Computed 缓存派生属性 :避免重复计算 hasErroritemCount 等派生值
  • async/await 异步模式:替代传统回调,代码逻辑更清晰
  • 深色模式自动适配base/dark/ 双资源目录,跟随系统主题切换
  • 类型安全的数据模型@ObservedV2 装饰的强类型数据类,替代数组索引取值

1.3 环境要求

工具/环境 版本要求
DevEco Studio 6.1 Release 及以上
HarmonyOS SDK API 23+
运行设备 HarmonyOS NEXT(手机/平板/2in1)

二、架构设计

2.1 项目目录结构

复制代码
entry/src/main/
├── ets/
│   ├── constants/
│   │   └── Constants.ets          # 常量定义 + @ObservedV2 数据模型
│   ├── entryability/
│   │   └── EntryAbility.ets       # 应用入口 Ability
│   ├── pages/
│   │   └── Index.ets              # 主页面(@ComponentV2)
│   └── utils/
│       └── NetworkUtil.ets        # 网络查询工具类
├── resources/
│   ├── base/element/
│   │   ├── color.json             # 浅色模式颜色资源
│   │   ├── float.json             # 尺寸资源
│   │   └── string.json            # 字符串资源
│   ├── dark/element/
│   │   └── color.json             # 深色模式颜色资源
│   └── base/profile/
│       └── main_pages.json        # 页面路由配置
└── module.json5                   # 模块配置(含权限声明)

2.2 分层架构图

#mermaid-svg-hzbBhZOVUuhtSnSZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hzbBhZOVUuhtSnSZ .error-icon{fill:#552222;}#mermaid-svg-hzbBhZOVUuhtSnSZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hzbBhZOVUuhtSnSZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .marker.cross{stroke:#333333;}#mermaid-svg-hzbBhZOVUuhtSnSZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hzbBhZOVUuhtSnSZ p{margin:0;}#mermaid-svg-hzbBhZOVUuhtSnSZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .cluster-label text{fill:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .cluster-label span{color:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .cluster-label span p{background-color:transparent;}#mermaid-svg-hzbBhZOVUuhtSnSZ .label text,#mermaid-svg-hzbBhZOVUuhtSnSZ span{fill:#333;color:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .node rect,#mermaid-svg-hzbBhZOVUuhtSnSZ .node circle,#mermaid-svg-hzbBhZOVUuhtSnSZ .node ellipse,#mermaid-svg-hzbBhZOVUuhtSnSZ .node polygon,#mermaid-svg-hzbBhZOVUuhtSnSZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .rough-node .label text,#mermaid-svg-hzbBhZOVUuhtSnSZ .node .label text,#mermaid-svg-hzbBhZOVUuhtSnSZ .image-shape .label,#mermaid-svg-hzbBhZOVUuhtSnSZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-hzbBhZOVUuhtSnSZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .rough-node .label,#mermaid-svg-hzbBhZOVUuhtSnSZ .node .label,#mermaid-svg-hzbBhZOVUuhtSnSZ .image-shape .label,#mermaid-svg-hzbBhZOVUuhtSnSZ .icon-shape .label{text-align:center;}#mermaid-svg-hzbBhZOVUuhtSnSZ .node.clickable{cursor:pointer;}#mermaid-svg-hzbBhZOVUuhtSnSZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .arrowheadPath{fill:#333333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hzbBhZOVUuhtSnSZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hzbBhZOVUuhtSnSZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hzbBhZOVUuhtSnSZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hzbBhZOVUuhtSnSZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .cluster text{fill:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ .cluster span{color:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hzbBhZOVUuhtSnSZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hzbBhZOVUuhtSnSZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-hzbBhZOVUuhtSnSZ .icon-shape,#mermaid-svg-hzbBhZOVUuhtSnSZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hzbBhZOVUuhtSnSZ .icon-shape p,#mermaid-svg-hzbBhZOVUuhtSnSZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hzbBhZOVUuhtSnSZ .icon-shape .label rect,#mermaid-svg-hzbBhZOVUuhtSnSZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hzbBhZOVUuhtSnSZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hzbBhZOVUuhtSnSZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hzbBhZOVUuhtSnSZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Index.ets (@ComponentV2)
NetworkUtil.ets (工具层)
Constants.ets (模型+常量)
@kit.NetworkKit (connection)
系统网络服务
@Local + AppStorage (安全区域)
EntryAbility.ets (AppStorage)

2.3 数据流图

#mermaid-svg-hfv6mWWLvOErxkPi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hfv6mWWLvOErxkPi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hfv6mWWLvOErxkPi .error-icon{fill:#552222;}#mermaid-svg-hfv6mWWLvOErxkPi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hfv6mWWLvOErxkPi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hfv6mWWLvOErxkPi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hfv6mWWLvOErxkPi .marker.cross{stroke:#333333;}#mermaid-svg-hfv6mWWLvOErxkPi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hfv6mWWLvOErxkPi p{margin:0;}#mermaid-svg-hfv6mWWLvOErxkPi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hfv6mWWLvOErxkPi .cluster-label text{fill:#333;}#mermaid-svg-hfv6mWWLvOErxkPi .cluster-label span{color:#333;}#mermaid-svg-hfv6mWWLvOErxkPi .cluster-label span p{background-color:transparent;}#mermaid-svg-hfv6mWWLvOErxkPi .label text,#mermaid-svg-hfv6mWWLvOErxkPi span{fill:#333;color:#333;}#mermaid-svg-hfv6mWWLvOErxkPi .node rect,#mermaid-svg-hfv6mWWLvOErxkPi .node circle,#mermaid-svg-hfv6mWWLvOErxkPi .node ellipse,#mermaid-svg-hfv6mWWLvOErxkPi .node polygon,#mermaid-svg-hfv6mWWLvOErxkPi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hfv6mWWLvOErxkPi .rough-node .label text,#mermaid-svg-hfv6mWWLvOErxkPi .node .label text,#mermaid-svg-hfv6mWWLvOErxkPi .image-shape .label,#mermaid-svg-hfv6mWWLvOErxkPi .icon-shape .label{text-anchor:middle;}#mermaid-svg-hfv6mWWLvOErxkPi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hfv6mWWLvOErxkPi .rough-node .label,#mermaid-svg-hfv6mWWLvOErxkPi .node .label,#mermaid-svg-hfv6mWWLvOErxkPi .image-shape .label,#mermaid-svg-hfv6mWWLvOErxkPi .icon-shape .label{text-align:center;}#mermaid-svg-hfv6mWWLvOErxkPi .node.clickable{cursor:pointer;}#mermaid-svg-hfv6mWWLvOErxkPi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hfv6mWWLvOErxkPi .arrowheadPath{fill:#333333;}#mermaid-svg-hfv6mWWLvOErxkPi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hfv6mWWLvOErxkPi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hfv6mWWLvOErxkPi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hfv6mWWLvOErxkPi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hfv6mWWLvOErxkPi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hfv6mWWLvOErxkPi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hfv6mWWLvOErxkPi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hfv6mWWLvOErxkPi .cluster text{fill:#333;}#mermaid-svg-hfv6mWWLvOErxkPi .cluster span{color:#333;}#mermaid-svg-hfv6mWWLvOErxkPi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hfv6mWWLvOErxkPi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hfv6mWWLvOErxkPi rect.text{fill:none;stroke-width:0;}#mermaid-svg-hfv6mWWLvOErxkPi .icon-shape,#mermaid-svg-hfv6mWWLvOErxkPi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hfv6mWWLvOErxkPi .icon-shape p,#mermaid-svg-hfv6mWWLvOErxkPi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hfv6mWWLvOErxkPi .icon-shape .label rect,#mermaid-svg-hfv6mWWLvOErxkPi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hfv6mWWLvOErxkPi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hfv6mWWLvOErxkPi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hfv6mWWLvOErxkPi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户点击查询
Index: 设置 isLoading=true
NetworkUtil.queryNetworkStatus
connection.getDefaultNet
connection.getNetCapabilities
connection.getConnectionPropertiesSync
封装 NetworkStatusItem 数组
更新 NetworkStatusModel
@Trace 触发 UI 刷新

2.4 状态管理 V2 设计要点

本案例使用以下 V2 装饰器构建响应式数据流:

装饰器 用途 对应 V1
@ComponentV2 声明 V2 组件 @Component
@ObservedV2 装饰可深度观察的数据类 @Observed
@Trace 标记需要追踪的属性 自动追踪
@Local 组件内部响应式状态 @State
@Param 父到子单向数据流 @Prop
@Computed 缓存派生属性 计算属性
@Local + AppStorage.get() 生命周期中读取 AppStorage @StorageLink

三、核心 API 解析

3.1 @kit.NetworkKit 网络连接管理

connection 模块提供以下核心 API:

typescript 复制代码
import { connection } from '@kit.NetworkKit';
API 说明 返回值
getDefaultNet() 获取系统默认激活的网络句柄 Promise<NetHandle>
getNetCapabilities(netHandle) 获取网络能力信息(带宽、承载类型) Promise<NetCapabilities>
getConnectionPropertiesSync(netHandle) 同步获取连接属性(网卡、MTU、路由) ConnectionProperties
hasDefaultNet() 检查是否有可用网络 Promise<boolean>

3.2 关键数据类型

NetHandle --- 网络句柄:

typescript 复制代码
interface NetHandle {
  netId: number;  // 网络 ID,0 表示无网络,有效值 >= 100
}

NetCapabilities --- 网络能力集:

typescript 复制代码
interface NetCapabilities {
  linkUpBandwidthKbps: number;     // 上行带宽 (kb/s)
  linkDownBandwidthKbps: number;   // 下行带宽 (kb/s)
  bearerTypes: NetBearType[];      // 网络承载类型
  networkCap: NetCap[];            // 网络具体能力
}

ConnectionProperties --- 连接属性:

typescript 复制代码
interface ConnectionProperties {
  interfaceName: string;           // 网卡名称
  mtu: number;                     // 最大传输单元
  linkAddresses: LinkAddress[];    // 链路地址列表
  routes: RouteInfo[];             // 路由信息列表
  dnses: NetAddress[];             // DNS 服务器列表
}

NetBearType --- 网络承载类型枚举:

枚举值 数值 说明
BEARER_CELLULAR 0 蜂窝网络
BEARER_WIFI 1 Wi-Fi 网络
BEARER_ETHERNET 3 以太网
BEARER_VPN 4 VPN 网络

四、分步实现

Task 1:配置模块权限

module.json5 中声明网络相关权限:

json5 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.GET_NETWORK_INFO"
      },
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

说明GET_NETWORK_INFO 用于获取网络信息(查询类 API 必需),INTERNET 用于网络访问能力。这两个权限均为 system_grant 类型,无需用户授权弹窗。

Task 2:定义数据模型与常量

创建 constants/Constants.ets,使用 @ObservedV2 + @Trace 定义响应式数据模型:

typescript 复制代码
import { connection } from '@kit.NetworkKit';

/**
 * 网络状态项 - 展示单条网络信息
 * @ObservedV2 使类实例可被深度观察
 * @Trace 标记需要追踪变化的属性
 */
@ObservedV2
export class NetworkStatusItem {
  @Trace title: string = '';   // 信息标题(如"网络类型")
  @Trace value: string = '';   // 信息值(如"Wi-Fi")

  constructor(title: string, value: string) {
    this.title = title;
    this.value = value;
  }
}

/**
 * 网络状态聚合模型
 * @Computed 缓存派生属性,仅在依赖变化时重新计算
 */
@ObservedV2
export class NetworkStatusModel {
  @Trace items: NetworkStatusItem[] = [];
  @Trace isConnected: boolean = false;
  @Trace isLoading: boolean = false;
  @Trace errorMessage: string = '';

  @Computed
  get hasError(): boolean {
    return this.errorMessage.length > 0;
  }

  @Computed
  get itemCount(): number {
    return this.items.length;
  }
}

设计要点

  1. @ObservedV2 + @Trace 替代 V1 的 @Observed,提供属性级别的细粒度追踪。当 titlevalue 单独变化时,只有引用该属性的 UI 组件会刷新。
  2. @Computed 自动缓存 hasErroritemCount 的计算结果。只有 errorMessageitems 变化时才重新计算,避免冗余运算。
  3. 强类型数据类 替代数组索引取值(V1 方案中用 statusOutput[0]~statusOutput[9]),消除越界风险和语义不清问题。

常量定义部分:

typescript 复制代码
export class Constants {
  // 网络状态标签
  static readonly LABEL_BANDWIDTH: string = '上/下行带宽';
  static readonly LABEL_NET_TYPE: string = '网络类型';
  static readonly LABEL_INTERFACE_NAME: string = '网卡名称';
  static readonly LABEL_MTU: string = '最大传输单元';
  static readonly LABEL_LINK_INFO: string = '链路信息';
  // ... 更多标签

  // 网络类型中文映射
  static readonly CELLULAR: string = '蜂窝网络';
  static readonly WIFI: string = 'Wi-Fi';
  static readonly ETHERNET: string = '以太网';
  static readonly VPN: string = 'VPN';

  // 工具方法:将枚举值转为可读文本
  static getNetBearTypeName(type: connection.NetBearType): string {
    switch (type) {
      case connection.NetBearType.BEARER_CELLULAR:
        return Constants.CELLULAR;
      case connection.NetBearType.BEARER_WIFI:
        return Constants.WIFI;
      case connection.NetBearType.BEARER_ETHERNET:
        return Constants.ETHERNET;
      case connection.NetBearType.BEARER_VPN:
        return Constants.VPN;
      default:
        return Constants.UNKNOWN;
    }
  }
}

Task 3:实现网络查询工具类

创建 utils/NetworkUtil.ets,封装所有网络查询逻辑:

typescript 复制代码
import { connection } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { Constants, NetworkStatusItem } from '../constants/Constants';

export class NetworkUtil {
  /**
   * 查询当前网络状态信息
   * 使用 async/await 模式替代传统回调,逻辑更线性
   */
  static async queryNetworkStatus(): Promise<NetworkStatusItem[]> {
    // Step 1:获取默认网络句柄
    const netHandle: connection.NetHandle = await connection.getDefaultNet();

    // Step 2:检查是否有网络连接(netId 为 0 表示无网络)
    if (netHandle.netId === Constants.DEFAULT_NET_ID) {
      return [];
    }

    // Step 3:获取网络能力信息
    const capabilities: connection.NetCapabilities =
      await connection.getNetCapabilities(netHandle);

    const items: NetworkStatusItem[] = [];

    // 带宽信息
    const bandwidth = `${capabilities.linkUpBandwidthKbps}/` +
      `${capabilities.linkDownBandwidthKbps} ${Constants.BANDWIDTH_UNIT}`;
    items.push(new NetworkStatusItem(Constants.LABEL_BANDWIDTH, bandwidth));

    // 网络类型
    items.push(new NetworkStatusItem(Constants.LABEL_NET_TYPE,
      Constants.getNetBearTypeName(capabilities.bearerTypes[0])));

    // Step 4:同步获取连接属性
    const properties = connection.getConnectionPropertiesSync(netHandle);

    items.push(new NetworkStatusItem(Constants.LABEL_INTERFACE_NAME,
      properties.interfaceName));
    items.push(new NetworkStatusItem(Constants.LABEL_MTU, `${properties.mtu}`));

    // 链路地址
    const linkInfo = NetworkUtil.formatLinkAddresses(properties.linkAddresses);
    items.push(new NetworkStatusItem(Constants.LABEL_LINK_INFO, linkInfo));

    // 路由信息
    NetworkUtil.addRouteInfo(items, properties.routes);

    return items;
  }
}

关键技术点

  1. async/await 替代回调 :原始方案使用 connection.getDefaultNet().then(...) + getNetCapabilities(netHandle, callback) 的嵌套回调模式,容易出现"回调地狱"。改用 async/await 后,代码逻辑线性可读。
  2. getConnectionPropertiesSync 同步调用 :由于 getDefaultNetgetNetCapabilities 已经是异步的,在此之后再调用同步的 getConnectionPropertiesSync 可以避免额外的异步层级。
  3. 无模块级状态 :原始方案使用模块级 let statusOutput: string[] 变量,存在并发安全问题。新方案每次查询返回独立的数组实例。
  4. 无 eventHub 依赖 :原始方案通过 context.eventHub.emit/on 传递数据,需要 Context 参数。新方案直接通过 Promise 返回值传递,更简洁。

Task 4:构建主页面 UI

创建 pages/Index.ets,使用 @ComponentV2@Local 管理页面状态:

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  // 页面状态 - 使用 @Local 声明响应式变量
  @Local networkStatus: NetworkStatusModel = new NetworkStatusModel();
  @Local hasQueried: boolean = false;

  // 安全区域高度(由 EntryAbility 写入 AppStorage,在 aboutToAppear 中读取)
  @Local topRectHeight: number = 0;
  @Local bottomRectHeight: number = 0;

  aboutToAppear(): void {
    const topHeight = AppStorage.get<number>('topRectHeight');
    const bottomHeight = AppStorage.get<number>('bottomRectHeight');
    if (topHeight !== undefined) {
      this.topRectHeight = topHeight;
    }
    if (bottomHeight !== undefined) {
      this.bottomRectHeight = bottomHeight;
    }
  }

  // 查询按钮点击事件
  private onQueryClick(): void {
    this.hasQueried = true;
    this.networkStatus.isLoading = true;
    this.networkStatus.errorMessage = '';
    this.networkStatus.items = [];

    NetworkUtil.queryNetworkStatus()
      .then((items: NetworkStatusItem[]) => {
        this.networkStatus.items = items;
        this.networkStatus.isConnected = items.length > 0;
        this.networkStatus.isLoading = false;
      })
      .catch((error: BusinessError) => {
        this.networkStatus.errorMessage = error.message ?? 'Unknown error';
        this.networkStatus.isLoading = false;
      });
  }

  build() {
    Column() {
      // 标题栏
      Text($r('app.string.page_title'))
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 内容区域 - 条件渲染五种状态
      if (this.networkStatus.isLoading) {
        // 加载中
        LoadingProgress().width(48).height(48)
      } else if (!this.hasQueried) {
        // 未查询 - 空状态提示
        SymbolGlyph($r('sys.symbol.wifi_exclamationmark'))
          .fontSize(64)
      } else if (this.networkStatus.hasError) {
        // 查询出错
        Text(this.networkStatus.errorMessage)
      } else if (this.networkStatus.isConnected) {
        // 已连接 - 展示信息列表
        ForEach(this.networkStatus.items, (item: NetworkStatusItem) => {
          StatusCard({ item: item })
        })
      } else {
        // 未连接网络
        SymbolGlyph($r('sys.symbol.wifi_slash'))
      }

      // 查询按钮
      Button($r('app.string.btn_query'))
        .onClick(() => { this.onQueryClick(); })
    }
  }
}

状态管理 V2 在页面中的应用

装饰器 变量 作用
@Local networkStatus 管理网络状态模型,属性变化自动刷新 UI
@Local hasQueried 控制是否已执行过查询
@Local topRectHeight aboutToAppear 中从 AppStorage 读取状态栏高度
@Local bottomRectHeight aboutToAppear 中从 AppStorage 读取导航条高度

Task 5:构建可复用的信息卡片子组件

使用 @ComponentV2 + @Param 创建子组件,实现父到子的单向数据流:

typescript 复制代码
@ComponentV2
struct StatusCard {
  @Param item: NetworkStatusItem = new NetworkStatusItem('', '');
  @Param index: number = 0;

  build() {
    Column() {
      Row() {
        // 图标区域
        SymbolGlyph(this.getIconSymbol())
          .fontSize(18)

        // 标题
        Text(this.item.title)
          .fontSize(14)
          .fontColor($r('app.color.text_secondary'))

        Blank()

        // 值
        Text(this.item.value)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.text_primary'))
      }
    }
    .padding(16)
    .backgroundColor($r('app.color.card_item_bg'))
    .borderRadius(12)
    .margin({ bottom: 8 })
  }
}

V2 父子组件数据流
#mermaid-svg-l5Q2g6TEiyy3CqtW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l5Q2g6TEiyy3CqtW .error-icon{fill:#552222;}#mermaid-svg-l5Q2g6TEiyy3CqtW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l5Q2g6TEiyy3CqtW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .marker.cross{stroke:#333333;}#mermaid-svg-l5Q2g6TEiyy3CqtW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l5Q2g6TEiyy3CqtW p{margin:0;}#mermaid-svg-l5Q2g6TEiyy3CqtW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .cluster-label text{fill:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .cluster-label span{color:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .cluster-label span p{background-color:transparent;}#mermaid-svg-l5Q2g6TEiyy3CqtW .label text,#mermaid-svg-l5Q2g6TEiyy3CqtW span{fill:#333;color:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .node rect,#mermaid-svg-l5Q2g6TEiyy3CqtW .node circle,#mermaid-svg-l5Q2g6TEiyy3CqtW .node ellipse,#mermaid-svg-l5Q2g6TEiyy3CqtW .node polygon,#mermaid-svg-l5Q2g6TEiyy3CqtW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .rough-node .label text,#mermaid-svg-l5Q2g6TEiyy3CqtW .node .label text,#mermaid-svg-l5Q2g6TEiyy3CqtW .image-shape .label,#mermaid-svg-l5Q2g6TEiyy3CqtW .icon-shape .label{text-anchor:middle;}#mermaid-svg-l5Q2g6TEiyy3CqtW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .rough-node .label,#mermaid-svg-l5Q2g6TEiyy3CqtW .node .label,#mermaid-svg-l5Q2g6TEiyy3CqtW .image-shape .label,#mermaid-svg-l5Q2g6TEiyy3CqtW .icon-shape .label{text-align:center;}#mermaid-svg-l5Q2g6TEiyy3CqtW .node.clickable{cursor:pointer;}#mermaid-svg-l5Q2g6TEiyy3CqtW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .arrowheadPath{fill:#333333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l5Q2g6TEiyy3CqtW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l5Q2g6TEiyy3CqtW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l5Q2g6TEiyy3CqtW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l5Q2g6TEiyy3CqtW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .cluster text{fill:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW .cluster span{color:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-l5Q2g6TEiyy3CqtW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l5Q2g6TEiyy3CqtW rect.text{fill:none;stroke-width:0;}#mermaid-svg-l5Q2g6TEiyy3CqtW .icon-shape,#mermaid-svg-l5Q2g6TEiyy3CqtW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l5Q2g6TEiyy3CqtW .icon-shape p,#mermaid-svg-l5Q2g6TEiyy3CqtW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l5Q2g6TEiyy3CqtW .icon-shape .label rect,#mermaid-svg-l5Q2g6TEiyy3CqtW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l5Q2g6TEiyy3CqtW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l5Q2g6TEiyy3CqtW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l5Q2g6TEiyy3CqtW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} @Param 单向传递
@Trace 属性变化
@Trace 属性变化
Index (@Local networkStatus)
StatusCard (@Param item)
Text(item.title) 刷新
Text(item.value) 刷新

networkStatus.items 被重新赋值时,ForEach 会为新创建的 NetworkStatusItem 实例生成新的 StatusCard,每个卡片通过 @Param 接收数据。当单个 item 的 titlevalue 变化时,只有引用该属性的 Text 组件会刷新。

Task 6:配置 EntryAbility 与窗口管理

typescript 复制代码
export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.getMainWindow().then((windowClass: window.Window) => {
      // 设置全屏布局
      windowClass.setWindowLayoutFullScreen(true);

      // 获取安全区域高度并存入 AppStorage
      const navArea = windowClass.getWindowAvoidArea(
        window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
      AppStorage.setOrCreate('bottomRectHeight', navArea.bottomRect.height);

      const sysArea = windowClass.getWindowAvoidArea(
        window.AvoidAreaType.TYPE_SYSTEM);
      AppStorage.setOrCreate('topRectHeight', sysArea.topRect.height);
    });

    windowStage.loadContent('pages/Index');
  }
}

AppStorage 数据传递链

复制代码
EntryAbility.onWindowStageCreate
  → getWindowAvoidArea → 获取安全区域高度
  → AppStorage.setOrCreate('topRectHeight', ...)
  → AppStorage.setOrCreate('bottomRectHeight', ...)

Index (主页面)
  → aboutToAppear() 中 AppStorage.get('topRectHeight') 读取状态栏高度
  → aboutToAppear() 中 AppStorage.get('bottomRectHeight') 读取导航条高度
  → 应用到 Column 的 padding 实现安全区域避让

Task 7:资源文件配置

base/element/string.json(浅色模式字符串):

json 复制代码
{
  "string": [
    { "name": "page_title", "value": "网络状态查询" },
    { "name": "btn_query", "value": "查询网络状态" },
    { "name": "empty_title", "value": "尚未查询" },
    { "name": "empty_hint", "value": "点击下方按钮查询当前网络状态信息" },
    { "name": "no_network_hint", "value": "当前设备未连接任何网络\n请检查网络设置后重试" },
    { "name": "query_error", "value": "查询失败" },
    { "name": "loading", "value": "正在查询网络信息..." }
  ]
}

base/element/color.json(浅色模式颜色):

json 复制代码
{
  "color": [
    { "name": "page_bg", "value": "#F5F5F5" },
    { "name": "card_item_bg", "value": "#FFFFFF" },
    { "name": "text_primary", "value": "#1A1A1A" },
    { "name": "text_secondary", "value": "#666666" },
    { "name": "text_hint", "value": "#999999" },
    { "name": "icon_bg", "value": "#E8F5E9" },
    { "name": "icon_tint", "value": "#4CAF50" },
    { "name": "status_connected", "value": "#4CAF50" },
    { "name": "status_disconnected", "value": "#F44336" },
    { "name": "btn_bg", "value": "#4CAF50" },
    { "name": "btn_text", "value": "#FFFFFF" }
  ]
}

dark/element/color.json(深色模式颜色):

json 复制代码
{
  "color": [
    { "name": "page_bg", "value": "#1A1A1A" },
    { "name": "card_item_bg", "value": "#2C2C2C" },
    { "name": "text_primary", "value": "#E0E0E0" },
    { "name": "text_secondary", "value": "#999999" },
    { "name": "text_hint", "value": "#666666" },
    { "name": "icon_bg", "value": "#1B3B1E" },
    { "name": "icon_tint", "value": "#66BB6A" },
    { "name": "btn_bg", "value": "#388E3C" },
    { "name": "btn_text", "value": "#FFFFFF" }
  ]
}

深色模式适配原理 :HarmonyOS 通过 resources/base/resources/dark/ 双资源目录实现主题自动切换。当系统切换为深色模式时,框架自动加载 dark/ 目录下的颜色资源,无需额外代码。


五、V1 到 V2 迁移对照

本案例的原始实现使用状态管理 V1,以下是关键迁移点:

V1 写法 V2 写法 改进说明
@Component struct MainPage @ComponentV2 struct Index 使用 V2 组件装饰器
@State statusOutput: string[] @Local networkStatus: NetworkStatusModel 强类型模型替代字符串数组
@StorageLink('topRectHeight') @Local + aboutToAppearAppStorage.get() V2 不支持 @StorageProp,改用生命周期读取
eventHub.on/emit 传递数据 Promise 返回值直接赋值 消除事件总线依赖
模块级 let statusOutput 函数内 const items 消除全局状态,线程安全
ForEach 通过数组索引映射 ForEach 遍历强类型对象数组 消除越界风险
@Observed class @ObservedV2 class + @Trace 属性级细粒度追踪
无计算属性 @Computed get hasError() 自动缓存派生值

原始 V1 代码的问题分析

  1. 模块级状态污染let statusOutput: string[] = [] 是模块级变量,多次查询时旧数据会残留(使用了 push 而非重新赋值),导致数据累积。
  2. 数组索引脆弱性statusOutput[0]~statusOutput[9] 的索引映射完全依赖固定顺序,如果路由信息有多条,索引就会错位。
  3. eventHub 耦合 :通过 context.eventHub 传递数据需要 Context 参数穿透,增加了组件间的耦合度。
  4. 回调嵌套getDefaultNet().then() 内嵌套 getNetCapabilities(handle, callback),形成回调地狱。

六、运行效果与测试

6.1 页面状态切换

应用包含五种页面状态:

状态 触发条件 显示内容
未查询 首次进入页面 空状态图标 + 提示文字
加载中 点击查询按钮后 LoadingProgress 动画
已连接 查询成功且有网络 网络信息卡片列表
未连接 查询成功但无网络 断网图标 + 提示
错误 查询异常 错误图标 + 错误信息

6.2 测试步骤

  1. 编译运行:在 DevEco Studio 中选择真机或模拟器,点击 Run
  2. 初始状态:页面显示空状态图标和"点击下方按钮查询"提示
  3. Wi-Fi 连接测试 :确保设备已连接 Wi-Fi,点击"查询网络状态"
    • 预期:显示网络类型"Wi-Fi"、带宽信息、IP 地址等
  4. 蜂窝网络测试 :关闭 Wi-Fi,使用蜂窝数据
    • 预期:网络类型显示"蜂窝网络"
  5. 无网络测试 :开启飞行模式
    • 预期:显示"未连接"提示

6.3 调试技巧

使用 HiLog 查看查询日志:

bash 复制代码
# 在 DevEco Studio 的 Log 窗口中过滤
NetworkAwareness

关键日志输出:

  • Query success, found N items --- 查询成功
  • No network connected, netId is 0 --- 无网络
  • Query failed: ... --- 查询失败

七、最佳实践总结

7.1 状态管理 V2 实践建议

  1. 优先使用 @ComponentV2:新代码统一使用 V2 装饰器体系,获得更好的类型安全和细粒度更新能力。
  2. @ObservedV2 + @Trace 建模 :将业务数据封装为 @ObservedV2 类,用 @Trace 标记需要在 UI 中响应的属性。
  3. @Computed 表达派生值 :用 @Computed 替代手动计算的中间变量,框架自动处理缓存和失效。
  4. @Local 管理页面状态 :替代 @State,语义更明确,且与 V2 组件生态一致。
  5. @Param 替代 @Prop :父到子数据传递使用 @Param,需要回传时配合 @Event

7.2 网络编程实践建议

  1. 权限前置声明GET_NETWORK_INFOINTERNET 必须在 module.json5 中声明,否则 API 调用会抛异常。
  2. netId 零值检查getDefaultNet() 返回的 netHandle.netId === 0 表示无网络,需要特殊处理。
  3. 空值保护linkAddressesroutes 可能为空数组,使用 for...of 遍历天然安全。
  4. 异步模式选择:优先使用 Promise/async-await 模式,避免回调嵌套。

7.3 UI 开发实践建议

  1. @Builder 拆分 UI 片段 :将不同状态的视图提取为 @Builder 方法,保持 build() 方法清晰。
  2. 条件渲染五种状态 :使用 if/else if 链处理加载中、空状态、有数据、无网络、错误等状态。
  3. 安全区域适配 :通过 AppStorage + @Local + aboutToAppear 传递状态栏和导航条高度,避免内容被系统 UI 遮挡。注意 @StorageProp 不兼容 @ComponentV2,需在生命周期中手动读取。
  4. 深色模式双目录base/dark/ 资源目录实现零代码主题切换。

八、常见问题 FAQ

Q1:调用 getDefaultNet() 报错 "permission denied"?

A :请检查 module.json5 中是否声明了 ohos.permission.GET_NETWORK_INFO 权限。该权限为 system_grant 类型,声明后自动授权,无需弹窗。

Q2:查询结果中带宽显示为 "0/0 kb/s"?

A:带宽值 0 表示系统无法评估当前网络带宽,这是正常现象。部分网络环境(如某些 VPN)不提供带宽评估能力。

Q3:getConnectionPropertiesSync 返回的路由信息为空?

Aroutes 数组可能为空,取决于当前网络配置。代码中使用 for...of 遍历空数组是安全的,不会抛出异常。

Q4:@ComponentV2 可以使用 @Reusable 吗?

A :不可以。@Reusable 仅支持 @Component(V1 组件)。在 V2 组件中,使用 @ObservedV2 + @Trace 实现细粒度响应式更新来替代组件复用的性能优化。

Q5:如何在 V2 组件中获取 Context?

A :在生命周期回调(如 aboutToAppear)中使用 getContext(this) 获取。本案例通过消除 eventHub 依赖,避免了在工具类中传递 Context 的需要。


九、总结

本案例展示了如何使用 HarmonyOS NEXT 的状态管理 V2 体系构建一个网络状态信息查询工具。核心技术要点:

  1. @ObservedV2 + @Trace 提供属性级细粒度响应式更新
  2. @Computed 自动缓存派生属性,减少冗余计算
  3. async/await 替代回调嵌套,代码更线性可读
  4. 强类型数据类替代数组索引取值,消除越界风险
  5. base/ + dark/ 双资源目录实现深色模式零代码适配

相比 V1 版本,V2 方案在类型安全代码可维护性UI 刷新性能三个维度均有显著提升,推荐在新项目中优先采用。


参考文档

本案例展示了如何使用 HarmonyOS NEXT 的状态管理 V2 体系构建一个网络状态信息查询工具。核心技术要点:

  1. @ObservedV2 + @Trace 提供属性级细粒度响应式更新
  2. @Computed 自动缓存派生属性,减少冗余计算
  3. async/await 替代回调嵌套,代码更线性可读
  4. 强类型数据类替代数组索引取值,消除越界风险
  5. base/ + dark/ 双资源目录实现深色模式零代码适配

相比 V1 版本,V2 方案在类型安全代码可维护性UI 刷新性能三个维度均有显著提升,推荐在新项目中优先采用。


参考文档