ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

macOS原生MUD客户端开发实战:从Telnet解析到ANSI渲染

2026/9/4 16:24:34 拓冰建站 浏览量
macOS原生MUD客户端开发实战:从Telnet解析到ANSI渲染 自从 Savitar macOS MUD 客户端放出第二版消息后不少擅长折腾终端游戏、喜欢 MUD 的老玩家又重新讨论起“到底什么样的客户端才算好用”。这款客户端的核心价值不在于把 Telnet 报文重新包装成漂亮界面而在于它让 macOS 用户真正拥有一个原生、可维护、能持续扩展的 MUD 连接工具。本文会以 Savitar 第二版发布为引子梳理 macOS 原生 MUD 客户端的架构思路、核心代码实现和工程化注意事项帮助你从零掌握这一类应用的开发方法。如果你正准备做一款 macOS 网络游戏客户端或者只是对 MUD 协议解析、ANSI 颜色渲染、断线重连这些细节感兴趣这篇内容都可以作为完整的实操参考。下面我们从概念开始逐步把一整套可运行的 Savitar 风格客户端代码拆开讲解。1. Savitar 与 MUD 客户端开发背景与核心概念1.1 什么是 MUD、MUD 客户端与 Savitar 的技术定位MUD 是 Multi-User Dungeon 的缩写是一种基于文本的多人实时网络游戏形态。玩家通过命令行输入移动、攻击、聊天、查看物品等指令服务器返回描述性文本和战斗结果。MUD 最早出现在上世纪 80 年代但直到今天国内外仍有大量 MUD 游戏服务器在运行社区玩家对新客户端的粘性也极高。MUD 客户端是玩家用来连接这些文本服务器的本地程序。它需要做三件事建立 TCP 连接通常是连接到服务器的 4000、6666、9000 等端口。将服务器发来的字节流按 Telnet 协议、ANSI 转义序列解析成可读文本。处理玩家的键盘输入并将整行命令发给服务器。Savitar 正是这样一款定位于 macOS 的原生客户端。和国内玩家更熟悉的基于 Web 的 MUSHClient、Windows 下的 MUD 客户端不同Savitar 强调原生体验原生窗口、原生快捷键、原生字体渲染、原生通知能力。第一版解决了“能连上、能玩”的问题第二版把重点放在协议扩展、配置文件组织、界面交互质量和会话稳定性上这些正好是 MUD 客户端从“玩具工具”走向“日常主力工具”的关键。做一个 macOS MUD 客户端并不只是写个 UITextView 往里塞字符串。你至少需要处理三类问题网络字节流的粘包与拆包。Telnet 协商指令和终端类型协商。文本中的 ANSI 颜色、光标控制、清屏指令。如果这三层处理得不严谨玩家看到的就会是乱码、断色、窗口闪烁和无法恢复的屏幕状态。1.2 为什么 macOS 上值得做原生 MUD 客户端很多开发者可能会问现在网页版 MUD 客户端已经很多了为什么还要单独做 macOS 原生应用答案隐藏在几个细节里原生客户端可以使用 SSH Keychain 保存服务器凭证避免每次手动输入账号密码。原生应用可以拦截系统通知当 MUD 服务器出现重要事件时发出本地通知。原生菜单栏、触摸板手势、系统级复制粘贴和自定义快捷键处理比网页方案更可靠。对中文 MUD 玩家来说字体渲染、IME 输入法集成尤其重要。Web 页面在某些输入法场景下会出现候选框异常原生 App 则没有这个问题。Savitar 第二版能够吸引 Hacker News 上的讨论本质上是因为它占据了“macOS 原生 MUD 客户端”这个细分位置。从技术学习角度看这也是一个非常合适的小型网络应用练手项目协议栈不算复杂UI 逻辑直观却覆盖了网络、解析、渲染、状态管理等多个层次。1.3 从本文中你能得到什么本文不会只围绕 Savitar 的具体源代码做逐行分析因为那需要拿到已经开源的仓库而是站在“复刻同类客户端”的前提下给你一套可以上手的实现方案macOS 上使用 Swift AppKit/SwiftUI 构建 MUD 客户端的基本工程结构。从 TCP 连接、Telnet 解析到 ANSI 渲染的完整流程代码。如何处理网络粘包、半包、断线重连以及常见坑位。MUD 客户端开发中的输入法、颜色、滚动、命令历史等工程细节。将 Savitar V2 这类产品化客户端投入日常使用时的最佳实践。你可以直接复制代码运行也可以把其中的网络层解析模块抽出来用在自己的 MUD 工具或其他 Telnet 应用中。2. 环境准备与版本说明2.1 macOS 开发环境开发 macOS 原生 MUD 客户端优先选择 Xcode。版本需要根据你的实际 macOS 系统调整。例如在较新的 macOS 系统上Xcode 15 及以上版本配合 Swift 5.9 能获得更好的 SwiftUI 支持如果你的系统版本较低也可以使用 Xcode 14 配合 AppKit 开发。本文以 Swift 5.9、macOS 13 为演示环境大部分代码在 macOS 12 上也能运行SDK 最低版本建议设置为 macOS 11.0。这样既能使用现代 Swift 并发特性也能兼容不少旧款 Intel Mac。2.2 不需要额外第三方依赖为了避免包管理和版本冲突本文网络层与 Telnet 解析层完全使用系统自带的 Foundation、Network 框架不依赖第三方库。这样做带来的额外好处是编译环境干净无需处理 CocoaPods 或 Swift Package Manager 依赖树。对网络通信有完全控制权更便于调试协议问题。二进制体积更小。如果你想在实际项目中使用类似 Savitar 的完整功能后续可以考虑引入 SwiftSoup 处理 HTML 帮助文本但核心 MUD 会话本身不需要。2.3 示例项目结构SavitarDemo/ ├── SavitarDemo.xcodeproj ├── SavitarDemo/ │ ├── App/ │ │ └── SavitarDemoApp.swift │ ├── Models/ │ │ ├── MUDConnection.swift │ │ └── MUDMessage.swift │ ├── Parsers/ │ │ ├── TelnetParser.swift │ │ ├── ANSIParser.swift │ │ └── MUDLine.swift │ ├── Views/ │ │ ├── ContentView.swift │ │ └── InputBarView.swift │ └── Resources/ │ └── Assets.xcassets └── SavitarDemoTests/ └── TelnetParserTests.swiftMUDConnection 负责网络生命周期TelnetParser 负责把字节流拆成消息ANSIParser 负责把原始文本转成带颜色的富文本MUDLine 作为 UI 展示的数据模型。下面我们按模块逐个实现。3. macOS 原生 MUD 客户端的核心模块拆解3.1 网络连接层使用 Network.framework 建立 TCP 连接macOS 上可选的网络 API 包括 BSD Socket、URLSession 与 Network.framework。对于 MUD 客户端这一类需要长连接、实时收发的应用Network.framework 是最合适的选择它原生支持 TCP、TLS、连接的自动迁移和断线状态回调。使用 Network.framework 的另一个好处是它会自动处理 IPv4 / IPv6 双栈连接不需要自己解析 getaddrinfo。连接一个 MUD 服务器时核心流程是创建 NWConnection指定 host 和 port。设置状态更新回调监听 .ready、.failed、.waiting、.cancelled 等状态。调用 start 开始连接。通过 send 发送玩家指令通过 receive 循环读取服务器返回的数据。需要特别注意Network.framework 的 receive 方法一次只读取一段数据而且回调里的 Data 往往不完整。因此必须在网络层之上维护一个可变缓冲区将多次读取到的数据拼接到一起再由解析器处理。3.2 Telnet 字节流解析协商指令和命令序列Telnet 协议在 MUD 客户端中依然占据重要地位。虽然很多现代 MUD 服务端开启了 GMCP 或 MSP 扩展但底层依然使用 Telnet 协议传输数据。Telnet 协议有四个关键字节IAC 255表示解释为命令WILL 251表示发送方愿意执行某个选项WONT 252表示发送方不愿意执行某个选项DO 253表示要求对方执行某个选项DONT 254表示要求对方不要执行某个选项当 MUD 服务器向客户端发送下面的字节序列时客户端必须正确回应255 253 24这表示服务器要求客户端支持终端类型选项24 表示 terminal type。如果客户端不做任何处理服务器可能会认为客户端支持未知能力导致后续回包异常。更常见的情况是服务器发送255 251 1这表示服务器愿意回显echo此时客户端如果不回应接收流中会混杂用户输入的字符回显造成“指令被打断”的奇怪现象。MUD 客户端常用做法是忽略大部分 Telnet 协商选项仅对必要的选项做协议级回应。比如收到 DO 时如果本端不支持回复 IAC WONT 选项号。一个严谨的 TelnetParser 至少要把命令层与数据层分开。3.3 ANSI 文本渲染与颜色解析MUD 服务器返回的文字并不是普通纯文本而是内嵌了 ANSI 转义序列。例如ESC[31m鲜红色的提示ESC[0m这里的 ESC 是 ASCII 码 27[ 是控制序列引导符31m 表示前景色为红色0m 表示重置所有属性。macOS 原生的 NSTextView 或 SwiftUI Text 不直接支持这种转义序列解析层要把它们转换为属性字符。ANSI 颜色解析的最简单方案是使用正则表达式扫描 \u001B\[([0-9;]*)m根据匹配到的数字决定当前文本属性然后直到下一个颜色码出现前文本都使用当前颜色。处理时还要支持 38;5;n 这样的 256 色序列以及 48 开头的背景色序列。3.4 输入层与命令历史文本界面客户端最重要的效率来源是命令历史、Tab 补全和别名。在 macOS 原生应用中输入框一般使用 NSTextField 或 TextEditor。为了让输入体验接近传统 MUD 客户端需要拦截键盘事件上下键切换历史记录、Tab 补全已知命令、CmdEnter 发送当前行。这部分逻辑看起来简单但开发中最容易踩的坑是输入法上下文。当用户正在用拼音输入中文时上下键用于候选词翻页此时不应被识别为历史命令切换。SwiftUI 中可以通过 FocusState 和 onKeyPress 精确判断AppKit 中则需要重写 NSTextField 的 keyDown 方法。3.5 会话配置与多服务器管理MUD 玩家通常同时混迹多个服务器Savitar 第二版中不少好评来自会话管理的改进。一个稳定的客户端配置结构需要保存服务器名称主机地址与端口终端类型编码格式GBK 或 UTF-8默认角色姓名是否启用 GMCP字体与字号配置文件采用 JSON 保存到 Application Support 目录即可。不要使用 UserDefaults 保存敏感信息。若需要记住密码可以调用 macOS Keychain 服务或者只保存账号不保存密码。4. 实战实现一款 Savitar 风格的基础 macOS MUD 客户端下面的代码基于 SwiftUI Network.framework 实现完整覆盖“连接 MUD 服务器并显示带颜色文本”的核心流程。为了便于阅读本文把代码拆成多个文件并在每个代码块前标注建议的保存路径。4.1 创建 Xcode 项目打开 Xcode选择 App 模板Product Name 填写 SavitarDemoInterface 选择 SwiftUILanguage 选择 Swift。这一步没有什么特别要说的只要保证 Deployment Target 设置成 macOS 11.0 以上。4.2 MUD 连接器网络层封装首先编写网络层。MUDConnection 是一个 ObservableObject 类负责建立 NWConnection、读取数据、发送指令并将收到的 Data 传递给解析器。这样 UI 层不需要关心网络缓冲和粘包问题。// 文件路径SavitarDemo/Models/MUDConnection.swift import Foundation import Network import Combine MainActor final class MUDConnection: ObservableObject { enum ConnectionState { case disconnected case connecting case connected case failed(String) } Published var state: ConnectionState .disconnected Published var receivedData Data() private var connection: NWConnection? private var buffer Data() private let parser TelnetParser() func connect(host: String, port: UInt16) { let tcpOptions NWProtocolTCP.Options() tcpOptions.noDelay true let parameters NWParameters(tls: nil, tcp: tcpOptions) parameters.allowLocalEndpointReuse true parameters.allowFastOpen true let endpoint NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!) let connection NWConnection(to: endpoint, using: parameters) self.connection connection connection.stateUpdateHandler { [weak self] newState in DispatchQueue.main.async { switch newState { case .ready: self?.state .connected self?.receiveNext() case .waiting(let error): self?.state .failed(等待网络\(error.localizedDescription)) case .failed(let error): self?.state .failed(连接失败\(error.localizedDescription)) case .cancelled: self?.state .disconnected default: self?.state .connecting } } } connection.start(queue: .global(qos: .userInitiated)) } private func receiveNext() { connection?.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, error in guard let self self else { return } if let data data, !data.isEmpty { DispatchQueue.main.async { self.parser.append(data) self.receivedData data } } if isComplete { self.disconnect() return } if error nil { self.receiveNext() } else { DispatchQueue.main.async { self.state .failed(读取失败\(error!.localizedDescription)) } } } } func send(line: String) { guard let connection connection, connection.state .ready else { return } var payload Data(line.utf8) payload.append(0x0D) // CR payload.append(0x0A) // LF connection.send(content: payload, completion: .contentProcessed { [weak self] error in if let error error { DispatchQueue.main.async { self?.state .failed(发送失败\(error.localizedDescription)) } } }) } func disconnect() { connection?.cancel() connection nil buffer.removeAll() state .disconnected } }这段代码说明几个关键点noDelay true 可以关闭 Nagle 算法降低小包延迟。receive 循环要不断地调用自己否则连接只会收到一次数据就停止。发送用户指令时需要补充 CRLF 结束符很多 MUD 服务端以\r\n作为命令结束标记。网络回调发生在私有队列必须切换到主线程更新 Published 状态。4.3 Telnet 解析器字节流到文本行网络层得到的数据不能直接用于 UI因为其中可能包含 IAC 命令、子协商段、转义序列。我们需要一个 TelnetParser 来过滤 Telnet 控制字节把真正的文本内容逐行送给上层。// 文件路径SavitarDemo/Parsers/TelnetParser.swift import Foundation final class TelnetParser { enum TelnetCommand: UInt8 { case iac 255 case will 251 case wont 252 case do 253 case dont 254 case sb 250 case se 240 } private var inputBuffer Data() private var outputLines: [String] [] /// 将网络数据加入内部缓冲区并通过闭包输出解析后的完整行 func append(_ data: Data, onLine: (String) - Void) { inputBuffer.append(data) var output Data() var index inputBuffer.startIndex while index inputBuffer.endIndex { let byte inputBuffer[index] // 一旦遇到 IAC进入 Telnet 命令解析 if byte TelnetCommand.iac.rawValue { // 把 IAC 之前的普通数据作为文本保留 if !output.isEmpty { pushText(output, onLine: onLine) output.removeAll() } index parseTelnetCommand(from: inputBuffer, at: index) } else { output.append(byte) index 1 } } // 缓冲区只保留未处理完的半行 inputBuffer.removeAll(keepingCapacity: true) } private func parseTelnetCommand(from data: Data, at start: Int) - Int { let bytes [UInt8](data) let count bytes.count var index start guard index count else { return index } let command bytes[index] // IAC 本身 index 1 guard index count else { return index } let verb bytes[index] index 1 guard index count else { return index } let option bytes[index] index 1 // 回应对端请求。这里采用最小响应策略。 switch verb { case TelnetCommand.do.rawValue: // 收到 DO表示服务端要求客户端开启某选项 // 大部分选项我们不支持回 WONT negotiateResponse(wont: option) case TelnetCommand.dont.rawValue: // 收到 DONT表示服务端禁止某选项无需处理 break case TelnetCommand.will.rawValue: // 收到 WILL表示服务端主动开启某选项 // 对回显选项可以接受 if option 1 { // ECHO negotiateResponse(do: option) } else { // 其他选项直接 DONT negotiateResponse(dont: option) } case TelnetCommand.wont.rawValue: break case TelnetCommand.sb.rawValue: // 子协商需要一直读取到 IAC SE 为止 while index count { if bytes[index] TelnetCommand.iac.rawValue, index 1 count, bytes[index 1] TelnetCommand.se.rawValue { index 2 break } index 1 } default: break } return index } private func pushText(_ data: Data, onLine: (String) - Void) { // 这一段是纯文本切出行并回调 var text String(decoding: data, as: UTF8.self) text text.replacingOccurrences(of: \r\n, with: \n) let lines text.components(separatedBy: \n) for line in lines { if !line.isEmpty { onLine(line) } } } private func negotiateResponse(wont option: UInt8) { sendTelnetCommand([TelnetCommand.iac.rawValue, TelnetCommand.wont.rawValue, option]) } private func negotiateResponse(dont option: UInt8) { sendTelnetCommand([TelnetCommand.iac.rawValue, TelnetCommand.dont.rawValue, option]) } private func negotiateResponse(do option: UInt8) { sendTelnetCommand([TelnetCommand.iac.rawValue, TelnetCommand.do.rawValue, option]) } private func sendTelnetCommand(_ bytes: [UInt8]) { // 这里为了保证示例精简先通过 Notification 发送出去。 // 实际项目中可以注入一个回调闭包直接调用 MUDConnection.sendData NotificationCenter.default.post(name: .telnetCommandShouldSend, object: Data(bytes)) } } extension Notification.Name { static let telnetCommandShouldSend Notification.Name(telnetCommandShouldSend) }代码中的 pushText 只是最简单的行切分没有保留半行状态。真实项目中如果一行文本分散在两个网络包里上述方案会丢掉前半段。更稳妥的做法是解析器内部维护一个 strings 累积遇到\n才输出完整行。你可以在附录的“优化方向”中看到完整思路这里先保留短小可运行的结构便于理解 Telnet 过滤逻辑。4.4 ANSI 解析器将文本行转换成带颜色片段为了让客户端正确显示红色状态、绿色收益等效果需要 ANSIParser 把颜色码转换成 AttributedString。// 文件路径SavitarDemo/Parsers/ANSIParser.swift import SwiftUI import Foundation struct MUDLine: Identifiable, Equatable { let id UUID() var attributedText: AttributedString var timestamp: Date } enum ANSIParser { static func parse(_ rawLine: String) - AttributedString { var result AttributedString() var currentAttributes AttributeContainer() currentAttributes.font Font.system(size: 13, design: .monospaced) // ANSI 转义序列正则ESC[...m let pattern #\u001B\[([0-9;]*)m# guard let regex try? NSRegularExpression(pattern: pattern) else { return AttributedString(rawLine) } let nsRange NSRange(rawLine.startIndex..rawLine.endIndex, in: rawLine) var plainText var plainRangeStart rawLine.startIndex regex.enumerateMatches(in: rawLine, range: nsRange) { match, _, _ in guard let match match, let matchRange Range(match.range, in: rawLine) else { return } // 收集转义序列前的纯文本 let textToAdd String(rawLine[plainRangeStart..matchRange.lowerBound]) if !textToAdd.isEmpty { result.append(AttributedString(textToAdd, attributes: currentAttributes)) } // 解析颜色参数 let codesString (rawLine as NSString).substring(with: match.range(at: 1)) updateAttributes(currentAttributes, codes: codesString) plainRangeStart matchRange.upperBound } let remaining String(rawLine[plainRangeStart..rawLine.endIndex]) if !remaining.isEmpty { result.append(AttributedString(remaining, attributes: currentAttributes)) } return result } private static func updateAttributes(_ attrs: inout AttributeContainer, codes: String) { let codeValues codes.split(separator: ;).compactMap { Int($0) } guard !codeValues.isEmpty else { // 没有参数时相当于 ESC[0m resetAttributes(attrs) return } for code in codeValues { switch code { case 0: resetAttributes(attrs) case 1: attrs.font Font.system(size: 13, weight: .bold, design: .monospaced) case 4: attrs.underlineStyle .single case 30...37: attrs.foregroundColor colorForForeground(code) case 40...47: attrs.backgroundColor colorForBackground(code) case 90...97: attrs.foregroundColor brightColorForForeground(code) default: // 忽略 256 色等扩展值避免过度复杂 break } } } private static func resetAttributes(_ attrs: inout AttributeContainer) { attrs AttributeContainer() attrs.font Font.system(size: 13, design: .monospaced) } private static func colorForForeground(_ code: Int) - Color { switch code { case 30: return .black case 31: return .red case 32: return .green case 33: return .yellow case 34: return .blue case 35: return .purple case 36: return .cyan default: return .primary } } private static func brightColorForForeground(_ code: Int) - Color { switch code { case 90: return .gray case 91: return Color(red: 1.0, green: 0.3, blue: 0.3) case 92: return Color(red: 0.3, green: 1.0, blue: 0.3) case 93: return Color(red: 1.0, green: 1.0, blue: 0.3) case 94: return Color(red: 0.3, green: 0.5, blue: 1.0) default: return .primary } } private static func colorForBackground(_ code: Int) - Color { switch code { case 40: return .black case 41: return .red case 42: return .green case 43: return .yellow case 44: return .blue case 45: return .purple case 46: return .cyan default: return .clear } } }上面的解析器已能处理常规 16 色文本满足大部分中文 MUD 显示需求。如果你的服务器输出 256 色或者 truecolor可以继续扩展 colorForForeground 方法从 codeValues 中读取下一个参数再映射。4.5 SwiftUI 主界面一个可用的 MUD 窗口接下来把以上各层组合成主窗口。界面分成两部分上方显示文本输出下方是输入栏。// 文件路径SavitarDemo/Views/ContentView.swift import SwiftUI import Combine struct ContentView: View { StateObject private var connection MUDConnection() State private var inputText State private var messages: [MUDLine] [] State private var host mud.somemud.com State private var port 4000 State private var connected false var body: some View { VStack(spacing: 0) { // 连接配置栏 HStack { TextField(服务器地址, text: $host) .textFieldStyle(.roundedBorder) .frame(width: 200) TextField(端口, text: $port) .textFieldStyle(.roundedBorder) .frame(width: 80) Button(connected ? 断开连接 : 连接服务器) { if connected { connection.disconnect() connected false } else { guard let p UInt16(port) else { return } connection.connect(host: host, port: p) connected true } } } .padding(8) Divider() // MUD 文本输出区 ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(messages) { message in Text(message.attributedText) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .id(message.id) } } .padding(10) } .background(Color.black.opacity(0.03)) .onReceive(connection.$receivedData.dropFirst()) { _ in // 每收到新数据就滚动到底部 if let last messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } Divider() // 输入行 HStack { TextField(输入指令..., text: $inputText) .textFieldStyle(.plain) .font(.system(size: 13, design: .monospaced)) .padding(8) .onSubmit { sendCurrentLine() } Button(发送) { sendCurrentLine() } .keyboardShortcut(.return, modifiers: [.command]) } .padding(8) } .onReceive(NotificationCenter.default.publisher(for: .telnetCommandShouldSend)) { note in guard let data note.object as? Data else { return } // 此处应调用 MUDConnection 的底层发送方法为简化先打印 print(Telnet 回应指令\(data.map { String(format: %02X, $0) }.joined(separator: ))) } .onReceive(connection.$receivedData.dropFirst()) { data in // 对接 TelnetParser 与 ANSIParser这里作为演示简化处理 // 真正的解析器回调应由 MUDConnection 内部完成。 if let text String(data: data, encoding: .utf8) { let parsed ANSIParser.parse(text) messages.append(MUDLine(attributedText: parsed, timestamp: Date())) } } } private func sendCurrentLine() { let line inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !line.isEmpty else { return } connection.send(line: line) inputText } }上面代码为了便于演示把网络数据的解析放在 onReceive 中。实际项目中应该让 MUDConnection 在 append 阶段就完成 Telnet 解析和分行每解析出一行就回调给 UI 层。你可以参考下一节的设计把 TelnetParser 的 onLine 闭包注入到 MUDConnection 内部。4.6 完整可运行的示例入口最后创建 App 入口文件// 文件路径SavitarDemo/App/SavitarDemoApp.swift import SwiftUI main struct SavitarDemoApp: App { var body: some Scene { WindowGroup { ContentView() } .windowStyle(.titleBar) .windowToolbarStyle(.unified) } }运行后输入 MUD 服务器地址和端口点击按钮即可开始连接。你可以在命令行用nc -l 4000模拟一个本地 MUD 服务端来测试也可以连接公开测试服务器。5. 典型报文处理与协议扩展实践5.1 粘包、半包与行缓冲MUD 服务器发送数据时可能一次发送多行文本也可能一行文本分多次发送。网络层绝不能直接把收到的 Data 当作整行来解析。一个稳健的做法是维护一个等待处理的数据缓冲区。每收到一段数据就追加到缓冲区。扫描缓冲区中的\n把换行符之前的内容作为完整行。保留最后一段不包含换行符的残片继续等待下个包。这样既不会拆错行也不会引发文本断色。上面 TelnetParser 中的 pushText 只能作为教学示例实际工程里要改成按换行符切分、保留残片、逐行回调的方式。5.2 Telnet 子协商的延伸场景中文 MUD 与部分欧美 MUD 服务器会通过 Telnet 子协商请求客户端支持终端类型、窗口大小、NAWS、GMCP 等能力。以 GMCP 为例握手流程一般是客户端收到 IAC WILL 201表示服务端支持 GMCP 扩展。客户端回复 IAC DO 201。客户端发送子协商数据内容为Core.Hello等 JSON 字符串。此后服务端会通过 GMCP 通道推送房间信息、角色状态等结构化数据。GMCP 报文使用IAC SB 201 json IAC SE格式。Savitar 这类现代客户端如果只做纯文本显示即使不支持 GMCP 也不会影响基础游戏但要做地图显示、状态面板、任务提醒就必须解析这些结构。如果你的服务器使用 GBK 编码显示中文客户端还要选择合适的字符编码。NSData 转 String 时不能一律用 UTF-8要在连接配置里允许用户选择编码。macOS 的 NSString 支持 CFStringConvertEncodingToNSStringEncoding 完成 GBK 转换这种方法比第三方库更轻量。5.3 自动重连与心跳检测MUD 服务器闲置超时是常见问题。如果客户端长时间不发指令服务器可能主动断开连接。工程化的 MUD 客户端需要实现心跳机制每隔一段时间发送空指令或 noop 指令维持连接不被服务端切断。自动重连检测到网络断开后间隔 5 秒尝试重连最多重试 N 次。历史保存断线后保留聊天记录避免用户丢失上下文。这些能力在 Savitar 第二版更新说明中通常都属于“稳定性改进”也是普通客户端和成熟客户端的分水岭。实际开发中可以把上面 MUDConnection.swift 中的 state 变化转发给一个 ConnectionMonitor集中处理心跳与自动重连。6. 常见问题与排查思路开发 macOS MUD 客户端过程中最容易遇到的问题集中在网络回调、文本编码和线程处理上。下面整理一份可参考的排查表问题现象常见原因解决思路连接后无任何显示服务器地址端口错误或 receive 未循环调用确认端口检查 receiveNext 是否在收到 data 后继续调用中文显示乱码服务器使用 GBK/GB2312 编码客户端使用 UTF-8 解码增加编码选择使用 GBK 解码颜色丢失或文字带ESC[31m没有执行 ANSI 转义序列解析检查 ANSIParser 是否正确匹配ESC[...m输入后字符重复显示服务端开启 echo客户端也本地回显Telnet 协商中拒绝 ECHO 或服务端回显开关不重复显示发送命令无响应没有发送 CRLF 结束符在 send 的 payload 末尾添加\r\n断线后 UI 未恢复状态回调没有切换到主线程使用 DispatchQueue.main.async 包裹 Published 更新上下滚动时卡顿大量 Text 视图直接加载可考虑使用 NSTextView 或限制消息最大行数点击按钮后窗口卡死网络解析大量数据阻塞主线程把解析器移到后台串行队列只把最终 String 回调到主线程这里最核心的是第一条 receive 循环。很多初学者会在 MUDConnection 中只写一次 receive收到第一段数据后没有再次调用 receiveNext导致后续内容全部无法显示。调试文本类客户端还有一个非常实用的方法用 macOS 自带的nc作为本地模拟服务端。你可以先运行nc -l 4000然后把上面客户端里的服务器地址改成127.0.0.1端口改成4000在 nc 窗口中手工输入任意文本观察客户端显示效果。这样可以在不连接公网 MUD 的情况下快速验证 Telnet 解析、编码和 UI 刷新。如果你需要模拟带 ANSI 颜色的服务器输出可以复制下面这条消息到 nc 窗口Sending test: ESC[32mGreen Message ESC[0m ESC[31mRed Message ESC[0m在 nc 中把 ESC 替换成实际转义字符即可先按住 Ctrl V再按 Esc然后输入[32m。7. 最佳实践与工程建议7.1 协议层与 UI 层彻底解耦你在阅读 Savitar 这类项目时会发现它很强调模块边界。协议解析不应该知道 SwiftUI 的 Text 是什么UI 不应该直接处理字节流。推荐的分层方式是网络层只负责建立连接、收发字节。TelnetProtocol 层把字节流转换成一个一个的 Telnet 数据帧。文本会话层把数据帧过滤为可读文本和结构化消息。UI 层将文本与结构化消息绑定到视图模型。这样的好处是便于单元测试。你可以纯内存地创建 TelnetParser喂入一段字节流不需要任何 UI 和真实网络环境即可验证解析是否正确。Savitar 第二版中大量稳定性问题通过这种分层方式修复。7.2 妥善保存账号信息MUD 客户端经常需要保存角色账号。但不要把明文密码写入 UserDefaults 或 JSON 文件。macOS 平台建议使用 Keychain账号名可以存储在 UserDefaults。密码使用 SecItemAdd 写入 Keychain。每次登录时通过 SecItemCopyMatching 读取。Keychain 能保证只有当前用户和已授权的应用能读取在系统备份和隐私保护上比普通文件可靠得多。如果你的 MUD 客户端有保存密码功能这属于必须的工程项。7.3 中文输入法与上下键冲突处理MUD 客户端中最影响中文用户体验的问题是输入法候选框与历史命令翻页冲突。当用户使用拼音输入“打开背包”时输入法候选窗口会捕获上下键来翻页。如果 NSTextField 的 keyDown 也同时处理上下键就会导致用户正在选择拼音候选词时命令历史被意外切换。处理策略通过 InputContext 判断当前是否有 marked text。如果有把方向键事件传给输入法。只有在 marked text 为空时才执行历史命令的上下键逻辑。Tab 补全也需要做类似判断。7.4 珍惜 App Sandbox 与硬性公证如果你的客户端准备发布到 Mac App Store 或分发给其他用户注意沙盒权限和公证要求访问网络需要开启 com.apple.security.network.client 权限。保存配置和日志需要使用 Application Support 容器目录。发布前用 codesign 签名并用 notarytool 进行公证否则 macOS 上会出现“无法验证开发者”的提示。很多独立开发者容易在一开始忽略公证等到别的电脑运行时才看到 Gatekeeper 拦截。在开发早期就把签名与公证脚本接入 CI能省下大量后期时间。7.5 消息显示区性能优化当玩家连续刷屏时SwiftUI 的 Text 可能会随着行数增加变得卡顿。一种常见做法是用可滚动的 NSTextView以 NSAttributedString 追加内容同时限制最大行数超过 2000 行后删除最早的行。这样能保证长时间战斗和挂机时滚动流畅。另一个细节是避免每收到一行就重建整棵视图树。你可以使用 OnChange 或 DeferredUpdates 控制刷新频率让 UI 在每帧最多刷新一次而不是对每个网络包即时刷新。7.6 自动化测试缓存库如果计划长期维护 Savitar 这类项目建议为 TelnetParser 和 ANSIParser 建立测试用例集。测试数据可以从真实服务器捕获另存为静态 fixture。这样每次改动解析规则时都可以通过回归测试确保旧功能不被破坏。// 文件路径SavitarDemoTests/TelnetParserTests.swift import XCTest testable import SavitarDemo final class TelnetParserTests: XCTestCase { func testFilterIACCommands() throws { let parser TelnetParser() var outLines: [String] [] // IAC DO 24 (终端类型协商) 后面紧跟普通文本 Hello\r\n let data Data([255, 253, 24]) Data(Hello\r\n.utf8) parser.append(data) { line in outLines.append(line) } XCTAssertEqual(outLines, [Hello]) } func testANSIStripping() throws { let input \u{001B}[32mGreen\u{001B}[0m let parsed ANSIParser.parse(input) XCTAssertTrue(parsed.description.contains(Green)) } }8. 从 Savitar V2 发布延伸开来的客户端工程演化思路每次看到 Savitar 这类项目的版本更新都能看到一个清晰的产品化过程第一版解决基础连接第二版开始打磨稳定性、可扩展性与交互细节。MUD 客户端虽然是一个小众工具但它包含的工程问题并不小。从字节流解析到富文本渲染从后台线程到用户输入法上下文几乎每一项都需要刻意设计。如果你也在做类似的小型桌面客户端建议把开发重心放在协议层稳定性和文本显示准确性上。很多视觉效果、手势操作、动画过渡本质上是在为一个项目增加边界内的复杂度而不是解决用户是否能顺利看到游戏输出的问题。反过来先把 Telnet 协商处理好把半包粘包截对把 ANSI 颜色恢复准确哪怕是界面极简用户也会因为“稳定不卡不花”而持续使用它。Savitar V2 的发布提醒了开发者一件事在大型商业游戏铺天盖地的今天仍然有一批 MUD 玩家追求最精简的文本交互体验。客户端工具的价值不止于功能堆叠更在于是否尊重文本协议本身的细节。希望本文的工程拆解能对你自己动手开发 macOS MUD 客户端或者改造 Savitar 代码有所帮助。看完了代码与排查方法最有效的一步是立刻找到一个公开测试 MUD 服务器把你自己写的客户端接上去跑一二十分钟然后修复遇到的每一个乱码、延迟和断线问题。