在这里插入图片描述

上一篇写完 Decompose 的时候,我在结尾留了句话,说 Ktor 的鸿蒙引擎「目前还是空白,含金量更高」。当时写那句其实有点心虚——因为我还没真正动手。等我自己把这个引擎写完、在模拟器上点出第一个 200 之后,回头再看那句话,觉得它说得还是轻了。

Decompose 那篇里我反复强调,它在鸿蒙上只能是「等价复刻」:一套内部状态机,用另一种语言重写一遍,语义对上就行,上游的 .so 其实没参与。Ktor 不是这样。HttpClientEngine 是个真接口,不是状态机。这意味着我可以在 ArkTS 这边给它补一个真正能发出请求、真正收回响应的引擎实现,而不是把上游代码翻译一遍。这件事做成了,Ktor 在鸿蒙上就是「真能用」,而不只是「看起来能用」。

这也是整个鸿蒙化适配里,我第一次觉得自己在做一件有「重量」的事。

一个让我纠结了半天的选择

动手之前,我先翻了翻 Ktor 的源码,心里其实是打鼓的。Ktor 的客户端引擎在 ohosArm64 上现在是空的,要补,摆在我面前有两条路,而且这两条路的差别,比表面上看起来大得多。

第一条路,叫它路线 B 吧,是把 ktor-client-cio 直接编到 ohosArm64。CIO 是纯 Kotlin 加 java.net.Socket 写出来的,听起来很诱人——代码现成,理论上编过去就行。可真要把它跑起来,有三座山得自己翻:

你得自己解决的事怎么解决风险在哪
DNS 解析用 cinterop 去拿 getaddrinfo失败往往不是编译时报,是运行到你头上才报
TLS绑 OpenSSL / BoringSSL,自己处理证书信任链证书链、握手、ALPN,每一个都是深坑
代理自己实现 HTTP / SOCKS 代理协商一进企业网络环境,分分钟教你做人

最要命的是,这三件事但凡出问题,通常都是「编译能过、一跑就崩」。适配阶段最怕的就是这种——你根本不知道自己到底适配好了没有,直到半夜报警电话打过来。

第二条路,路线 A,是我最后选的:不编 CIO,而是在 ArkTS 这边自己写一个 HttpClientEngine,底层直接走鸿蒙的 @ohos.net.http。换句话说,把「网络能力」整个交给系统,我只做一个很薄很薄的引擎壳:

能力交给谁我这边写什么
DNS@ohos.net.http 内部走系统 resolver一个字都不用写
TLS系统 TLS 栈,证书信任链系统维护一个字都不用写
代理系统 / 全局代理设置Ktor 侧完全不介入

代价我得说清楚:这样你拿不到 CIO 那种细到 socket 级别的选项,比如自定义的 keep-alive、原生 socket 回调之类。但说句实在话,我工作了这么些年,95% 的业务请求根本碰不到这些东西。

选 A 的那一刻我想通了一件事:鸿蒙的系统网络栈,是已经被千万级 App 在真机上反复捶打过的成熟能力。我犯不着在适配阶段,自己造一个可能半夜崩溃的 TLS 实现。这件事也正好贴合这次征文想表达的「适配思路」——能复用平台能力的,就别硬去刚平台短板。

动手之前,先把 Ktor 的骨架画出来

我没一上来就写引擎,而是先写了个不碰网络的语义层,文件叫 Ktor.ets。这一步当时有人问过我:你直接发请求不就行了,折腾这些 HttpMethod、Headers 干啥?

我的想法是,Ktor 最值钱的不是它的网络实现,是它那套 API 契约。HttpClient 之所以能在各个平台换引擎,就是因为 HttpMethod、HttpRequestData、HttpResponseData、各种异常这些概念是稳定、统一的。我先在 ArkTS 这边把这些契约原样画一遍,后面写引擎、写页面的时候,脑子里想的就是 Ktor 的上游,而不是鸿蒙的某个 SDK 细节。

export class HttpMethod {
  static readonly GET: HttpMethod = new HttpMethod('GET');
  static readonly POST: HttpMethod = new HttpMethod('POST');
  // ... PUT / DELETE / HEAD / OPTIONS / PATCH
  readonly name: string;
  constructor(name: string) { this.name = name; }
}

export class Headers {
  private readonly map: Map<string, string[]> = new Map();
  // 键统一转小写;多值语义一定要保留(Set-Cookie 一个键真会有多个值)
  append(key: string, value: string): void { /* ... */ }
  toRequestHeaderObject(): Record<string, string> { /* 多值用逗号连接 */ }
}

