news 2026/9/9 17:27:42

Flutter鸿蒙多地址监听实战:http_multi_server与Socket桥接方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter鸿蒙多地址监听实战:http_multi_server与Socket桥接方案

先说个背景。去年我给一个开源的多设备调试工具做 OpenHarmony 适配时,遇到了一个非常具体但又特别磨人的问题:开发板同时连着公司 WiFi 和开着一个手机热点,Flutter 侧的控制面板需要局域网内其他电脑、手机都能访问。折腾了一圈发现,默认的HttpServer.bind只能绑定一个地址,WiFi 网段能访问了,热点网段就挂;IPv4 通了,IPv6 又不行。后来就是靠 Flutter 三方库http_multi_server加上一层鸿蒙原生 Socket 桥接把这个事彻底解决的。这篇记录一下完整的适配思路、核心代码和我在实操中踩过的坑,给正在做 Flutter for OpenHarmony 局域网服务、设备协作类功能的朋友一个参考。

1. 先说清楚需求:多设备局域网协作到底卡在哪

做设备端服务的人和做纯 App 的人,思考路径完全不一样。App 只要考虑“用户怎么点”,设备端要考虑“局域网里谁来找我、我从哪个网卡出去”。鸿蒙开发板这类设备尤其典型,它不只是一个跑 UI 的终端,很多时候要承担“局域网内的小服务器”这个角色。

1.1 一个典型场景:设备控制面板加数据接收

我这里说的项目,是一台基于 OpenHarmony 的开发板,上面用 Flutter 做了两套东西:

  • 一套是给用户看的控制界面,本身也是 Flutter UI;
  • 另一套是后台启动的 HTTP 服务,局域网内任何一台电脑打开浏览器输入http://192.168.x.x:8080,就能看到设备实时状态、修改配置;手机扫码也能进入同一个控制面板。

同时,设备上还跑着一个数据接收接口,其他终端会把传感器数据、日志通过 POST 请求推到这个接口,设备统一处理后入库或转发。

这就带来两个并行的服务需求:一个是“人访问的页面”,一个是“机器访问的接口”。如果都塞在一个 Server 实例里,路由层会越写越乱;更麻烦的是,这两个服务在设备启动阶段、异常重启阶段的生命周期还不一样,接口服务要第一时间起来,页面可以稍后加载。

1.2 只监听一个地址为什么不够

很多刚开始写设备端服务的人会问:HttpServer.bind(InternetAddress.anyIPv4, 8080)不就行了吗?它确实把 8080 端口绑到了所有 IPv4 网卡上,但在真实鸿蒙设备环境里,这个“行”要打个折扣。我实际遇到的情况有这么几类:

  • 开发板同时连 WiFi 和开热点。公司 WiFi 给开发板分配的地址是192.168.1.101,热点网段是192.168.137.1anyIPv4确实能同时响应两个网卡,但如果后续代码里要区分“哪个网卡被访问”,或者要根据请求来源做权限控制,就需要显式拿到每个地址单独处理;
  • IPv6 环境直接失联。家里或者办公室路由器开了 IPv6 后,开发板会有一个fe80::开头的链路本地地址,甚至还有一个公网 IPv6 地址。你只 bind 了 IPv4 时,局域网里用[fe80::xxxx]:8080访问是连不上的;反过来只 bind IPv6 时,老设备用 IPv4 访问又不行;
  • 多服务需要独立启停。控制页面服务和数据接收服务的异常恢复策略不一样,如果混在一个 Server 里,别的都没法平滑处理。

这些问题的本质是:你的服务要面向“多个网络入口”提供服务,而不是面向“某个具体地址”。http_multi_server这个三方库解决的就是这个层次的抽象问题。

2. 看源码理解 http_multi_server:多地址监听不只是“多绑几个端口”

先说结论:http_multi_server这个库很轻,核心代码量不大,但它的设计角度选得很好。它不是简单帮你把 bind 循环写一遍,而是把“多地址监听”抽象成了一个统一的Stream<HttpRequest>入口。

2.1 它内部到底做了什么

