鸿蒙天气 App 网络层是怎么搭起来的

编者给这个天气 App 接数据的时候,本以为麻烦的地方在界面,结果界面比预想中顺利得多,真正耗掉大半天的是网络层。所以想把这部分的设计和踩过的坑写下来,因为和风天气这几年的接口改版,让网上不少教程里的写法已经彻底跑不通了。

从一串写错的请求地址说起

最直觉的做法是打开浏览器敲一个地址,回车,看到温度数字,然后把这串地址照搬进代码。编者第一次就是这么做的,写出来的链接大致是这样:

https://devapi.qweather.com/v7/weather/now?location=西安市&key=我的KEY

看上去完全合理,把西安市的名称填进去,理应返回西安的天气。但它返回的并不是温度,而是一个错误结果。

原因在于天气接口根本不认识中文城市名。它的 location 参数只接受两种取值,一种是形如 101110101 的 LocationID,另一种是形如 34.34,108.94 的经纬度坐标。也就是说,想知道某个地方的天气,必须先把这个地方的名称翻译成坐标,而翻译这件事是另一个接口负责的。于是整条链路被拆成了三段:

输入"南京" → 城市搜索接口 → 拿到 32.04155,118.76741 → 实时天气接口 → 拿到温度

如果跳过第一段就直接请求天气,后面所有努力都是白费的。这是网络层需要解决的第一个问题,也是后面所有代码结构安排的起点。

动手之前必须先确认的三件事

在写任何代码之前,有三件事需要先确认清楚。它们不是什么编码技巧,而是接口本身的规定,一旦判断错误,后面的代码会成片返工。

第一件事是认证方式并不是把密钥塞进查询参数。很多平台的接口是加一个 key 参数就完事,和风天气不是这样。它要求两样东西同时正确,一个是 API Host,一个是凭据。API Host 是每个账号专属的域名,形状类似这样:

na3tek4qpc.re.qweatherapi.com

它取代了过去的公共域名,也就是 devapi.qweather.com 和 api.qweather.com 那一批。官方文档里写得很明白,公共地址从 2026 年起逐步停止服务,这就意味着大量老教程里的域名不是偶尔失败,而是注定失败。

凭据本身有两条路可走。JWT 的安全性更高,用 Ed25519 算法签名,但鸿蒙提供的加解密算法库只支持 RSA、ECC 和 SM2 这几种,并不支持 Ed25519,所以在 ArkTS 里根本签不出合法的 JWT。这个限制把客户端项目逼到了另一条路上,也就是 API KEY,使用时把密钥放进请求头,字段名是 X-QW-Api-Key:

X-QW-Api-Key: 你的KEY

这里还有一个容易忽略的细节,两种传密钥的方式不要同时使用,同时用请求头和同时用查询参数会导致认证失败。

第二件事是新版 v1 接口的响应体里没有 code 字段,这是编者这次踩得最深的一个坑。和风天气早期的 v7 接口,每一个响应都带一个 code 字段,值等于字符串 200 的时候表示成功,所以判断成功的写法很自然会写成这样:

const body = JSON.parse(text);
if (body.code !== '200') { throw new Error('接口异常'); }

这段逻辑在 GeoAPI 的城市搜索接口上工作得很好,因为那个接口确实会返回 code。但把它用在 /weather/v1/current 和 /weather/v1/daily 上就完全不行了,它会每一次都抛出异常,原因在于 v1 的响应体里压根没有这个字段。v1 的实时天气响应结构大致是这样:

{
  "metadata": { "tag": "...", "attributions": ["..."] },
  "condition": { "text": "晴", "code": "100" },
  "temperature": { "value": 30.08, "unit": "°C" },
  "feelsLike": { "value": 26.61, "unit": "°C" },
  "humidity": 0.17,
  "wind": { "direction": { "degree": 317, "compass": "nnw" }, "scale": 3 }
}