export class HttpResponseData {
  readonly statusCode: number;
  readonly statusText: string;
  readonly headers: Headers;
  readonly bodyAsText: string;
  readonly elapsedMs: number;          // 引擎观测到的耗时,纯验收用
  get isSuccess(): boolean { return this.statusCode >= 200 && this.statusCode < 300; }
  bodyPreview(limit: number = 400): string { /* ... */ }
}

你看 isSuccess()、statusLine、bodyPreview() 这些名字,都是照着上游 io.ktor.client.statement.* 来的。等真正写业务的时候,写出来的代码读起来「就是 Ktor」,而不是「一个套了 Ktor 名字的 http 封装」。这种手感上的统一,我觉得比少写几百行代码重要得多。

这一层一共 446 行,它不 import 任何 @kit.*,意味着它将来想挪到 Node 里做离线测试也行,没有任何平台包袱。

真正发请求的地方

骨架画好,引擎层 OhosKtorEngine.ets 就水到渠成了。它做的事其实就三件:把请求组装好、发出去、把系统报的错翻译成 Ktor 的异常家族。核心代码长这样:

import { http } from '@kit.NetworkKit';
import { Headers, HttpClientConfig, HttpRequestData, HttpResponseData,
         KtorEngineException, KtorNetworkException, KtorTimeoutException }
  from '../ktor/Ktor';

export interface HttpClientEngine {
  readonly name: string;
  execute(request: HttpRequestData, config: HttpClientConfig): Promise<HttpResponseData>;
  close(): void;
}

export class OhosHttpEngine implements HttpClientEngine {
  readonly name: string = 'ohos';

  async execute(request: HttpRequestData, config: HttpClientConfig): Promise<HttpResponseData> {
    const startedAt = Date.now();
    const httpRequest: http.HttpRequest = http.createHttp();   // 每个请求单独一个实例
    try {
      const headerObject = request.headers.toRequestHeaderObject();
      const options: http.HttpRequestOptions = {
        method: request.method.name as http.RequestMethod,     // 字面量一致,直接映射
        extraData: request.body.isEmpty ? '' : request.body.toExtraData(),
        header: headerObject,
        expectDataType: config.expectJson ? http.HttpDataType.OBJECT : http.HttpDataType.STRING,
        readTimeout: config.requestTimeoutMs,
        connectTimeout: config.connectTimeoutMs
      };
      const response: http.HttpResponse = await httpRequest.request(request.url, options);
      const statusCode: number = Number(response.responseCode);  // 类型是 ResponseCode | number
      return new HttpResponseData(statusCode, statusTextOf(statusCode), /* ... */);
    } catch (err) {
      throw mapError(err as BusinessError<void>, KtorUrlHost(request.url));
    } finally {
      httpRequest.destroy();   // 成败都要释放,不然连接池会漏
    }
  }
}

这里面有两个点,是官方文档白纸黑字写着的,我一开始也没太当回事,后来才明白它俩是真的会咬人。

第一个,createHttp() 是每次请求都新建一个,用完了必须 destroy()。我最早偷懒想复用一个长生命周期的实例,心想这样还能省点开销。结果文档里专门提醒,长期复用会在长跑场景(比如后台轮询)下慢慢泄漏连接。所以现在老老实实「一次请求一个实例,finally 里销毁」——代码丑一点,但睡得着。

第二个,responseCode 这个字段,声明类型是 ResponseCode | number。你要是直接当 number 拿去用,ArkTS 的类型收窄会直接把你拦在编译期。我第一次见到这个报错还愣了一下,心想状态码还能不是数字?后来才反应过来它给了你一个枚举联合类型,得自己 Number(...) 收敛一下。这不是坑,是 ArkTS 在逼你写明确的代码。

编译过程如下:
在这里插入图片描述
在这里插入图片描述

很好,完美通过!

错误分类这件小事,救过我的命

我想单独聊聊异常家族这块,因为它看起来不起眼,实际上是我觉得整个引擎里「最懂业务」的一部分。

Ktor 把失败统一成 KtorException 家族,我在 ArkTS 这边对齐成三种:KtorTimeoutException、KtorNetworkException、KtorEngineException。光看名字好像只是给错误分了个类,但分类的判据才是关键——必须分得清「超时」和「连不上」:

function mapError(err: BusinessError<void>, host: string): KtorException {
  const code = err.code;
  if (code === 401000) return new KtorTimeoutException('请求超时(connect/request timeout)');
  if (code === 401001) return new KtorTimeoutException('读取超时(read timeout)');
  if (code === 401002) return new KtorTimeoutException('写入超时(write timeout)');
  if (code >= 200000 && code < 300000)
    return new KtorNetworkException(`网络不可达或 DNS 解析失败(code=${code})`, host);
  return new KtorEngineException(`请求失败(code=${code})`, code);
}

