HarmonyOS APP开发---「军录」军事装备App,需要用到网络请求和数据缓存

0 阅读5分钟

如果你想做一个军事装备App,需要用到网络请求和数据缓存

如果你身边有华为手机或者鸿蒙设备,欢迎去鸿蒙应用市场搜索「军录」,下载体验一下这个军事装备数据库App。从二战到现代,数千件装备信息尽在掌握。


写在前面

大家好,我是一名鸿蒙开发者,也是个军事爱好者。平时刷到各种军事装备的文章,总觉得信息零散、不好整理。想查一款坦克的参数,要翻好几个网页;想对比两款战机的性能,没有一个统一的工具。所以我做了「军录」这个App,核心功能是:从网络获取装备数据,在本地缓存方便离线查看,支持分类浏览和收藏管理。

这篇文章的重点是两个API:@ohos.net.http(网络请求)和 @kit.ArkData 里的 preferences(本地轻量缓存)。网络请求负责从服务器拉数据,preferences 负责把数据缓存到本地,这样下次打开App时即使没网也能看。

这篇文章聊什么

  • 第一步:理解鸿蒙的网络请求 API 和本地缓存策略
  • 第二步:React 版本用 fetch + localStorage 模拟
  • 第三步:ArkTS 实现 HTTP GET 请求获取装备数据
  • 第四步:ArkTS 实现 preferences 本地缓存
  • 第五步:ArkTS 实现离线模式和网络错误处理
  • 流程图
  • React vs ArkTS 对比表
  • 权限声明

第一步:网络请求和缓存策略

鸿蒙的网络请求在 @kit.NetworkKit 里,模块名是 http

import { http } from '@kit.NetworkKit';

基本流程是:创建 httpRequest 实例 --> 调用 request 发起请求 --> 处理回调中的响应数据 --> 调用 destroy 销毁实例。

本地缓存用 preferences(偏好设置),它是鸿蒙里最简单的数据存储方式,适合存键值对数据。导入方式:

import { preferences } from '@kit.ArkData';

我们的缓存策略是「先缓存后请求」:

  1. 打开App时先从 preferences 加载缓存数据,立刻显示
  2. 同时发起网络请求拉最新数据
  3. 网络请求成功后更新 UI 并把新数据写入缓存
  4. 网络请求失败时显示缓存数据,提示用户当前是离线模式

这种策略的好处是用户打开App就能看到内容,不用盯着加载框等。


第二步:React 版本的前端实现

// JunLuApp.jsx - React版本
import React, { useState, useEffect } from 'react';

const CATEGORIES = ['坦克', '战斗机', '舰船', '火炮', '导弹', '装甲车'];

// 模拟装备数据(实际项目会从API获取)
const MOCK_DATA = {
  tanks: [
    { id: 1, name: 'T-72', country: '苏联', year: 1973, weight: '41吨', mainGun: '125mm滑膛炮', fav: false },
    { id: 2, name: 'M1 Abrams', country: '美国', year: 1980, weight: '62吨', mainGun: '120mm滑膛炮', fav: false },
    { id: 3, name: '99式', country: '中国', year: 2001, weight: '55吨', mainGun: '125mm滑膛炮', fav: false },
  ],
  aircraft: [
    { id: 4, name: 'F-22', country: '美国', year: 2005, type: '隐形战斗机', fav: false },
    { id: 5, name: 'Su-57', country: '俄罗斯', year: 2020, type: '隐形战斗机', fav: false },
  ],
};