最外层是 metadata,里面放着数据的唯一标识和归属声明;然后是 condition,包含天气现象的文字描述和代码;接着是 temperature,里面是一个对象,字段是 value 和 unit,单位是摄氏度;再往下是 feelsLike,结构与 temperature 相同;humidity 是一个单独的数字;wind 下面又分了 direction 和 scale,direction 里面是 degree 和 compass。整个响应从头翻到尾,确实没有 code。于是就出现了很尴尬的一幕,取出来的 body.code 是 undefined,而 undefined 不等于字符串 200 这个比较恒为真,程序于是一路唱着接口异常往下走,每一处请求都失败,而写代码的人还在怀疑是不是网络问题或者密钥问题。

编者的处理办法是把判断依据换成 HTTP 状态码。成功与否先看状态码是不是 200,业务错误码只在它确实存在的时候才参与判断。判断逻辑因此被拆成两个函数,一个负责从响应文本里能读就读一个错误码、读不到就返回空串,另一个只对 GeoAPI 的响应做业务码校验。这样既兼容了两种接口风格,也不会再出现那种必然抛异常的情况。

第三件事是字段的单位和直觉不一致,而且不一致的地方文档里并没有直白写出来。编者拿到真实响应之后做了几组对照,发现湿度返回的是 0.17 这样的小数而不是 69 这样的百分数,显示成界面上必须乘以一百。降水概率的位置也和直觉不同,它不在 days 数组的顶层,而是藏在 daytime 下面的 precipitation 里面的 probability,需要逐层判空才能安全取到。更意外的是这个概率的值本身也是小数,返回 0.03 表示百分之三,所以直接拼上百分号会显示成 0.03%,看起来像个错误。风向同样如此,direction 里面只有 compass 这个方位代码,取出来是 nnw 这样的字符串,想显示成北西北风必须自己做一张映射表。这三点如果按直觉处理,界面上会出现湿度 0.17% 和降水概率 0.03% 这种荒唐数字,而程序并不会报任何错,这类问题是最难排查的。

想清楚之后的分层

把上面三件事确认清楚,结构其实就没有太多可纠结的了。整个网络层最终只用了三个文件:

common/QWeatherConfig.ets    ← 全工程唯一需要修改的地方(Host + Key)
model/WeatherModel.ets       ← 接口返回长什么样
common/QWeatherService.ets   ← 发请求 + 调接口 + 把数据翻译成 UI 能用的形状

把配置单独拆成一个文件,目的是让换一个密钥这件事只需要改一行,而不是在整个工程里搜索字符串:

export class QWeatherConfig {
  static readonly API_HOST: string = 'na3tek4qpc.re.qweatherapi.com';
  static readonly API_KEY: string = '你的KEY';
  static readonly TIMEOUT_MS: number = 8000;

  static base(): string {
    return 'https://' + QWeatherConfig.API_HOST;
  }

  /** 两个值都填了才发起网络请求,否则先用本地演示数据,方便先看 UI */
  static ready(): boolean {
    return QWeatherConfig.API_HOST.indexOf('在这里填') < 0
      && QWeatherConfig.API_KEY.indexOf('在这里填') < 0
      && QWeatherConfig.API_HOST.length > 0
      && QWeatherConfig.API_KEY.length > 0;
  }
}

这个 ready 方法看上去有些多余,实际上很有用,它让还没有填密钥和填错了密钥成为两种不同的状态:前者安静地不发起请求,界面继续显示占位数据;后者弹出一个明确的错误提示。如果没有这个区分,使用者会误以为代码没有生效,然后开始到处排查。

描述响应结构的那个文件同样值得一提。它把每个接口返回什么字段、哪些字段可能不存在,全部写成了具名接口,其中温度这一类带单位的数值单独抽出了一个 Metric:

/** 带单位的数值 */
interface Metric {
  value: number;
  unit: string;
}

/** 实时天气响应 */
export interface CurrentNow {
  condition: Condition;
  temperature: Metric;
  feelsLike: Metric;
  /** 0~1 的小数 */
  humidity: number;
  wind: Wind;
}

值得说明的是这里为什么必须用具名接口而不是直接写内联的对象类型。ArkTS 有一条限制,禁止把对象字面量当作类型使用,像 condition 后面直接跟一对花括号写明字段名,或者 wind 里面再嵌套一层 direction 的写法,都会触发编译报错。所以像 Condition、WindDirection、Wind 这些嵌套结构,全都被提成了独立的具名接口,这是这次写代码时被编译器反复教育之后才养成的习惯。