为什么这件事重要?因为超时和连不上,重试策略完全是两回事。超时,多半是网络抖了一下,重试往往有用;连不上、DNS 解析失败,重试一百次也是白搭,反而把队列堵死。我早年写过「一律重试三次」的代码,结果一个服务挂了,我们这边把自己重试到雪崩。从那以后我就认一个理:错误不分类,分类不为重试策略服务,那这个网络库就只是个 http 封装,配不上叫框架。

验收页上每种异常都跟着一句「该不该重试」的提示,业务层根本不用去背 @ohos.net.http 那张 errno 表。这就是 HttpClientEngine 这个抽象的含金量——它把「平台怎么报错」和「业务怎么应对」彻底隔开了。

验收页:换引擎,真的只要一行

为了让这个引擎「看得见摸得着」,我写了个 KtorDemo.ets 验收页,上面六个按钮,分别去打 GitHub Zen、查出口 IP、POST 一段 JSON、拼个带特殊字符的自定义 URL、故意要一个 404、再故意触发一次超时。每个请求回来,把状态码、响应头数量、耗时、body 摘要写进历史卡。

我想用这一页证明四件事:引擎真的能发请求拿回响应;DNS 和 TLS 确实走的是系统网络栈(不然我自己哪来的能力去解析域名、去握 TLS);错误真的能分成那三个家族;最后,也是我最想显摆的——换引擎只要改一行工厂调用。

private ensureClient(): KtorClient {
  if (this.client === null) {
    const config = new HttpClientConfig();
    config.withTimeout(10000, 5000).withRedirects(true, 5);
    // 这一行就是「换引擎」:CIO / OkHttp / 鸿蒙引擎,只是工厂不同
    this.client = new KtorClient(new OhosHttpEngine(new OhosEngineConfig()), config);
    this.engineLabel = `HttpClient(engine = ${this.client.engineName})`;
  }
  return this.client;
}

写这一段的时候我有点小得意。Ktor 在别家的平台上是这么玩的,在鸿蒙上照样是这么玩的。那种「抽象没有被平台打碎」的爽感,是这次适配给我最大的正反馈。

那些只有踩过才知道的坑

这一节是我特意留给想照着做的朋友的。下面这些,没有一个是我提前知道的,全是在 hvigor 编译器的红字里一个个认出来的。

getter 不能带参数。 我本来把「body 摘要」写成 get bodyPreview(limit),想着跟属性一样用多优雅。编译器一句话把我拍回来:'get' accessor cannot have parameters。ArkTS 里 getter 就是不能有参数,改成普通方法 bodyPreview(limit: number = 400) 就好了。

字段名和方法名不能重名。 这个坑我踩了两次。一次是 KtorUrlBuilder 里有个 private fragment 字段,我又写了个 fragment() 方法,重名;一次是 KtorClient 里 private readonly config 字段,又定义了 get config() getter,还是重名。ArkTS 不管你是字段还是方法,只要在同一类里同名就报错。改字段名最省事:fragment → fragmentValue,config → clientConfig。

BusinessError 必须带泛型参数。 系统抛出来的错误类型是 BusinessError<T = void>,你 as 的时候得写全:err as BusinessError<void>。漏了那个 <void>,编译器不认。

RequestMethod 不是 HttpMethod。 鸿蒙那边的方法枚举叫 http.RequestMethod,跟 Ktor 的 HttpMethod 是两个八竿子打不着的类。好消息是它们的字面量取值一模一样(都是 "GET"、"POST" 那套),所以引擎里直接 request.method.name as http.RequestMethod 就行,不用写一堆 switch。

INTERNET 权限,是真正会要命的那个。 前面四个坑,编译期就拦你了,你改完就完事。这个不会。漏了 ohos.permission.INTERNET,http.createHttp().request() 在运行时静默失败——不崩,就是请求永远进不来,最后统统掉进 KtorNetworkException。我在模拟器上第一次遇到这个,对着日志发呆了十分钟,以为自己引擎写错了,结果是权限忘声明。务必在 module.json5 里加上:

"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "reason": "$string:permission_internet_reason",
    "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
  }
]

配上 string.json 里的 permission_internet_reason,这事才算落地。

还有两个废弃 API 的小事:router.pushUrl 和 router.back 在 API 18 起标了废弃。我没硬换成 Navigation/NavPathStack,因为那套要求两个页面同属一个导航容器,而我这里 Ktor 页和 Decompose 页是各自独立验证的,硬塞进一个容器反而别扭。我就在注释里把原委写清楚,保留原调用。这种「知道它废弃、也知道为什么暂时不换」的状态,比盲目追新要踏实。

怎么知道它真的活了

