拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kotlin Multiplatform for OpenHarmony 实战:为 Ktor 客户端实现 OpenHarmony 引擎

Kotlin Multiplatform for OpenHarmony 实战:为 Ktor 客户端实现 OpenHarmony 引擎


您好,我是ID: 熊猫钓鱼!
十余年深耕技术一线,我始终相信:优秀的开发如同垂钓——既要对技术生态的「水域」有深邃理解,也要对问题本质的「鱼汛」保持敏锐直觉。从架构设计到性能调优,从技术选型到团队协作,我专注在恰当的时机,用最合适的技术钓起最优雅的解决方案。关注我,我带你一起研究最新又好玩的热点技术~!

上一篇写完 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 细节。

exportclassHttpMethod{staticreadonlyGET:HttpMethod=newHttpMethod('GET');staticreadonlyPOST:HttpMethod=newHttpMethod('POST');// ... PUT / DELETE / HEAD / OPTIONS / PATCHreadonlyname:string;constructor(name:string){this.name=name;}}exportclassHeaders{privatereadonlymap:Map<string,string[]>=newMap();// 键统一转小写;多值语义一定要保留(Set-Cookie 一个键真会有多个值)append(key:string,value:string):void{/* ... */}toRequestHeaderObject():Record<string,string>{/* 多值用逗号连接 */}}exportclassHttpResponseData{readonlystatusCode:number;readonlystatusText:string;readonlyheaders:Headers;readonlybodyAsText:string;readonlyelapsedMs:number;// 引擎观测到的耗时,纯验收用getisSuccess():boolean{returnthis.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';exportinterfaceHttpClientEngine{readonlyname:string;execute(request:HttpRequestData,config:HttpClientConfig):Promise<HttpResponseData>;close():void;}exportclassOhosHttpEngineimplementsHttpClientEngine{readonlyname:string='ohos';asyncexecute(request:HttpRequestData,config:HttpClientConfig):Promise<HttpResponseData>{conststartedAt=Date.now();consthttpRequest:http.HttpRequest=http.createHttp();// 每个请求单独一个实例try{constheaderObject=request.headers.toRequestHeaderObject();constoptions:http.HttpRequestOptions={method:request.method.nameashttp.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};constresponse:http.HttpResponse=awaithttpRequest.request(request.url,options);conststatusCode:number=Number(response.responseCode);// 类型是 ResponseCode | numberreturnnewHttpResponseData(statusCode,statusTextOf(statusCode),/* ... */);}catch(err){throwmapError(errasBusinessError<void>,KtorUrlHost(request.url));}finally{httpRequest.destroy();// 成败都要释放,不然连接池会漏}}}

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

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

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

编译过程如下:

很好,完美通过!

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

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

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

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

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

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

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

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

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

privateensureClient():KtorClient{if(this.client===null){constconfig=newHttpClientConfig();config.withTimeout(10000,5000).withRedirects(true,5);// 这一行就是「换引擎」:CIO / OkHttp / 鸿蒙引擎,只是工厂不同this.client=newKtorClient(newOhosHttpEngine(newOhosEngineConfig()),config);this.engineLabel=`HttpClient(engine =${this.client.engineName})`;}returnthis.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之后再编,而不是在改了几个文件之后增量编——增量编过了,不代表从头来一遍也过。命令就是:

ohpminstall"E:/Program Files/DevEcoStudio/DevEco Studio/tools/hvigor/bin/hvigorw.bat"\clean assembleHap--modemodule-pmodule=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 封装之间,往往就差这一层判断。


工程信息

  • 新增代码:1319 行 ArkTS(Ktor.ets446 +OhosKtorEngine.ets356 +KtorDemo.ets517)
  • 工具链:DevEco Studio 26.0.0 Release / SDK5.1.1(19)/ HarmonyOS Kotlin 2.2.21-1.0.0
  • 引擎路线:路线 A(自研HttpClientEngine走@ohos.net.http)
  • 必要权限:ohos.permission.INTERNET
  • 适配后仓库:https://atomgit.com/wdracky/kmp-ohos-adapters/tree/main/ktor
  • KMP/CMP 鸿蒙化社区:https://atomgit.com/CPF-KMP-CMP

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

推荐 AtomCode(AI 编程工具,专属邀请码): https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths

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

返回列表