这个库的核心 API 是MultiServer.startMultiServer(),它会接收一组要监听的地址,然后为每个地址分别创建独立的HttpServer实例,再把所有实例收到的请求事件合并到一个 Stream 里交给上层统一处理。

用 Dart 伪代码来表达它的思路就是:

Stream<HttpRequest> startMultiServer( List<dynamic> addresses, int port, dynamic Function(HttpRequest) handler, ) async* { final servers = <HttpServer>[]; for (final addr in addresses) { final server = await HttpServer.bind(addr, port); servers.add(server); } // 合并所有 server 的请求流 for (final server in servers) { yield* server.transform(...); // 请求统一汇入 } }

实际源码不是这么简单,它还有stream管理、server注销、地址合法性检查等细节,但这个核心模型已经足够说明问题:它把一个“多入口服务”建模成“一组 HttpServer 的请求聚合”,上层业务代码只需要写一套 handler,不用关心请求是从哪个地址进来的。

顺带一提,库内置了local()loopback()等便捷方法,分别用于监听所有本地地址和回环地址,这在调试阶段特别好用。我做桌面端冒烟测试时,直接调MultiServer.loopback()就能把测试跑起来。

2.2 为什么不直接上 Nginx 或反向代理

有人可能会说,多地址监听问题用 Nginx 或者一个反向代理就解决了。但在 OpenHarmony 设备上,这不是一个“好不好用”的问题,而是“值不值得”的问题:

  • 资源开销:嵌入式开发板内存通常只有几百 MB 到 2GB,跑一个 Nginx 进程虽然不大,但加上 Flutter 引擎、鸿蒙系统服务,占用已经不小,能省则省;
  • 部署复杂度:OpenHarmony 应用打包成 HAP 后,外挂一个 Nginx 二进制涉及签名、权限、路径白名单等一系列问题,调试成本高;
  • 技术栈割裂:服务端的控制逻辑(比如根据设备状态动态返回 JSON)已经写在 Dart 里了,再用 Nginx 转发,等于在中间插了一层“翻译官”,出问题时定位链路很长。

所以在 Dart 层解决多地址监听,对这个场景来说是最短路径。

2.3 多地址和“多端口”有什么区别

这一点容易混淆。我把它整理成一个表格,看一遍就清楚了:

维度多端口方案多地址方案
端口数量每个服务一个端口所有服务共享同一端口或各自指定
网卡绑定默认绑全部网卡,难以区分可以精确指定哪些网卡提供服务
IPv4/IPv6 控制需要额外处理可以按地址类型分别监听
适合场景页面 8080、接口 8081 分开同一套服务要同时暴露在多个网段
在鸿蒙上的适配复杂度也要过 Socket 桥接,没有本质区别一次桥接,多个入口同时生效

我在实际项目里最终是“多地址 + 多端口”结合着用:控制面板走 8080,数据接口走 8081,两个端口各自都通过MultiServer绑到 WiFi 和热点两个网段上。这样每个服务的启停互不影响,请求来源也能通过request.connectionInfo.remoteAddress拿到。

3. 鸿蒙适配的关键路径:dart:io 到系统 Socket 的桥接方案

如果你只在 Android、iOS、桌面端跑 Flutter,http_multi_server可以直接用一个dart:io实现,两三行代码就能跑起来。但到了 OpenHarmony 上,事情没有这么顺利。Flutter for OpenHarmony 虽然把 Dart 虚拟机完整移植过来了,可网络栈底层和标准dart:io的行为存在差异,尤其是HttpServer这类需要绑定原生 Socket 的能力,在不同版本的 ohos 分支 SDK 上表现不稳定。我自己在 OpenHarmony 4.1 环境上测试时,HttpServer.bind就出现过地址监听异常的情况。

3.1 先确认当前环境的边界

做适配第一步不是急着写代码,而是先摸清当前 Flutter for OpenHarmony 分支里dart:io有哪些 API 可用、哪些不可用。

我当时列了一个检查表:

  • Socket.connect客户端连接是否正常;
  • ServerSocket.bind是否支持指定InternetAddress
  • HttpServer.bind是否可用、响应是否正常;
  • HttpClient请求外部服务是否正常。

测试下来发现,客户端方向的HttpClient基本没问题,但服务端方向的HttpServer在部分网卡绑定、IPv6 地址处理上不可靠。所以我的方案是:Dart 侧保留http_multi_server的 API 形状,底层通过 MethodChannel 把实际监听工作交给鸿蒙原生 Socket 完成,解析出来的 HTTP 请求再映射成 Dart 侧的HttpRequest对象。这样上层调用方不用变,只是替换了“Server 实现”。

3.2 桥接层设计:把鸿蒙 TcpServer 包装成 Dart Stream

这个桥接层要解决的核心问题有两个:

  1. 鸿蒙侧@ohos.net.socketTcpServer只提供原始 TCP 字节流,不解析 HTTP;
  2. Dart 侧要拿到一个符合Stream<HttpRequest>语义的数据源,才能喂给http_multi_server的请求合并逻辑。

所以我在 Dart 侧定义了一个内部接口:

abstract class MultiServerPlatform { Stream<HttpRequest> start({ required List<String> addresses, required int port, bool ipv6Only = false, }); Future<void> stop(); }

默认情况下,桌面端、Android 上用DartIoMultiServer实现,内部直接走HttpServer.bind;在 OpenHarmony 上则使用OhosMultiServer,内部走 MethodChannel 调到 ArkTS 层。

3.3 ArkTS 侧 TcpServer 的关键实现

ArkTS 侧我用的是@ohos.net.socket提供的tcpServer。核心流程分三步:

  • 创建TcpServer实例并绑定指定 IP 和端口;
  • 监听connection事件,对每一个SocketConnection读取数据;
  • 解析 HTTP 请求行、请求头、请求体,组装成结构化数据回传 Dart。

ArkTS 侧核心代码大致长这样:

import { socket } from '@kit.NetworkKit'; let tcpServer = socket.constructTCPSocketInstance(); tcpServer.listen({ address: '192.168.1.101', port: 8080, family: 1, // 1 表示 IPv4,2 表示 IPv6 keepAlive: true, }).then(() => { console.info('TcpServer listening'); }); tcpServer.on('connect', (connection: socket.SocketConnection) => { let socketConnection = connection.connection; socketConnection.on('message', (message: ArrayBuffer) => { // 这里拿到原始 TCP 数据,需要做 HTTP 报文解析 let request = parseHttpRequest(message); sendToDart(request); }); });

parseHttpRequest这个函数需要处理一个很常见的问题:TCP 是流式协议,一个 HTTP 请求可能分多个包到达,也可能多个请求粘在一个包里到达。我在桥接层维护了一个字节缓冲区,只有解析出完整的头部(读到\r\n\r\n)才封装成一次请求,正文按Content-LengthTransfer-Encoding: chunked继续读取。这段逻辑是桥接层里最容易出 bug 的地方,后面会专门展开讲。

4. 改造实操:在开发板上同时开启 WiFi 和热点双网段服务

理论讲完了,下面把我在 Dayu200 开发板上跑通的完整过程写出来。整个工程基于 OpenHarmony 4.1 Release,Flutter SDK 用的是 ohos 适配分支,DevEco Studio 负责鸿蒙侧编译打包。

4.1 获取设备当前所有可用地址

要让http_multi_server真正发挥“多地址”的价值,首先得知道设备当前有哪些地址可以监听。我用NetworkInterface.list拿到所有网卡信息,然后按规则过滤:

import 'dart:io'; Future<List<InternetAddress>> getListenableAddresses() async { final interfaces = await NetworkInterface.list( includeLoopback: false, includeLinkLocal: true, ); final result = <InternetAddress>[]; for (final iface in interfaces) { for (final addr in iface.addresses) { // 跳过回环地址,回环地址单独用 loopback 监听 if (addr.isLoopback) continue; if (addr.address.startsWith('fe80')) { // 链路本地地址保留,但只在 IPv6 场景需要时加入 if (enableIpv6LinkLocal) result.add(addr); } else { result.add(addr); } } } return result; }

注意一个细节:默认情况下,开发板如果同时连 WiFi 和创建热点,NetworkInterface.list可能把热点虚拟网卡也列出来,但热点网卡的地址在不同系统版本上有差异。有的版本是192.168.137.1,有的版本是192.168.43.1,写代码时不要硬编码任何网段。

4.2 启动多地址监听

拿到地址列表后,把它交给MultiServer.start

import 'package:http_multi_server/http_multi_server.dart'; Future<HttpServer> startMultiAddressServer({ required int port, required Future<void> Function(HttpRequest request) handler, }) async { final addresses = await getListenableAddresses(); // 核心:把多个地址交给 MultiServer 统一管理 final server = await MultiServer.start( addresses, port, (request) async { // 这里就是所有地址、所有请求的统一入口 await handler(request); }, ); return server; }

注意MultiServer.start返回的是一个符合HttpServer接口的对象,所以后续server.close()server.connectionsInfo()这些操作都能直接用。我在这个环节做了两件额外的事:

  • 把当前监听的地址和端口通过日志打出来,方便调试;
  • 为每个地址生成一个二维码展示在 Flutter 界面上,手机扫码就能打开对应网段的控制面板。

4.3 MethodChannel 回传请求的封装细节

如果你是纯 Dart 工程,上面代码就够了;但鸿蒙侧走的是 ArkTS 桥接,Dart 侧收到原生解析出的 HTTP 请求后,还要把原始数据重新构造成一个HttpRequest对象。

我的做法是让 ArkTS 侧回传一个 Map,包含以下字段:

字段说明示例
methodHTTP 方法GET / POST
uri请求路径和查询参数/api/status?type=1
httpVersion协议版本HTTP/1.1
headers请求头 MapContent-Type: application/json
bodyUTF-8 编码的请求体{“cmd”: “reboot”}
remoteAddress请求来源 IP192.168.1.50

Dart 侧拿到这些字段后,通过StreamController<HttpRequest>包装,再把 Stream 交给业务层签名一致的 handler。这样上层不用关心请求是从纯 Dart 的HttpServer来的,还是从鸿蒙 Socket 桥接来的。

4.4 服务的优雅关闭

多地址服务还有一个很关键的点:关闭顺序。我在项目里踩过一个问题:直接调用server.close()后,底层 TCP 连接还在,客户端会一直转圈直到超时。后来我改成两步关闭:

  • 先停止接收新连接:server.close(force: false)
  • 再统一结束所有存活连接。

在鸿蒙侧还要注意:TcpServer.close()之后必须把connection事件监听置空,否则可能造成回调泄漏,Dart 侧的StreamSubscription也要记得cancel()

5. 实测中踩过的五个坑与完整排查过程

这部分是这次适配里最有价值的内容。有些问题你不到真实硬件上跑根本想象不到,文档里也查不到。

5.1 IPv6 和 IPv4 同时监听时端口被占用

现象:我先 bind 了 IPv4 的192.168.1.101:8080,再 bind IPv6 的fe80::xxx:8080,第二个 bind 直接报EADDRINUSE

排查链路:

  • 先怀疑是端口冲突,用命令行查端口占用,发现 8080 并没有被其他进程占用;
  • 再怀疑是鸿蒙 Socket 层不允许 IPv4 和 IPv6 使用相同端口,但官方文档里没有明确说明;
  • 后来换成只 bind 一个 IPv6 地址,发现端口依然被占用。

最终定位:在部分鸿蒙版本中,TcpServer.listen底层使用了一个统一的监听队列,当 IPv4 接口绑定端口后,同一端口的 IPv6 绑定需要设置特殊的地址复用标记。我绕过了这个问题,在 ArkTS 侧单独维护了一套配置,看到EADDRINUSE时会自动把失败地址记录下来,换下一个地址继续尝试,而不是让整个服务启动失败。

经验:在鸿蒙上做多地址监听,某个地址绑定失败不应该导致所有地址全部失败,需要逐地址容错。

5.2 热点环境下请求通但响应超时

现象:电脑连开发板热点后,浏览器能正常打开页面,但 POST 数据接口经常超时。

排查链路:

  • 先怀疑是数据处理线程被阻塞,看了日志发现 handler 执行时间也就几十毫秒;
  • 再用curl -v观察,发现请求确实到了设备,设备也返回了 200,但响应迟迟不到客户端;
  • 用抓包工具看,发现设备返回的 TCP 报文被分成了两个包,第二个包没有发出去。

最终定位:我在 ArkTS 侧解析 HTTP 请求后,响应数据是分段回写的,第一段成功、第二段因为 Socket 写缓冲被占满导致卡住。后来统一改成一次性构造完整响应字节流再write,问题消失。

5.3 中文路径和 Header 的编码问题

现象:请求路径包含中文文件名时,服务端解析出来是乱码。

原因:HTTP 请求行本身没有指定编码,Dart 侧默认按 UTF-8 解码,但鸿蒙侧原始字节流里中文路径可能是被客户端按照utf8编码的,而我在 ArkTS 侧用TextDecoder解码时指定了解码格式不一致。

解决办法:统一在两侧使用 UTF-8,并且对路径再做一次Uri.decodeComponent。Header 方面,注意响应头里的Content-Type一定要带charset=utf-8,否则部分 Windows 浏览器打开中文页面会乱码。

这里有个容易被忽略的点:HTTP Header 的值本质上是 Latin-1 编码,如果业务要在 Header 里塞中文(比如自定义设备名称),Dart 侧HttpHeaders会直接报错,必须先做编码转换或者塞到请求体里。

5.4 反复启停后请求被重复处理

现象:我的 Flutter 界面有一个“重启 HTTP 服务”的按钮,多按几次之后,同一个请求被 handler 处理了两遍甚至三遍。

排查链路:

  • 我以为是http_multi_server的 Stream 合并逻辑有重复订阅,看了源码没发现问题;
  • 加了日志后发现,每次点击重启按钮,都会创建一个新的 MethodChannel 调用,而 ArkTS 侧的上一个TcpServer实例没有被真正关闭。

最终定位是典型的StreamSubscription泄漏:Dart 侧对 MethodChannel 的返回值监听没有在关闭时取消,导致新服务启动时,旧服务的请求流还在被业务层接收。

修复方式是在服务关闭流程里加了一个状态机:

Future<void> stopServer() async { await _subscription?.cancel(); _subscription = null; await _channel.invokeMethod('stopServer'); _server = null; }

5.5 AP 隔离导致地址能 ping 通但端口访问不了

现象:在家里路由器网络下,手机能 ping 通开发板,但浏览器访问 8080 端口打不开。

这个坑和代码无关,属于网络环境。很多家用路由器默认开了“AP 隔离”或者“访客网络隔离”,设备之间二层互通但三层端口被防火墙拦了。我一开始折腾了半天鸿蒙侧代码,最后换了一个路由器网络测试才确认问题。

经验:遇到端口无法访问时,先用curl从另一台终端测,再用adb shell在设备本机curl 127.0.0.1:8080测,快速区分是“服务没起来”还是“网络隔离”。

6. 验证方法、性能基线与应用扩展

服务写完不是终点,能稳定跑在设备上才是。我把验证方法和扩展场景一起说说。

6.1 多地址验证清单

我每次改完桥接代码,都会跑一遍下面的验证清单:

检查项命令/方法预期结果
WiFi 地址访问curl http://192.168.1.101:8080/api/status返回 JSON
热点地址访问curl http://192.168.137.1:8080/api/status返回 JSON(若热点开启)
IPv6 链路本地访问curl -g http://[fe80::xxx%25wlan0]:8080/返回页面
本地回环访问curl http://127.0.0.1:8080/返回页面
大包传输curl -X POST -d @2MB.json http://192.168.1.101:8080/api/upload正常返回,无粘包乱序
并发请求ab -n 1000 -c 50 http://192.168.1.101:8080/无崩溃、无内存明显上涨
服务重启连续点击重启服务按钮 10 次请求只被处理一次,无泄漏

压测时我比较关注两个指标:一是长时间运行的内存曲线,二是连接关闭后文件描述符是否回落。在 Dayu200 上跑了一个 12 小时的长稳测试,内存占用稳定在 180MB 上下,没有明显泄漏。

6.2 一个值得尝试的扩展:局域网文件分享

http_multi_server在鸿蒙上跑通后,我的下一个想法是文件分享。OpenHarmony 设备本身可以外接 USB 存储或者 SD 卡,通过这个库开启一个 HTTP 文件服务,局域网内任何设备浏览器访问就能上传下载文件。实现上只需要增加两个路由:

// GET /files -> 文件列表 // POST /upload -> 接收文件

而且由于多地址监听的特性,不管是设备在 WiFi 网段、热点网段,还是通过 USB 共享网络,统一一套代码都能访问。这个能力对于没有屏幕的嵌入式设备调试特别有用,不需要装任何客户端,浏览器就是操作界面。

6.3 手机扫码配网场景

我还做了一个配网联动:设备热点启动后,同时启动一个http_multi_server实例监听热网段,手机连接设备热点自动弹出配网页,选择 WiFi SSID 后提交密码,设备收到后自动连接目标网络。由于监听地址同时覆盖热点和 WiFi,配网完成后页面会自动切到新的 WiFi 地址继续显示设备状态,整个过程非常顺滑。

这个场景特别能体现多地址监听的价值:配网前设备只能通过热点访问,配网后设备获得了新的 WiFi 地址,服务在两边都要在线,才能保证切换过程不中断。

我觉得这套“Dart 侧保持http_multi_server抽象 + 鸿蒙原生 Socket 桥接”的思路,除了 HTTP 服务,还能迁移到自定义 TCP 协议、WebSocket 服务等场景。核心经验就一条:不要试图让所有平台共享一套完美的底层实现,而是把平台差异全部收敛到一层薄薄的桥接背后,上层业务保持同一个入口和同一种心智模型。这样代码既能在 Android、桌面端跑,也能在 OpenHarmony 上稳定运行,后续哪怕鸿蒙 Flutter 适配好了dart:io,我也可以在不改上层代码的情况下把桥接实现直接替换掉。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 17:26:28

微信聊天记录导出指南:4步把对话存成3种格式到本地

微信聊天记录导出指南&#xff1a;4步把对话存成3种格式到本地 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trending/we/WeChatMs…

作者头像 李华
网站建设 2026/9/9 17:23:32

Video2X 使用指南:3步把480p视频放大到1080p并插帧

Video2X 使用指南&#xff1a;3步把480p视频放大到1080p并插帧 【免费下载链接】video2x A machine learning-based video super resolution and frame interpolation framework. Est. Hack the Valley II, 2018. 项目地址: https://gitcode.com/GitHub_Trending/vi/video2x …

作者头像 李华
网站建设 2026/9/9 17:23:11

n8n GitHub节点实战:从凭证配置到智能体自动操作仓库

做智能体做到一定阶段&#xff0c;你会发现一个共性难题&#xff1a;模型再聪明&#xff0c;也只能在对话里输出文字&#xff0c;没法真正去操作你手上的系统。尤其是开发类智能体&#xff0c;代码仓库、Issue、PR 这些天天要打交道的东西&#xff0c;如果 Agent 不能直接读写&…

作者头像 李华
网站建设 2026/9/9 17:22:15

系统综合管理软件深度解析:注册表清理与磁盘分析实战指南

电脑系统用久了&#xff0c;最常见的现象就是开机越来越慢、C 盘越来越满、更新补丁后频繁卡顿&#xff0c;再到浏览器被绑定、软件卸载后残留一堆无效文件。很多人第一反应是重装系统&#xff0c;实际上有一类工具专门解决这些问题&#xff0c;就是“系统综合管理软件”。这类…

作者头像 李华
网站建设 2026/9/9 17:21:39

湿法冶金短流程如何高效提取锑:CH-99萃取工艺与废水治理实践

干湿法冶炼这行的人都知道&#xff0c;锑是个不好伺候的金属。它表面上是脆性金属&#xff0c;工业上却撑起了阻燃、电池、催化剂一大片市场&#xff0c;国内又是全球最主要的资源国和出口国&#xff0c;所以被叫“战略金属”一点不过分。但真正干到生产端&#xff0c;问题全来…

作者头像 李华
网站建设 2026/9/9 17:20:57

OpenCore Legacy Patcher:三步给老Mac装上最新macOS

OpenCore Legacy Patcher&#xff1a;三步给老Mac装上最新macOS 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 2017 年以前的老 Mac 打不开最新 macOS 安装器…

作者头像 李华