验证这件事,我是分了两层做的,跟 Decompose 那篇一样。

第一层是 clean 全量编译。我特别坚持要 clean 之后再编,而不是在改了几个文件之后增量编——增量编过了,不代表从头来一遍也过。命令就是:

ohpm install
"E:/Program Files/DevEcoStudio/DevEco Studio/tools/hvigor/bin/hvigorw.bat" \
  clean assembleHap --mode module -p module=entry@default

结果很干净:BUILD SUCCESSFUL,零 ArkTS error。只有两条 router.pushUrl / router.back 的废弃告警,就是上面说的那两个,属已知噪音。产出的 entry-default-unsigned.hap 大概 444 KB,未签名,模拟器直接能装。

第二层才是真刀真枪:跑 entry 模块,首页点「Ktor 客户端引擎适配 Demo(真实网络请求)→」,进去点按钮。各按钮的预期我列一下,方便你对着查:

按钮你该看到什么
GET · GitHub Zen200,一句英文格言
GET · 查出口 IP200,响应体是 JSON 且带着你的出口 IP(这就证明 DNS 走的是系统 resolver)
POST · JSON200,服务端把你发的 JSON 原样回显(证明请求体 + Content-Type 推导都对)
GET · 自定义 URL200,URL 里 q=a b&c=d 和中文参数被正确转义了
GET · 预期 404进历史卡、标红,但不抛异常(404 是正常响应,不是错误)
GET · 预期超时进 KtorTimeoutException 分支,提示「可重试」

说句心里话,当我在模拟器上第一次看到 GitHub Zen 那条记录亮起绿色 200 OK 的时候,身体是真松了一口气的。那一刻我才算确认:这不是一个「理论上能发请求」的引擎,它是真的跑在了鸿蒙的系统网络栈上。

运行效果如下:
在这里插入图片描述
我们点击不同按钮进行测试,可以看到真实的数据反馈:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

路由较远的链接我们请求会发现反馈时间特别长,符合预期。
在这里插入图片描述

和 Decompose 那篇,是两种完全不同的「适配」

写到这里,我想把这两篇连起来说,因为我觉得这才是整个系列最值得讲清楚的一点。

维度DecomposeKtor
本质纯状态管理(组件树 / 生命周期 / 状态)真实网络能力(发请求、收响应、处理错误)
鸿蒙上怎么做的ArkTS 等价复刻(语义对上,不是真上游)ArkTS 真实现 HttpClientEngine
.so 真的就绪之后替换桥接层替换引擎层(换成 Kotlin 侧的实现)
验证难度相对容易(没有外部依赖)难得多(要真联网、要权限、DNS/TLS 要真的通)

Decompose 那 2350 行,本质上是「把一套内部状态机用另一种语言重写一遍」;Ktor 这 1319 行,是「给一个真接口补一个真实现」。后者,才更贴近「适配」这两个字本来的意思——让一个为多平台设计的库,在新平台上长出真正能用的后端,而不是换个皮。 这也是为什么我在上一篇结尾说它含金量更高:复刻考验的是耐心,实现考验的是你对平台边界的判断。

三条我现在认准的判断

折腾完这一圈,有三件事我现在是想得很清楚的:

先看抽象层,再决定动手写什么。 Ktor 把「网络栈」抽成了 HttpClientEngine 这个接口,所以我的适配工作量,说到底就是「实现这一个接口」。如果当年它把引擎做成 sealed class 的内部实现,那鸿蒙适配就变成重写整个 client 了。抽象画在哪儿,工作量就在哪儿。

能复用平台能力,就别硬刚平台短板。 路线 A 把 DNS、TLS、代理一股脑交给系统网络栈,绕开了 native TLS 那个大坑。适配的目的从来不是「证明我也能写 TLS」,而是「让 Ktor 在鸿蒙上可用」。把目标认准了,很多执着就放下了。

错误一定要分类,而且分类要服务于重试策略。 这一点我前面专门聊过,这里再强调一次也不为过。一个网络库和一个 http 封装之间,往往就差这一层判断。


工程信息


欢迎加入KMP&CMP 鸿蒙社区: https://atomgit.com/CPF-KMP-CMP

推荐 AtomCode(AI 编程工具,专属邀请码):https://atomgit.com/dashboard/atomcode?utm_source=av&isLogin=99

顺手说一句下一步的打算:像 ktor-client-logging、ktor-client-content-negotiation 这类纯 Kotlin 插件,根本不碰平台网络栈,编进 ohosArm64 的成本极低,随时能加;真正需要平台化的只有 HttpClientEngine 这一层,而它已经在我这台模拟器上跑出第一个 200 了。

Logo

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

更多推荐