鸿蒙 ArkTS 真机排查实录,三个「代码没错」却跑不通的坑
在curl 挨个试过都正常,上真机上,删一个会话,弹窗「服务返回 HTTP 501」。 **后端问题?** DevEco Studio 26.0.0.851,SDK API 26,真机 SGT-AL10,HarmonyOS 7.0.0.109。服务端是我自己拿 Python `http.server` 写的一个小桥,跑在 Windows 上,手机通过 Tailscale 连过来,纯 HTTP 加一个自定义 token。会话列表、流式输出、文件上下行全走这套接口。 三个坑按撞上的顺序写。每个坑我一开始都怀疑服务端,三次都猜错。 ## 501 从一个不该存在的服务回来 删完会话要刷新列表,501 就是这时候弹的。我去翻桥的日志,同一时刻那条删除请求成功到达,也处理完了。 不对劲的地方在响应体。原文写着 `Unsupported method ('GET')`。这是 Python `BaseHTTPRequestHandler` 对不认识的 method 的默认回包,可我的服务明明写了 `do_GET`。 我先怀疑自己看错了日志。给桥加上逐请求日志,一行一条,格式是 `GET /api/sessions -> 200`,然后在手机上又复现了一遍。 日志里没有那条 GET。一条都没有。 请求没到服务端,客户端却收到了一条 501 响应。中间还能有谁。 答案在 SDK 的声明文件里。`@ohos.net.http` 有个 `usingProxy` 字段,注释原文是 If this item is not configured, the system proxy is used by default。不显式配,请求会走系统代理。 当时手机挂着 VPN,它那个本地代理只处理 CONNECT 隧道,明文的 HTTP GET 进去,被它当成了认不出的方法,回了一个 501。至于这个代理为什么用 Python 的报错格式回包,我没去查,跟问题本身没关系。 这里我白绕了一圈。看到 `Unsupported method` 就一路盯着自己的服务端看,从头到尾没想过客户端和服务端之间还夹着一层。 解法是一行。 ```javascript const req = http.createHttp(); const resp = await req.request(Api.url(path), { method: http.RequestMethod.POST, header: Api.header(), extraData: body.length > 0 ? body : undefined, connectTimeout: connectMs, readTimeout: readMs, // 不走系统代理。手机挂着 VPN 或代理时,明文请求可能被它吃成 // 501 Unsupported method,而服务端根本收不到包 usingProxy: false }); ``` 改完之后,后面几轮真机上这一条没再出现过。要说彻底根除,我手上只有这几轮的观察,没做过开关代理的对照实验,不敢讲满。 这个坑还带出一个小的。我拿 HEAD 请求探活,桥当时只实现了 `do_GET`,探活自己也吃到 501。补一个返回 204 的空 `do_HEAD` 就干净了。这件事教训是探测姿势得对,拿错误的探测结果当证据,只会把方向带得更偏。 ## DELETE 带上请求体,请求根本没发出门 删会话报 `Parameter error`。我用 curl 手发一毛一样的 DELETE 请求,服务端 200 正常返回。 这次判据来得快。桥的逐请求日志里,一条 DELETE 都没有,请求没出门。而 401 那个错误码也不是服务端给的,是鸿蒙 http 栈在客户端本地造出来的。 原因是 http 栈不接受 DELETE 加 `extraData` 这个组合,直接抛 401,不发包。换方法就行,语义塞进路径。 ```javascript // 不可用。鸿蒙 http 栈对 DELETE + extraData 直接抛 401,请求不会发出 // method: http.RequestMethod.DELETE, extraData: JSON.stringify({...}) const req = http.createHttp(); const resp = await req.request(Api.url('/api/sessions/' + sid + '/delete'), { method: http.RequestMethod.POST, header: Api.header(), usingProxy: false }); ``` 服务端路由跟着改成 `POST /api/sessions//delete`,顺手加了个 `POST /restore` 做撤销软删。 这里有一条是我拿代价换的。探删除接口的鉴权行为时,我为了省事,直接拿了一条真实会话的 sid 去试,结果真把它删了,后来靠补上的 `/restore` 才捞回来。探鉴权用不存在的假 sid 就够了,本来要验的只有能不能进门,把真数据搭进去没有任何好处。 ## bindSheet 点了没反应 会话的「重命名」和「连接设置」都是半模态。两个 sheet 都挂在页面根节点上,点菜单项,状态变量确实变成 `true` 了,sheet 不出来。 先怀疑菜单的点击没触发,加日志打了一圈。点击进去了,状态也改了,界面没动。那问题就在组件层。 同一个宿主节点上绑两个 `bindSheet`,会互相吞,后绑的接管,先绑的静默失效。官方文档写了这件事,只是埋在一份很长的组件文档里,代码层面完全看不出来。编译通过,状态正常,没有异常,日志不报错。 这坑还有另一种长相。我先给模型胶囊按钮绑了一个,又给页面根节点绑了一个,结果是弹出两个 sheet 叠在一起,看着像层级乱了。 改法是全页只留一处 `bindSheet`,内容和高度都用状态切。 ```javascript @State showSheet: boolean = false; @State sheetMode: string = ''; // 'rename' | 'conn' // 触发处只置模式,不各自弹 sheet this.sheetMode = 'rename'; this.showSheet = true; ``` ```javascript // 页面根节点,全页唯一一处 bindSheet .bindSheet($$this.showSheet, this.mainSheet(), { height: this.sheetMode === 'rename' ? 260 : 380, dragBar: true, backgroundColor: $r('app.color.sheet_bg'), blurStyle: BlurStyle.COMPONENT_ULTRA_THICK }) ``` `mainSheet()` 里按 `sheetMode` 分支渲染两套内容,高度也跟着走。改完两个入口都正常,观感还统一了。 这条后来成了我自己的一条规矩。一个宿主节点只绑一个 `bindSheet`,要几套半模态就用单状态机切,别开多个绑定。 ## 仨坑串起来看 每处修复都在五行以内。时间基本都花在找方向上,写代码只占最后几分钟。三次我都先怀疑服务端,三次日志都告诉我请求没出门。同一个动作重复三遍,我才算把「先看服务端到底收没收到包」变成条件反射。 现在这套代码里,所有请求参数统一带 `usingProxy: false`,删除一律走 POST,全页只有一处 `bindSheet`。这几条谈不上什么设计,都是被真机打过之后才加上的。 如果你正在写直连自有服务的鸿蒙应用,这三个参数值得先记下。`usingProxy` 显式写 `false`,DELETE 别带 body,一个宿主只绑一个 `bindSheet`。全都是三十秒能改完的事,找它们每一条都可能花掉半天。