如果你想做一个军事装备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';
我们的缓存策略是「先缓存后请求」:
- 打开App时先从 preferences 加载缓存数据,立刻显示
- 同时发起网络请求拉最新数据
- 网络请求成功后更新 UI 并把新数据写入缓存
- 网络请求失败时显示缓存数据,提示用户当前是离线模式
这种策略的好处是用户打开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 + flush:put 写入内存,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) 返回 Promise | http.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.INTERNET 是 system_grant 级别,声明即可,不需要用户确认。但你的 App 如果没有任何网络行为却声明了这个权限,审核可能会被拒。
总结
这篇文章我们用鸿蒙的网络请求和 preferences 缓存实现了「军录」军事装备App:
http.createHttp().request():发起网络请求,注意回调式 API 和 destroy 生命周期。设置合理的超时时间。
preferences:轻量键值对缓存,put 写内存、flush 持久化、get 读取。适合存 JSON 字符串。
离线优先策略:先缓存后请求,保证用户体验。网络成功更新缓存,失败保持离线。
下一篇是「矿泉录」矿泉水评测App,核心是多表关联查询和条形对比图。