function JunLuApp() {
  const [category, setCategory] = useState('坦克');
  const [equipment, setEquipment] = useState([]);
  const [favorites, setFavorites] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isOffline, setIsOffline] = useState(false);

  // 模拟从缓存加载数据
  useEffect(() => {
    const cached = localStorage.getItem('junlu_cache');
    if (cached) {
      setEquipment(JSON.parse(cached));
    }
  }, []);

  // 模拟网络请求
  const fetchData = async (cat) => {
    setIsLoading(true);
    try {
      // 模拟 fetch 请求
      // const response = await fetch(`https://api.example.com/equipment?category=${cat}`);
      // const data = await response.json();

      // 模拟数据
      const dataMap = { '坦克': MOCK_DATA.tanks, '战斗机': MOCK_DATA.aircraft };
      const data = dataMap[cat] || [];

      // 模拟网络延迟
      await new Promise(resolve => setTimeout(resolve, 800));

      setEquipment(data);
      localStorage.setItem('junlu_cache', JSON.stringify(data));
      setIsOffline(false);
    } catch (err) {
      setIsOffline(true);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    fetchData(category);
  }, [category]);

  // 切换收藏
  const toggleFavorite = (id) => {
    setEquipment(prev => prev.map(item =>
      item.id === id ? { ...item, fav: !item.fav } : item
    ));
  };

  return (
    <div style={{ padding: 20, fontFamily: 'Arial', maxWidth: 500, margin: '0 auto' }}>
      <h1>军录 - 军事装备数据库</h1>

      {/* 分类标签 */}
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
        {CATEGORIES.map(cat => (
          <button key={cat} onClick={() => setCategory(cat)}
            style={{ padding: '6px 14px', borderRadius: 16, border: 'none',
              backgroundColor: category === cat ? '#1A237E' : '#eee',
              color: category === cat ? '#fff' : '#333', cursor: 'pointer' }}>{cat}</button>
        ))}
      </div>

      {isOffline && <p style={{ color: '#FF5722' }}>当前为离线模式,显示缓存数据</p>}
      {isLoading && <p>加载中...</p>}

      {/* 装备列表 */}
      {equipment.map(item => (
        <div key={item.id} style={{ padding: 12, borderBottom: '1px solid #eee' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <strong>{item.name}</strong>
            <span onClick={() => toggleFavorite(item.id)} style={{ cursor: 'pointer', color: item.fav ? '#FF5722' : '#ccc' }}>
              {item.fav ? '已收藏' : '收藏'}
            </span>
          </div>
          <p>{item.country} | {item.year}年</p>
          {item.mainGun && <p>主炮: {item.mainGun}</p>}
        </div>
      ))}
    </div>
  );
}

export default JunLuApp;

第三步:ArkTS 版本 - 网络请求

// entry/src/main/ets/pages/Index.ets
// 军录 - 军事装备数据库App

import { http } from '@kit.NetworkKit';
import { preferences } from '@kit.ArkData';
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

const CATEGORIES: string[] = ['坦克', '战斗机', '舰船', '火炮', '导弹', '装甲车'];

// 装备数据接口
interface Equipment {
  id: number;
  name: string;
  country: string;
  year: number;
  category: string;
  mainWeapon: string;
  weight: string;
  isFavorite: boolean;
}

@Entry
@Component
struct Index {
  // 分类和列表
  @State currentCategory: string = '坦克';
  @State equipmentList: Equipment[] = [];
  @State isLoading: boolean = false;
  @State isOffline: boolean = false;
  @State networkError: string = '';

  // preferences 实例
  private prefStore: preferences.Preferences | null = null;
  private context: common.UIAbilityContext | null = null;

  aboutToAppear() {
    this.context = this.getContext(this) as common.UIAbilityContext;
    this.initPreferences();
    // 先加载缓存,再请求网络
    this.loadCachedData();
    this.fetchEquipment();
  }

  // ===== 初始化 Preferences =====
  // preferences 类似前端的 localStorage,但是是异步的
  // getPreferences 需要一个 context 和一个名字
  // 名字就是偏好设置文件的标识
  async initPreferences() {
    try {
      this.prefStore = await preferences.getPreferences(this.context!, 'junlu_store');
    } catch (err) {
      console.error('初始化 Preferences 失败:' + JSON.stringify(err));
    }
  }

  // ===== 从 Preferences 加载缓存数据 =====
  async loadCachedData() {
    if (!this.prefStore) return;
    try {
      // preferences.get 读取指定 key 的值
      // 第二个参数是默认值,key 不存在时返回默认值
      const cached = await this.prefStore.get('cache_equipment', '');
      if (cached && cached.length > 0) {
        // 反序列化 JSON 字符串为数组
        const data = JSON.parse(cached as string) as Equipment[];
        this.equipmentList = data;
        this.isOffline = true; // 先标记为离线模式,网络成功后切回在线
      }
    } catch (err) {
      console.error('加载缓存失败:' + JSON.stringify(err));
    }
  }

  // ===== 写入缓存到 Preferences =====
  async saveCachedData(data: Equipment[]) {
    if (!this.prefStore) return;
    try {
      // preferences.put 写入键值对
      // put 只是修改内存中的值,需要配合 flush 才能持久化到文件
      await this.prefStore.put('cache_equipment', JSON.stringify(data));
      // flush 把内存中的数据同步到文件
      // 参数 true 表示阻塞等待写入完成
      await this.prefStore.flush();
    } catch (err) {
      console.error('保存缓存失败:' + JSON.stringify(err));
    }
  }

  // ===== 网络请求获取装备数据 =====
  async fetchEquipment() {
    this.isLoading = true;
    this.networkError = '';

    try {
      // http.createHttp() 创建 HTTP 请求实例
      // 每次请求建议创建新实例,用完销毁
      let httpRequest = http.createHttp();

      // http.createHttp 返回的不是 Promise,而是用回调方式处理
      // request 方法发起 HTTP 请求
      // 参数1: URL
      // 参数2: 请求配置
      //   method: HTTP 方法(GET / POST / PUT / DELETE)
      //   header: 请求头
      //   readTimeout: 读取超时(毫秒)
      //   connectTimeout: 连接超时(毫秒)
      // 参数3: 回调函数 (err, data)
      //   err: 错误对象,null 表示成功
      //   data: 响应对象,包含 responseCode(状态码)和 result(响应体)
      httpRequest.request(
        `https://api.example.com/equipment?category=${encodeURIComponent(this.currentCategory)}`,
        {
          method: http.RequestMethod.GET,
          header: { 'Content-Type': 'application/json' },
          readTimeout: 60000,    // 60秒读取超时
          connectTimeout: 60000, // 60秒连接超时
        },
        (err, data) => {
          // 无论成功失败,都要销毁 httpRequest 实例
          // 不销毁会泄漏连接资源
          httpRequest.destroy();
          httpRequest = null as unknown as http.HttpRequest;

          if (err) {
            // 网络请求失败
            // 常见原因:无网络、DNS解析失败、服务器无响应、超时
            console.error('网络请求失败:' + JSON.stringify(err));
            this.networkError = '网络请求失败,显示缓存数据';
            this.isOffline = true;
            this.isLoading = false;
            return;
          }

          if (data.responseCode === 200) {
            // HTTP 200 表示成功
            // data.result 是响应体的字符串
            try {
              const equipment = JSON.parse(data.result as string) as Equipment[];
              this.equipmentList = equipment;
              this.isOffline = false;
              // 成功获取数据后写入缓存
              this.saveCachedData(equipment);
            } catch (parseErr) {
              console.error('JSON 解析失败:' + JSON.stringify(parseErr));
              this.networkError = '数据格式错误';
            }
          } else {
            // 非 200 状态码
            this.networkError = `请求失败,状态码: ${data.responseCode}`;
          }
          this.isLoading = false;
        }
      );
    } catch (err) {
      console.error('发起请求异常:' + JSON.stringify(err));
      this.isLoading = false;
    }
  }

  // ===== 切换收藏 =====
  async toggleFavorite(item: Equipment) {
    // 更新列表中的收藏状态
    this.equipmentList = this.equipmentList.map((eq) => {
      if (eq.id === item.id) {
        return { ...eq, isFavorite: !eq.isFavorite };
      }
      return eq;
    });
    // 同步更新缓存
    await this.saveCachedData(this.equipmentList);
    this.showToast(item.isFavorite ? '已取消收藏' : '已收藏');
  }

  // ===== 切换分类 =====
  switchCategory(cat: string) {
    this.currentCategory = cat;
    this.equipmentList = [];
    this.fetchEquipment();
  }

  showToast(message: string) {
    this.getUIContext().getPromptAction().openToast({ message, duration: 2000, bottom: 100 });
  }

  build() {
    Column() {
      Text('军录 - 军事装备数据库')
        .fontSize(24).fontWeight(FontWeight.Bold)
        .width('100%').padding({ left: 16, right: 16, top: 16 })

      // 分类标签
      Scroll({ scrollDirection: ScrollDirection.Horizontal }) {
        Row({ space: 8 }) {
          ForEach(CATEGORIES, (cat: string) => {
            Text(cat)
              .fontSize(14)
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .backgroundColor(this.currentCategory === cat ? '#1A237E' : '#EEEEEE')
              .fontColor(this.currentCategory === cat ? '#FFFFFF' : '#333333')
              .borderRadius(16)
              .onClick(() => { this.switchCategory(cat); })
          }, (cat: string) => cat)
        }
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
      }

      // 状态提示
      if (this.isLoading) {
        Row() {
          LoadingProgress().width(24).height(24)
          Text('加载中...').fontSize(14).fontColor('#666666').margin({ left: 8 })
        }
        .width('100%').padding({ left: 16, top: 8 })
      }

      if (this.isOffline) {
        Text('当前为离线模式,显示缓存数据')
          .fontSize(13).fontColor('#FF9800')
          .width('100%').padding({ left: 16, top: 4 })
      }

      if (this.networkError) {
        Text(this.networkError)
          .fontSize(13).fontColor('#F44336')
          .width('100%').padding({ left: 16, top: 4 })
      }

      Scroll() {
        Column({ space: 8 }) {
          // 装备列表
          ForEach(this.equipmentList, (item: Equipment) => {
            Column({ space: 4 }) {
              Row() {
                Column({ space: 2 }) {
                  Text(item.name).fontSize(16).fontWeight(FontWeight.Bold)
                  Text(`${item.country} | ${item.year}年`)
                    .fontSize(13).fontColor('#666666')
                  Text(item.mainWeapon || '')
                    .fontSize(12).fontColor('#999999')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Text(item.isFavorite ? '已收藏' : '收藏')
                  .fontSize(14)
                  .fontColor(item.isFavorite ? '#FF5722' : '#999999')
                  .onClick(() => { this.toggleFavorite(item); })
              }
              .width('100%').padding(12)
            }
            .width('100%')
            .backgroundColor('#FFFFFF')
            .borderRadius(8)
            .shadow({ radius: 4, color: '#00000008', offsetY: 2 })
          }, (item: Equipment) => `${item.id}`)
        }
        .padding({ left: 16, right: 16, bottom: 24 })
      }
      .layoutWeight(1)
    }
    .height('100%').width('100%').backgroundColor('#FAFAFA')
  }
}

关于网络请求和缓存的几个关键点:

http.createHttp() 和 destroy():每次请求创建新实例,用完必须 destroy。不 destroy 会泄漏 TCP 连接,累积多了会导致网络不可用。

回调式 API:鸿蒙的 http 模块用的是回调风格,不是 Promise。在回调里处理响应数据和错误,最后调 destroy。

preferences 的 put + flushput 写入内存,flush 持久化到文件。如果不调 flush,App 被杀掉后数据就丢了。这和 React 里 localStorage.setItem(同步写盘)不太一样。

离线优先策略:打开时先读缓存再请求网络,保证用户始终能看到内容。网络成功后更新缓存,网络失败时保持离线模式标记。


流程图

flowchart TD
    A[用户打开军录] --> B[initPreferences 初始化缓存]
    B --> C[loadCachedData 从 Preferences 读取缓存]
    C --> D{缓存有数据?}
    D -->|是| E[显示缓存数据 + 标记离线模式]
    D -->|否| F[显示空列表]

    E --> G[fetchEquipment 发起网络请求]
    F --> G
    G --> H{请求成功?}
    H -->|是| I[解析 JSON 数据]
    I --> J[更新 UI 列表]
    J --> K[saveCachedData 写入 Preferences]
    K --> L[切换为在线模式]
    H -->|否| M[显示网络错误]
    M --> N[保持离线模式]

    L --> O{用户操作}
    N --> O
    O -->|切换分类| G
    O -->|收藏/取消| P[更新数据 + 写入缓存]

React vs ArkTS 对比表

对比项React (Web)ArkTS (鸿蒙)
HTTP 请求fetch(url) 返回 Promisehttp.createHttp().request(url, opts, callback)
请求方法fetch 第二个参数 { method: 'GET' }http.RequestMethod.GET
超时设置AbortController 或库配置readTimeout / connectTimeout 配置项
实例管理fetch 无需手动管理createHttp() + destroy() 配对
JSON 解析response.json()JSON.parse(data.result)
本地缓存localStorage.setItem(key, value)preferences.put(key, value) + flush()
读取缓存localStorage.getItem(key)preferences.get(key, defaultValue)
离线检测navigator.onLine请求失败时标记 isOffline

权限声明

军录用到了网络请求,需要在 module.json5 中声明 INTERNET 权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

ohos.permission.INTERNETsystem_grant 级别,声明即可,不需要用户确认。但你的 App 如果没有任何网络行为却声明了这个权限,审核可能会被拒。


总结

这篇文章我们用鸿蒙的网络请求和 preferences 缓存实现了「军录」军事装备App:

http.createHttp().request():发起网络请求,注意回调式 API 和 destroy 生命周期。设置合理的超时时间。

preferences:轻量键值对缓存,put 写内存、flush 持久化、get 读取。适合存 JSON 字符串。

离线优先策略:先缓存后请求,保证用户体验。网络成功更新缓存,失败保持离线。

下一篇是「矿泉录」矿泉水评测App,核心是多表关联查询和条形对比图。