一个请求函数管住所有容易忘的事

三个接口的请求方式完全一样,只有路径和查询参数不同,所以只需要写一个通用的请求函数,把所有容易忘记的细节一次性固定在里面:

async function getJson<T>(path: string, query: string): Promise<T> {
  const req = http.createHttp();
  const url: string = QWeatherConfig.base() + path + (query.length > 0 ? '?' + query : '');
  try {
    const resp: http.HttpResponse = await req.request(url, {
      method: http.RequestMethod.GET,
      header: {
        'X-QW-Api-Key': QWeatherConfig.API_KEY,
        'Accept-Encoding': 'gzip'
      },
      expectDataType: http.HttpDataType.STRING,
      connectTimeout: QWeatherConfig.TIMEOUT_MS,
      readTimeout: QWeatherConfig.TIMEOUT_MS
    });
    const text: string = resp.result as string;
    if (resp.responseCode !== 200) {
      throw new Error(explain(readCode(text), resp.responseCode));
    }
    const body: T = JSON.parse(text) as T;
    if (isGeoError(body)) {                       // 仅 /geo/v2 需要判业务码
      throw new Error(explain((body as GeoResp).code, 200));
    }
    return body;
  } finally {
    req.destroy(); // 必须释放,否则连接会泄漏
  }
}

请求头里有两个字段值得单独说明。第一个是认证字段 X-QW-Api-Key,密钥放在请求头而不是拼接进地址,原因有两层,塞进地址会被日志、历史记录和中间代理记下来,而且和风天气明确要求两种方式不要混用。第二个是 Accept-Encoding,值设为 gzip。天气响应里塞满了 metadata 和 astro 这类大多数场景用不上的字段,其中 astro 包含日出日落和月相数据,一个七日预报的响应能有几千字节,开启压缩之后传输量能明显下降。免费额度虽然是按请求次数计算、省不了次数,但省流量总归没有坏处。

请求选项里还显式指定了返回的数据类型为字符串。这样做的好处是响应结果的类型就确定了,不必再去猜它返回的是字符串、普通对象还是二进制缓冲区,后续做 JSON 解析时也不用做多余的判断。超时时间配置成了八秒,连接超时和读取超时都设了同一个值,对于一个天气请求来说这个时间相当宽裕,超时了基本可以确定是网络本身的问题。

整个函数里最容易被漏掉的一行,是在 finally 里销毁请求对象。漏了这一行不会立刻报错,它会安静地泄漏连接,等到请求次数积累上去才暴露出问题。放进 finally 而不是放在成功分支里,是因为无论请求成功、请求失败还是中途抛出异常,这个请求对象都必须被释放。

细心的读者可能会注意到,错误处理的顺序是先判断 HTTP 状态码,再判断业务错误码。这正是前面第二件事的落地方式。读取错误码的那一步做得非常保守:

/** 只带 code 字段的响应体,用于读错误码 */
interface CodeOnly {
  code?: string;
}

/** 从响应文本里尽量取出和风的业务错误码;取不到返回空串 */
function readCode(text: string): string {
  try {
    const obj: CodeOnly = JSON.parse(text) as CodeOnly;
    const code: string | undefined = obj.code;
    return code === undefined ? '' : code;
  } catch (e) {
    return '';
  }
}

它尝试解析响应文本并取出 code 字段,如果解析失败或者字段不存在就返回空串,然后把空串交给统一的错误解释函数去兜底。判断是不是 GeoAPI 错误的那一步则写成泛型函数:

/** 只有 GeoAPI 的响应带 code 字段,非 200 才算错误 */
function isGeoError<T>(body: T): boolean {
  const code: string = (body as GeoResp).code;
  return code !== undefined && code !== '200';
}

写成泛型是为了绕开 ArkTS 的另一条限制:如果把参数声明成 Object 类型,那么把未约束的泛型 T 传进来会触发编译错误,写成泛型参数本身就没有这个问题。

把错误码翻译成人话这件事本身也很值得做。使用者看到 401 只会一脸茫然,看到认证失败并提示检查 API KEY 与 API Host,就知道该去哪个文件里改什么,所以这个解释函数覆盖了最常见的几种情况:

