Kotlin Multiplatform for OpenHarmony 实战:为 Ktor 客户端实现 OpenHarmony 引擎
发布时间:2026/10/5 18:14:02 锦皓数字建站

您好我是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。换句话说把「网络能力」整个交给系统我只做一个很薄很薄的引擎壳能力交给谁我这边写什么DNSohos.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:HttpMethodnewHttpMethod(GET);staticreadonlyPOST:HttpMethodnewHttpMethod(POST);// ... PUT / DELETE / HEAD / OPTIONS / PATCHreadonlyname:string;constructor(name:string){this.namename;}}exportclassHeaders{privatereadonlymap:Mapstring,string[]newMap();// 键统一转小写多值语义一定要保留Set-Cookie 一个键真会有多个值append(key:string,value:string):void{/* ... */}toRequestHeaderObject():Recordstring,string{/* 多值用逗号连接 */}}exportclassHttpResponseData{readonlystatusCode:number;readonlystatusText:string;readonlyheaders:Headers;readonlybodyAsText:string;readonlyelapsedMs:number;// 引擎观测到的耗时纯验收用getisSuccess():boolean{returnthis.statusCode200this.statusCode300;}bodyPreview(limit:number400):string{/* ... */}}你看isSuccess()、statusLine、bodyPreview()这些名字都是照着上游io.ktor.client.statement.*来的。等真正写业务的时候写出来的代码读起来「就是 Ktor」而不是「一个套了 Ktor 名字的 http 封装」。这种手感上的统一我觉得比少写几百行代码重要得多。这一层一共 446 行它不import任何kit.*意味着它将来想挪到 Node 里做离线测试也行没有任何平台包袱。真正发请求的地方骨架画好引擎层OhosKtorEngine.ets就水到渠成了。它做的事其实就三件把请求组装好、发出去、把系统报的错翻译成 Ktor 的异常家族。核心代码长这样import{http}fromkit.NetworkKit;import{Headers,HttpClientConfig,HttpRequestData,HttpResponseData,KtorEngineException,KtorNetworkException,KtorTimeoutException}from../ktor/Ktor;exportinterfaceHttpClientEngine{readonlyname:string;execute(request:HttpRequestData,config:HttpClientConfig):PromiseHttpResponseData;close():void;}exportclassOhosHttpEngineimplementsHttpClientEngine{readonlyname:stringohos;asyncexecute(request:HttpRequestData,config:HttpClientConfig):PromiseHttpResponseData{conststartedAtDate.now();consthttpRequest:http.HttpRequesthttp.createHttp();// 每个请求单独一个实例try{constheaderObjectrequest.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.HttpResponseawaithttpRequest.request(request.url,options);conststatusCode:numberNumber(response.responseCode);// 类型是 ResponseCode | numberreturnnewHttpResponseData(statusCode,statusTextOf(statusCode),/* ... */);}catch(err){throwmapError(errasBusinessErrorvoid,KtorUrlHost(request.url));}finally{httpRequest.destroy();// 成败都要释放不然连接池会漏}}}这里面有两个点是官方文档白纸黑字写着的我一开始也没太当回事后来才明白它俩是真的会咬人。第一个createHttp()是每次请求都新建一个用完了必须destroy()。我最早偷懒想复用一个长生命周期的实例心想这样还能省点开销。结果文档里专门提醒长期复用会在长跑场景比如后台轮询下慢慢泄漏连接。所以现在老老实实「一次请求一个实例finally 里销毁」——代码丑一点但睡得着。第二个responseCode这个字段声明类型是ResponseCode | number。你要是直接当 number 拿去用ArkTS 的类型收窄会直接把你拦在编译期。我第一次见到这个报错还愣了一下心想状态码还能不是数字后来才反应过来它给了你一个枚举联合类型得自己Number(...)收敛一下。这不是坑是 ArkTS 在逼你写明确的代码。编译过程如下很好完美通过错误分类这件小事救过我的命我想单独聊聊异常家族这块因为它看起来不起眼实际上是我觉得整个引擎里「最懂业务」的一部分。Ktor 把失败统一成KtorException家族我在 ArkTS 这边对齐成三种KtorTimeoutException、KtorNetworkException、KtorEngineException。光看名字好像只是给错误分了个类但分类的判据才是关键——必须分得清「超时」和「连不上」functionmapError(err:BusinessErrorvoid,host:string):KtorException{constcodeerr.code;if(code401000)returnnewKtorTimeoutException(请求超时connect/request timeout);if(code401001)returnnewKtorTimeoutException(读取超时read timeout);if(code401002)returnnewKtorTimeoutException(写入超时write timeout);if(code200000code300000)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.clientnull){constconfignewHttpClientConfig();config.withTimeout(10000,5000).withRedirects(true,5);// 这一行就是「换引擎」CIO / OkHttp / 鸿蒙引擎只是工厂不同this.clientnewKtorClient(newOhosHttpEngine(newOhosEngineConfig()),config);this.engineLabelHttpClient(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→fragmentValueconfig→clientConfig。BusinessError必须带泛型参数。系统抛出来的错误类型是BusinessErrorT void你as的时候得写全err as BusinessErrorvoid。漏了那个void编译器不认。RequestMethod不是HttpMethod。鸿蒙那边的方法枚举叫http.RequestMethod跟 Ktor 的HttpMethod是两个八竿子打不着的类。好消息是它们的字面量取值一模一样都是GET、POST那套所以引擎里直接request.method.name as http.RequestMethod就行不用写一堆 switch。INTERNET权限是真正会要命的那个。前面四个坑编译期就拦你了你改完就完事。这个不会。漏了ohos.permission.INTERNEThttp.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之后再编而不是在改了几个文件之后增量编——增量编过了不代表从头来一遍也过。命令就是ohpminstallE:/Program Files/DevEcoStudio/DevEco Studio/tools/hvigor/bin/hvigorw.bat\clean assembleHap--modemodule-pmoduleentrydefault结果很干净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 走的是系统 resolverPOST · JSON200服务端把你发的 JSON 原样回显证明请求体 Content-Type 推导都对GET · 自定义 URL200URL 里qa bcd和中文参数被正确转义了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 行 ArkTSKtor.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/ktorKMP/CMP 鸿蒙化社区https://atomgit.com/CPF-KMP-CMP欢迎加入KMPCMP 鸿蒙社区 https://atomgit.com/CPF-KMP-CMP推荐 AtomCodeAI 编程工具专属邀请码 https://developer.huaweicloud.com/codeartsco.html?sourcedmzntgwatomgit1sourceaddmzntgwatomgiths顺手说一句下一步的打算像ktor-client-logging、ktor-client-content-negotiation这类纯 Kotlin 插件根本不碰平台网络栈编进 ohosArm64 的成本极低随时能加真正需要平台化的只有HttpClientEngine这一层而它已经在我这台模拟器上跑出第一个 200 了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。