匍匐前进的三年
一名前端页面仔,用三年时间独自趟过 Electron、TCP 长连接、实时语音、蓝牙硬件和崩溃治理的深水区。这篇文章不是成功的经验,而是一个普通开发者匍匐前进的完整地图。
引言
这是一款硬件配套类桌面端 IM 应用,对标主流即时通讯产品,基于 Electron + Node 原生插件 + SQLite 从 0 到 1 完整搭建。它和普通 IM 最大的区别在于:系统复杂度来自“软硬件耦合”。
核心能力包括:
- 自研 TCP 长连接,实现双向实时通信体系
- 音频实时采集、流式传输、低延迟播放链路
- 蓝牙协议对接专属硬件,构建音频路由自适应系统(硬件/系统双通道切换)
- 基于蓝牙链路不断拓展音频相关业务场景
我独立负责:框架搭建、业务流程设计、底层通信、本地数据持久化、硬件适配全链路。这也是我从纯前端,真正转向桌面端业务架构设计的转型项目。
一、我只是一台“功能交付机”
在接手这个项目之前,我对「架构」的认知很模糊。觉得那是架构师才需要考虑的事,设计数据结构、规划体系,也默认是后端的职责。那时的我,本质上还是一个纯粹的业务前端——一切以页面实现、功能交付为首要目标。我想,这也是行业里对前端长期存在刻板偏见与差异化看待的原因之一。
最初只是顺手接了一个 IM 桌面端开发需求,以主力前端的身份慢慢啃下各类技术难点。早期思维完全局限在 React 渲染层,只关注页面交互与业务逻辑,并没有深入 Electron API 和 Node 与操作系统的交互。在开发过程中,我逐步补齐了 Electron 的相关能力,但整体仍然是“功能驱动开发”——先做出来再说。
为了系统性理解桌面端开发,我自己复刻了一套 Electron 框架,实现了一套代码四端部署的能力模型(Windows / macOS / Linux + Web)。带着这套“自建骨架”进入现在的公司,开始独立负责整个 Electron 项目,三年多持续演进。
这一阶段的我,莽撞且盲目自信,完全没有架构设计意识,更多是在用工程经验堆功能。架构究竟是什么?我需要学架构吗?它能为我的程序带来什么? 这是我从来没想过的问题。在项目反复出现的 bug 中,在自身开发过程中反复遇见的问题里,我开始审视自己。无论是计算机世界还是现实世界,抱怨和吐槽的爽点可能带来短期的解压,可要真正解决问题,得向内找原因。当你设计的程序足够稳定,bug 也可能是程序中无需修复的亮点。
我曾看到一篇文章讨论“前端需不需要架构”,大家各抒己见。当时我在这个项目中被搓磨了一年左右,心里很矛盾,就去问一位同样从事前端的朋友,他也觉得前端不需要“架构”。我其实特别理解——前端技术杂、变化快,拿工具快速拼出页面就能开发,在这个项目之前我也是这样认为的。但真正复杂的桌面端架构不是这样:它要考虑模型、边界、规则、适配器、约束、迭代、维护性、隔离崩溃等问题,要时刻保持全局观,考虑任何可能给程序带来的风险。我觉得架构就像管理——文件怎么组织、组件拆分、通信设计、制定规则、边界约束、安全意识,都需要明确的规定。
我觉得自己不是一个很聪明的人,对代码的钝感力太强,总感觉有一道无形的墙屏蔽着我,技术名词拽不起来,好像自己是这行的局外人。
面试中遇到过很多说着高大上名词的人,话还没听清,人就过了;而我总被判定为不合格,以至于觉得自己一直在小白的位置上打转。几次重复碰壁后,我下定决心复盘——发现自己缺少系统分层思维、复杂项目的全局推演能力、标准化设计的经验,以及桌面端 & 跨端 & 软硬件协同的体系知识和岗位匹配的底气。
但也正是这段没人带、却必须往前走的经历,让我真正从一个页面仔,长成了能扛住一整个桌面端项目的开发者。这里没有什么超级英雄,只有一个在项目里匍匐前进、慢慢成长的前端页面仔。这一路我陪着项目一起活下来:它崩溃我就崩溃,它稳定我就安心,我和它几乎融为一体。
这个阶段我明白了:抱怨没有用,向内找原因才是开始。
二、开荒期 — 业务层踩坑实录
- 基础技术选型
- SQLite 本地存储
- Electron 主进程
- umi 脚手架 → 渲染进程
- dva + proxy
- 请求拦截 + crypto-js 加解密 + 证书
- electron-builder + webpack 实现跨端打包
- IM 业务从零实现
- 登录 / 注册 / 忘记密码流程打通
- 好友、群组、新的朋友
- 聊天列表、消息发送、未读数、置顶、免打扰
- 自定义窗口、托盘、多窗口通信
- PDF 预览、表情、图片、视频发送
- 我犯的 5 个全局观错误
回看当初,我在业务层至少犯了5个全局性的错误,每一个都让我付出过至少一周的代价。
(1)没有架构意识:框架搭好后,直接按“功能驱动”上手开干,导致后期反复调整底层,牵一发而动全身。多次栽在同一个 bug 坑后,我意识到构建统一处理模型、集中管理,优势远大于零散修补。
(2)消息系统设计短视:最初没有考虑到消息功能的庞大与分支复杂度,导致消息越迭代越复杂、耦合度越高。最终代码洁癖逼我选择了痛苦又痛快的方式——重构掉它!重构时,我采用中介者模式集中管理消息收发,用适配器模式统一数据结构,再分发给不同消息类型的单例模式进行处理。
(3)数据库表设计混乱:接口返回什么字段就存什么,没有一次性把表结构和字段规划完整。明知要抽离公共的更新/插入逻辑,却只做了半截。对字段属性值的特殊性(空字符串、null、默认值)缺乏认知,导致数据库频频报错,甚至拖垮整个程序。
(4)组件复用性差:为了赶功能,组件没有及时拆分,到处复制粘贴,组件之间各自为王,没有统一的交互规范和通信约定,后期维护像在打地鼠。
(5)(隐含)没有数据层统一建模:这一点在后续的日报案例中会具体展开。 - 一次典型的“从创可贴到缝合线”案例
在过去,出现 bug 后,我因着急修改、推进项目,有时候根本不考虑它的波及范围,永远是有漏洞就拿创可贴贴上,没想过用缝线。
后来,我逐渐有意识地去分析:bug 的波及范围、预测同类问题组件、bug 的来源、是否影响其他组件……我开始问自己:如何构建模型?做数据过滤?统一字段?有没有拖底的容错?
下面是我日报中的一个真实案例:
【问题】
设置群成员功能,无法正常展示本地好友备注。
【排查过程】
根源:好友个人资料、群成员列表接口字段命名差异化严重,两套 JSON 字段无法通用,业务渲染处需要大量零散判断兼容。
项目初期未提前做数据建模与多源数据统一规划,缺少标准化数据层设计。
长期依赖页面硬编码多字段判断,治标不治本,导致同类 BUG 反复出现、分散在多个组件中。
原有缓存仅做原始数据存储,无多源合并、优先级规则。
【处理】
梳理数据来源:好友列表、群成员列表为两套独立接口,备注仅存储在本地好友数据源中。
重构本地缓存架构,新增统一标准用户数据模型,分层维护原始好友、群组缓存,搭建全局用户统一映射表。
多源合并:基于 userId 匹配,整合本地好友数据 + 群成员原始数据。
优先级规则:本地好友备注 > 好友原生昵称 > 群内成员昵称 > 兜底文案。
封装全局统一名称解析方法,全项目收口复用。
非好友边界兼容:无好友缓存时正常读取群昵称。
【当前状态】
标准化模型、分层缓存、合并逻辑已落地;全项目逐步替换旧逻辑。
【遗留/风险】
历史组件存在旧逻辑,短期新旧并行。
这个案例让我第一次尝到“架构层面解决问题”的甜头。 我不再贴创可贴,而是开始缝线。
三、深水区 — 通信与音视频的硬仗
1. TCP 实时通信踩坑实录
- 分包、合包、partNumber 错乱
初期没做固定长度的包头,直接裸流收发数据,半包、粘包、跨包问题频发。加上没有统一的 partNumber 校验逻辑,多段消息乱序、丢失,直接影响了消息的完整性。 - 解码乱码、数据丢失
没有提前约定编码格式和字节序,遇到中文、特殊字符时,很容易出现乱码、解析失败,甚至整条消息丢失。 - sessionId 大小端问题
JS 中 Number 的精度限制,加上服务端与客户端大小端不一致,导致 sessionId 在两端表现不同,会话校验失败、消息状态异常。 - 弱网、重连、心跳、网络状态管理
一开始没做心跳包,也没设计自动重连机制,网络一波动就断连,消息直接丢了;后来才加上心跳检测、断线重连、消息队列,网络状态管理才稳定下来。
2. 语音采集:从杂音到 320 字节对齐
项目中最核心也最磨人的,是语音数据的采集与传输。早期我只想着用队列实现 “先来先播”,却没有先把流程设计清楚,结果采集、传输、播放各自为政,UI 和数据流互相打架,杂音、回颤、卡顿问题层出不穷。
后来我才明白:只要严格按采样率、位深和时间片切割数据,统一格式和转换规则,很多杂音问题根本不会出现。
系统采集(麦克风)
使用 Web Audio API 中的 AudioWorklet 进行数据收集,结合订阅模式实时传输。
很长一段时间里,采集的数据传到对端的语音全是杂音。我一度怀疑是 PCM16 大小端转换问题,钻了很久死胡同。
最后发现,问题根本不在大小端,而是:数据包大小没有按时间片切割。
我的音频配置:
- 采样率 16000 Hz
- 位深 16 bit(PCM16)
- 声道:单声道
公式:
- 1 秒数据量 = 16000 * 2 = 32000 字节
- 10ms(0.01 秒)数据量 = 32000 * 0.01 = 320 字节
解决方式:
需要将采集的 Float32Array 切成每 320 字节一段,转为 Int16Array(PCM16)再发送,杂音问题直接解决。
硬件设备采集
设备传输过来的 OPUS 压缩数据,不同设备的原始包大小不同,先剥离前2字节的帧序号,再将剩余数据切割成 40 字节一个包,然后走和系统采集一样的发送流程,保证传输格式统一。
3. 语音播放:系统播放与硬件播放
系统播放
采用 AudioContext 将收到的二进制 PCM16 音频数据转成浏览器能播放的声音源。
踩坑主要在大小端转换——在这个项目上踩坑很大的原因就是没有接触过类型化数组,没有概念。
硬件播放
设备播放是这一节里最复杂、也是让我最头疼的部分,核心难点在于:蓝牙设备的缓冲状态是被动通知的,只能靠“溢出”触发,无法主动轮询,这就决定了我不能用简单的一问一答模型来控制发送节奏。
【流程逻辑】
1.准备阶段:BLE_SET_OPUS_PLAY先向设备下发播放准备命令,让设备进入播放就绪状态。
2.循环下发:BLE_SET_OPUS_DATA将 TCP 传输过来的 OPUS 数据拆分成 40 字节一个包,持续下发给设备。
3.状态触发:BLE_GET_OPUS_BUFF_STATE关键坑点就在这里:设备只有在数据溢出(状态 1)时才会主动通知,并不是每次下发都会返回状态。一旦收到 “缓冲区满” 的信号,就必须立刻暂停数据发送,避免设备阻塞。
4.恢复发送:空闲状态 0当设备处理完部分数据,缓冲区出现空闲(状态 0)时,再恢复队列中的剩余数据下发。
5.播放结束:BLE_OPUS_PLAY_END当没有更多数据需要发送时,下发播放结束命令。
我目前的实现方案是:将数据拆包后维护一个发送队列,先攒够一批数据再下发,收到设备 “缓冲区满” 的信号就暂停,收到 “空闲” 信号再恢复。坦白说,这是一个在设备 “被动通知” 限制下的妥协方案,我至今也不认为它是完美的,总觉得在队列调度和状态预判上还有优化空间,后续会持续调整,直到满意为止。
复盘:数据流图、缓冲模型、时序图的重要性
- 未建立设计模型概念,先写代码再想流程 → 功能各自为王,相互冲突,严重时程序崩溃,迭代时 bug 叠加。
- 不做缓冲模型 → 语音必崩。
- 不画时序图 → TCP 必乱。
这个阶段我明白了:在复杂通信面前,先画图再写码不是浪费时间,是节省生命。
四、崩溃与隔离 — 我学会了“先假设会崩”
1. Node 原生插件崩溃:一崩全死
- 直接 require 插件 → 主进程崩 → 程序退出
- 解决方案:fork 子进程托管,通过 stdio / IPC 通信隔离,并加入崩溃重启机制。
- 稳定率:从不可用 → 90%(目前仍有剩余崩溃问题,正在持续监测中)
2. 渲染进程健壮性改造
- 全局错误边界,局部崩溃不连坐
- 数据清洗、Schema 校验,不信任任何外部输入
- 断网自动重连、发送队列、失败重发
- 本地存储加密、防丢失
3. 复盘:凡是原生模块,先假设它会崩溃
没有隔离意识的代价
等所有渲染进程问题处理得七七八八后,发现语音崩溃复现率特别高。根本原因是:传输数据收尾时数据过大,一次性给服务端超过承载量,导致通信插件直接让主程序崩溃。
蓝牙崩溃率最高
重复断连接崩溃、伪连接崩溃、与发送语音数据相互影响等崩溃问题。
我开始评估每一个 Node 插件,将它们隔离处理,并增加崩溃自启功能,在各自的线程中去轮询,减少对线程的影响。但波及范围极广——等于重构了蓝牙和通信的主进程流程,进而影响渲染进程的处理。我好像在不断返工中不断摸索,不断被打败,又不断强迫自己站起来继续战斗。
没有容错设计,被接口牵着鼻子走
项目中我一度没有主观意志,接口参数怎么定义我就怎么定义。又因为数据库图省事,整出各种幺蛾子——整个项目中这个问题上报错最多。有时又因为过度设计和优化,导致错上加错。
这个阶段我明白了:先隔离,再编码。不信任任何外部模块,不信任任何输入。
五、硬件协同 — 状态机与命令队列
1. 蓝牙设备全流程开发
- 扫描、连接、断连、自连
- 全局状态管理
- 多设备连接架构
- 设备信息、电量、信号、模式切换
2. 设备功能深度实现
- 录音模式、TF 卡文件列表、播放、进度条
- 音乐模式、音量、均衡器
- 防丢报警、信号阈值、延时报警
- 设备快捷键、TTS 播报、语音播报联系人
3. 蓝牙高速通信重构:从 IPC 到 MessageChannelMain
向扫描到的蓝牙设备下发连接与控制信令时,原生 IPC 通信延迟过高,无法满足硬件实时交互要求。为解决通信卡顿、指令响应滞后问题,我对蓝牙消息通信链路整体重构,改用 MessageChannelMain 实现跨进程高速通信。整套旧通信方案完全清理重写,但因赶迭代节奏,模块抽离不够彻底,埋下少量技术债。好在本次拆分粒度更细,逻辑边界清晰,不会出现早期消息模块高度耦合的问题,为后续多设备扩展打下基础。
4. 复盘:硬件通信 = 线程模型 + 命令规范 + 状态机
- 协议不规范,对接全靠碎片化适配硬件侧没有完整统一的协议文档,指令命名、返回格式、错误码定义杂乱无章。各类控制指令零散无序,不同固件版本的设备行为差异极大。我只能一边调试一边临时兼容,大量判断逻辑散落业务中,没有统一解析层,维护负担持续加重。
- 连接状态混乱,断连、闪断、异常频发设备休眠、锁屏、后台挂起、距离波动都会触发莫名断连。早期没有全局连接管理,缺少心跳检测、自动重连、异常复位机制。一旦断开无法主动恢复,只能手动重连甚至重启应用,稳定性极差。
- 无状态机管控,多场景状态互相打架全局缺少统一蓝牙状态管理,未区分「扫描中、待连接、已连接、断开、异常」等标准状态。多个业务组件各自独立判断设备在线状态,并行操作、互相覆盖,频繁出现 UI 状态错乱、点击无效、操作冲突等问题。
- 指令无队列,并发冲突导致设备卡死同一时段并行下发音量调节、模式切换、录音控制、播报指令等多条硬件命令时,没有串行队列、超时限制与重试机制。指令乱序、丢失、互相覆盖,极易造成设备无响应、功能卡死、逻辑异常。
- 蓝牙与音频深度耦合,切换链路失控项目核心难点在于软硬件音频联动,硬件蓝牙通道与系统音频通道需要双向自适应切换。早期没有统一路由管理,切换时机无约束,频繁出现爆音、杂音、无声、声道错乱。蓝牙断开后音频无法自动回落系统通路,大量边缘场景难以覆盖。
- 扫描逻辑无约束,性能与体验双重问题初期蓝牙扫描没有节流、时效限制与结果去重,频繁扫描造成应用卡顿、资源占用过高。重复设备、无效设备大量堆积,UI 渲染臃肿,整体体验粗糙。
- 二进制透传、数据解析各类底层问题蓝牙透传二进制数据存在分包、粘包、数据截断问题,大小端、编码格式、数据长度规则没有提前约定。原始脏数据直接进入业务逻辑,缺少统一清洗、校验、容错,极易引发功能异常与页面报错。
- Electron 环境限制 + 原生插件不稳定桌面端原生蓝牙能力存在局限,项目重度依赖 Node 原生插件完成硬件通信。这类插件容错极低,一旦出现内存异常、底层报错,极易连带主进程卡顿甚至整体崩溃。前期没有进程隔离设计,单点故障直接拖垮整个应用。
- 缺少权限、前置校验与异常兜底未做蓝牙开关、系统权限、设备占用、系统策略限制等前置判断。异常场景无统一捕获、重置逻辑,出现问题只能依靠重启应用恢复,容错能力极其薄弱。
- 对单线程的影响:获取信号、网络轮询、设备不同模式的播放进度实时获取、信号报警阈值实时监听、点击事件等线程相互抢占,独立子进程后,在各自的进程内做轮询,合并轮询,禁止依赖一个线程处理。
六、我的原则 — 三年换来的三样东西
三个习惯
- 修任何 bug 先画影响范围图不画图不动手。
- 每周写一次“如果再来一次”设计文档哪怕只有半页纸,也要记录:如果重做这个模块,我会怎么设计。
- 桌面端三原则
- 先隔离,再编码:原生模块、不稳定依赖统统子进程隔离
- 先画流,再写码:复杂流程必须有时序图/状态机
- 先容错,再功能:假设所有输入都是恶意的,假设所有依赖都会崩溃
结尾
这三年里,我一直在一个没有标准答案的系统里补边界。我从来没真正“理解过架构”,但一直在被系统逼着理解架构。
直到后来我才明白:
所谓架构能力,不是你画过多少图,而是你是否意识到——复杂性必须被提前收敛,而不是事后补救。
当初无意入手的 Electron 框架带我走入了这家公司。我不知道随着 AI 的出现会不会让我这几年的努力白费,更不知道这个项目又能带我走向哪里。有时候技术的问题,答案却在江湖。
本是后山人,偶作前堂客,如果读者觉得不满意我的复盘总结,就当我臭显显能吧。