function explain(code: string, httpStatus: number): string {
  if (code === '401') { return '认证失败:请检查 API KEY 与 API Host'; }
  if (code === '403') { return '无权限:项目未订阅该数据服务'; }
  if (code === '402') { return '本月免费额度已用尽或余额不足'; }
  if (code === '429') { return '请求太频繁,请稍后再试'; }
  // ……其余情况由 HTTP 状态码兜底
  return '网络异常(HTTP ' + httpStatus + ')';
}

这样任何一条失败路径都能给出一句有意义的话,而不是把原始错误码直接丢给使用者。

主流程与两次请求

首页需要显示的信息分成两类,一类是当下,包括温度、体感、湿度和风力;另一类是未来,也就是七日预报。这两类数据来自两个不同的接口,所以一次刷新实际上是两次请求:

export async function loadWeather(name: string, lat: string, lon: string): Promise<CityWeather> {
  const now: CurrentNow = await getJson<CurrentNow>(
    '/weather/v1/current/' + lat + '/' + lon,
    'localTime=true&lang=zh'
  );
  const daily: DailyResp = await getJson<DailyResp>(
    '/weather/v1/daily/' + lat + '/' + lon,
    'days=7&localTime=true&lang=zh'
  );
  // ……把两段数据翻译成界面需要的形状
}

这里编者选择用顺序等待而不是并发请求。对一个小项目来说两种写法都可以,顺序写的可读性更好,出错时日志也更容易对应上下文的先后关系;如果真要改成并发,前提是先确认对方接口的每分钟请求限制能不能承受瞬时并发,否则省下的那点时间可能换来一个限流错误。

请求参数里有一个 localTime 设为 true 的选项值得留意,它让返回的时间是当地时间而不是协调世界时。如果不加这个参数,返回的时间戳在没有时区偏移的情况下,在你的时区里可能会串到前一天,最终表现为星期几整体错位。这类错误看起来像界面问题,实际上根源在网络参数上。

真正花时间的是最后那段翻译工作。界面不应该关心温度是个带单位的对象、湿度是个小数、风向是个方位代码,它只想拿到能直接显示的字符串:

const city: CityWeather = {
  name: name, lat: lat, lon: lon,
  temp: Math.round(now.temperature.value).toString(),   // 30.08 → "30"
  text: now.condition.text,
  feel: '体感 ' + Math.round(now.feelsLike.value) + '°',
  humidity: Math.round(now.humidity * 100) + '%',       // 0.69 → "69%"
  wind: compassToChinese(now.wind.direction.compass) + ' ' + now.wind.scale + '级',
  days: days
};

看这几行赋值就够了:温度取出来之后四舍五入再转成字符串;体感温度同样处理并拼上体感两个字;湿度乘一百取整后拼上百分号;风向先用映射表翻成中文,再拼上风级。前面提到的三个坑,在这一小段里全部被处理掉了。

风向的映射表最初想写成对象字面量,但 ArkTS 不支持按索引访问对象字段,那样会触发类型检查报错,最后改用了 Map 并逐个 set 进去:

const COMPASS: Map<string, string> = new Map<string, string>();
COMPASS.set('n', '北风');
COMPASS.set('nne', '北东北风');
// ……中间省略
COMPASS.set('nnw', '北西北风');

function compassToChinese(code: string): string {
  const hit: string | undefined = COMPASS.get(code);
  return hit === undefined ? '风向不定' : hit;
}

这样做还有一个附带的好处,Map 的 get 方法遇到不存在的键会返回 undefined,所以取值的函数天然带有兜底逻辑,将来和风天气新增一个方位代码,界面会显示风向不定,而不是直接崩溃。降水概率的处理更麻烦一层,因为 daytime 和 precipitation 都可能不存在,所以要逐层判空:

function rainOf(d: DailyItem): string {
  if (d.daytime === undefined || d.daytime.precipitation === undefined) { return '--'; }
  const p: number | undefined = d.daytime.precipitation.probability;
  if (p === undefined) { return '--'; }
  const percent: number = p <= 1 ? Math.round(p * 100) : Math.round(p);
  return percent + '%';
}

最后那一行判断概率值是否小于等于一,小于等于一就乘一百,否则原样取整,是刻意写得不太精确的。实测返回的是 0.03 这样的小数,但万一对方某天改成返回 3 这种写法,这段代码同样能正确显示成百分之三。接口字段的确切含义偶尔会变,用一小段兼容代码换一份心安,在学习项目里是划算的。

界面层只剩下拿来就用

把翻译做干净之后,页面里的网络代码就只剩下很少的几行:

private async refresh(): Promise<void> {
  if (this.refreshing) { return; } // 防连点
  this.refreshing = true;
  const index: number = this.currentIndex;
  const city: CityWeather = this.cities[index];
  try {
    const fresh: CityWeather = await loadWeather(city.name, city.lat, city.lon);
    this.cities[index] = fresh; // 整体替换,保证 UI 一定刷新
    promptAction.showToast({ message: city.name + ' 天气已更新' });
  } catch (e) {
    promptAction.showToast({ message: (e as Error).message, duration: 3000 });
  } finally {
    this.refreshing = false;
  }
}

这个函数里有几个细节是编者特意保留的。一开始先判断是否正在刷新,如果正在刷新就直接返回,这是为了防止使用者手快连点刷新按钮造成连发好几组请求,白白消耗免费额度。接着记录下当前的城市下标,因为异步请求返回的时候当前下标可能已经被切换了。成功之后把返回的新对象整体赋值回城市数组的对应位置,这里选择了整体替换而不是逐个字段赋值,原因是 ArkTS 的状态装饰器只监听整体赋值,不监听嵌套对象的深层属性变化,逐个改字段界面上不会刷新。失败的时候只是弹一个提示,城市数组里的数据保持原样,这样断网时界面还显示着上一次的数据,使用者能感觉到刚才那一次没有刷新成功,而不是觉得 App 坏了。最后无论成功失败,都把刷新状态复位。

添加城市则多了一步把名称换成坐标:

const loc: GeoLocation = await lookupCity(name);
this.cities.push(blankCity(loc.name, loc.lat, loc.lon));
this.currentIndex = this.cities.length - 1;
this.refresh();

先调用城市搜索接口拿到地理位置对象,检查这个城市是不是已经在列表里,然后把坐标和名称一起存成一个占位的城市对象推进数组,切换到最后一项,再触发一次刷新。之所以要把坐标存进城市对象,是因为后续每次刷新都直接使用坐标请求,不必再查一遍城市。城市搜索接口每次请求都要计费,能省则省。三个默认城市干脆把坐标直接写在代码里:

const DEFAULT_CITIES: CityWeather[] = [
  { name: '西安市', lat: '34.34', lon: '108.94', temp: '--', text: '加载中', feel: '', humidity: '', wind: '', days: [] },
  { name: '北京市', lat: '39.90', lon: '116.41', temp: '--', text: '加载中', feel: '', humidity: '', wind: '', days: [] },
  { name: '上海市', lat: '31.23', lon: '121.47', temp: '--', text: '加载中', feel: '', humidity: '', wind: '', days: [] }
];

这样冷启动的时候不需要任何额外的请求,只有使用者手动输入城市名的时候才真正调用搜索接口。

与把所有逻辑塞在一个方法里的写法对比

如果不做这些分层,最常见也最容易写出来的做法是把创建请求、拼接地址、解析响应、赋值给状态全部塞进一个方法里:

// 反面写法
http.createHttp().request('https://.../v7/weather/now?location=' + cityName + '&key=' + KEY)
  .then((resp) => {
    const body = JSON.parse(resp.result as string);
    this.temp = body.now.temp;               // 一旦字段名变了,这里静默变成 undefined
    this.humidity = body.now.humidity + '%'; // 0.17% —— 而且不会报错
  });

这种写法能跑,也有它的好处,代码短、一眼看得完、调试的时候不用在三四个文件之间跳来跳去。但它在三个方面要付出代价。

第一是字段变更之后无从察觉。旧接口里的 now.temp 在新接口里变成了 now.temperature.value,嵌套关系也整体变了。把所有逻辑塞在一起的那种写法不会报错,只会让界面上出现空白或者 undefined 字样,写代码的人得一行一行去猜是哪里断掉的。而分层之后,描述响应结构的接口把期待的形状写死了,字段一改名,映射代码就会明显对不上,错误暴露在编译阶段或者第一次运行的时候,而不是悄悄地发生。

第二是同一个坑要反复踩。如果每个请求都手写一遍超时、压缩、认证头和错误码判断,那么漏掉任何一处都会成为独立隐患,而且将来要改也是每一处都要改。把共性收进一个通用的请求函数之后,改一处就等于全改,新增接口时也只需要管路径和参数。

第三是界面和接口耦合在一起。界面代码里一旦出现取温度要穿透三层字段、湿度要乘一百这样的表达式,就意味着接口一改界面也得跟着改。分层之后界面只认识自己那一个数据结构,接口怎么变都被挡在服务层内部。

当然分层并不是没有代价。文件数量变多了,一个字段可能要追三层才能看到它是从哪里来的,简单的改动也要先想清楚该落在哪一层。对十来个接口的中型项目,这点代价是值得的;但如果只是写个练习取一个温度显示出来,把所有代码塞进一个函数反而更清楚。判断标准其实很简单,看这个接口会不会变、会不会有第二个人来读你的代码。

这套写法还能用在什么地方

这套先翻译再查询、把共性收进一个请求函数、在服务层完成字段翻译的思路,并不只适用于天气。凡是先查标识再查内容的接口组合,结构都大同小异。

地图类服务是典型的例子。想查某家店铺的详细信息,通常得先用名称搜索拿到 POI 标识,再用这个标识查详情和周边,先翻译再查询的两段式,和这里的城市名换成坐标是同一个模式,中间那个从搜索结果里挑一条的判断,也对应着本项目里取第一条结果的那一步。电商开放平台也是类似,商品名称搜索返回一个标识列表,再根据标识逐个查价格和库存,同样存在两段查询,同样需要在中间做一次选择,区别只在于挑选规则可能比取第一条复杂得多,那时候挑选逻辑就需要单独抽出来。

再往外推,任何带免费额度的接口都适用这套思路。免费额度是有限的,和风天气是每月前五万次请求免费,所以结果缓存多久、哪些请求可以省掉、连点要不要拦截,这些决策都会实实在在地影响可用性。这个项目里的做法有四条:默认城市的坐标写死,省掉城市搜索那次请求;切换城市只在没有数据的时候才请求;限制城市数量上限;刷新时防连点。四条规则加起来,正常使用完全不会碰到额度上限,也顺便避免了使用者因为乱点导致的意外消耗。

总结

回头看这一层的设计,关键其实很朴素,就是先把接口的真实行为搞清楚,再决定代码的结构。三个事实决定了三处设计:天气接口的 location 只接受坐标,所以必须多出城市搜索这一步;认证需要 Host 和凭据两样东西同时正确,而 Ed25519 在鸿蒙上签不出来,所以只能走 API KEY,并且把配置单独隔离成一个文件;新版 v1 响应里没有 code 字段,所以判断成功与否改成看 HTTP 状态码。

真正省事的做法是让一个通用请求函数把所有共性钉死,再让一个加载函数把接口字段翻译成界面说得清的语言,界面层就只剩下一件事,拿到数据并且显示出来。

现在回过头看,最花时间的并不是写代码,而是确认接口到底返回了什么。湿度是小数、降水概率藏在 daytime 下面的 precipitation 里、v1 响应没有 code 字段,这三点文档里都没有直白地写出来,全是拿真实响应一个字段一个字段对着看才发现的。所以如果读者也在接第三方接口,编者的建议是第一件事不要打开编辑器,而是先写一条命令行请求把响应原样打印出来看清楚:

curl -H "X-QW-Api-Key: 你的KEY" "https://你的APIHost/geo/v2/city/lookup?location=北京&range=cn"
curl -H "X-QW-Api-Key: 你的KEY" "https://你的APIHost/weather/v1/current/39.90/116.41?localTime=true"

看清楚之后再动手,代码该怎么写往往就没有悬念了。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