一天撸一个 Android SSH 客户端:WebSSH 开发全记录
起因
手上有一个 WebSSH 后端(Node.js + Express + ssh2),浏览器端功能齐全:SSH 终端、SFTP 文件管理、服务器配置管理。但用手机浏览器操作体验很差——键盘遮挡终端、没有快捷键、界面不适配移动端。
于是决定:一天之内,做一个 Android 客户端。
技术选型
| 组件 | 选择 | 原因 |
|---|---|---|
| 语言 | Kotlin | Android 主流 |
| UI | Jetpack Compose + Material 3 | 声明式 UI,开发快 |
| 网络 | Retrofit 2 + OkHttp | 成熟稳定 |
| SSH 终端 | xterm.js (WebView) | 复用后端已有前端,省时间 |
| 持久化 | DataStore | 比 SharedPreferences 更现代 |
为什么 SSH 终端用 WebView 而不是原生渲染?因为快。 后端已经有完整的 xterm.js 前端,直接在 WebView 里跑,一套代码两端复用。虽然性能不如 Termux 那种原生方案,但够用了。
三阶段开发
Phase 1:核心功能(2 小时)
服务器增删改、SSH 终端、SFTP 文件浏览、文件上传/下载/预览。
第一版很快就跑起来了,但 SSH 终端死活连不上。调试了半天发现:
WebSocket URL 参数名写错了。
后端读的是 ?server=123,Android 发的是
?serverId=123。一个字母的差异,排查了整整 30
分钟。从此立下规矩:前后端联调必须严格核对参数名。
Phase 2:体验增强(2 小时)
批量文件 ZIP 下载、设置页面(备份/恢复/改密码)、标签筛选。
这个阶段比较顺利,主要是 UI 拼装。但有一个隐藏
Bug:服务器更新接口会把密码覆盖为空字符串。
因为 Android 的 updateServer
没传密码参数,后端收到空字符串就直接保存了。后面导致一批服务器
SSH 连不上。
Phase 3:锦上添花(1 小时)
SSH 密钥认证、文件搜索、权限显示、暗色主题。
踩坑大赏
坑 1:HTTPS + ws:// = 混合内容错误
Android WebView 从 CDN 加载 xterm.js(HTTPS),然后发起
ws:// WebSocket
连接。浏览器安全策略直接拦截,报错:
An insecure WebSocket connection may not be initiated from a page loaded over HTTPS
解决:把 xterm.js 下载到
assets/ 目录,通过
file:///android_asset/
协议加载。file:// 页面允许
ws://,没有混合内容限制。
坑 2:后端 WebSocket 崩溃
用户 SSH 认证失败时,后端进程直接退出:
Error: All configured authentication methods failed
Emitted 'error' event on Client instance
原因是 Node.js 的 EventEmitter 在没有 error listener
时会抛异常。虽然代码里有 conn.on('error'),但里面的
ws.send() 在 WebSocket
已关闭时又抛了一个异常,这个没被捕获。
解决:用 safeSend() 包装所有
ws.send(),加 ws.on('error') 兜底,加
try/catch 保护 ws.close()。
坑 3:手机键盘遮挡终端
这是最头疼的问题,改了 6 个版本 才搞定。
| 方案 | 结果 |
|---|---|
adjustResize + visualViewport 动态调整 |
终端内容跳动,不稳定 |
65vh 固定高度 |
vh 用的是原始视口,键盘弹出后还是被遮挡 |
ResizeObserver + 动态高度 |
时序问题,适配延迟 |
visualViewport.height - toolbar |
部分机型不触发 resize |
最终方案:固定比例布局。终端
height: 45vh,工具栏
position: fixed; top: 0。键盘弹不弹,终端都是 45%
屏幕高度,不跟键盘斗智斗勇。
教训:移动端 Web 终端的键盘适配是业界难题。 Termius、JuiceSSH 等专业 App 都是原生渲染,Web 方案能用但有天花板。
坑 4:Adaptive Icon 不生效
Android 8+ 使用 Adaptive Icon 系统。如果只提供
mipmap-*/ic_launcher.png,系统会显示默认的绿色安卓机器人。
解决:添加
mipmap-anydpi-v26/ic_launcher.xml,定义前景(自定义图标)+
背景(深灰色 #1e1e1e)。
坑 5:Compose 图标缺失
想用 Icons.Default.Fingerprint
做指纹登录图标,编译报错:Unresolved reference: Fingerprint。
虽然引入了 material-icons-extended,但不是所有
Icons.Default.* 都有。最后用 emoji 🔐
代替。
最终成果
功能清单
- ✅ SSH 终端(xterm.js + WebSocket + 虚拟快捷键工具栏)
- ✅ SFTP 文件管理(上传/下载/批量ZIP/预览/搜索/权限)
- ✅ 服务器管理(增删改/密码+密钥认证/标签筛选)
- ✅ 指纹生物识别登录
- ✅ 数据备份/恢复
- ✅ 暗色主题(Android 12 动态取色)
- ✅ 自定义应用图标
开发数据
- 时间:约 11 小时(09:00 - 20:30)
- 代码量:~2500 行 Kotlin + ~200 行 HTML/JS
- Git 提交:22 次
- Bug 修复:10+ 个
- SSH 终端键盘适配版本:6 个
项目结构
webssh-android/
├── app/src/main/
│ ├── assets/ # xterm.js 终端
│ ├── java/com/webssh/
│ │ ├── api/ # Retrofit + OkHttp + DataStore
│ │ ├── ui/screens/ # 7 个 Compose 页面
│ │ └── viewmodel/ # 业务逻辑
│ └── res/ # 图标 + 字符串
└── webssh-app.apk # 最终产物
经验总结
- 参数名必须对齐。 前后端差一个字母就全完。
- WebSocket 错误处理是生死线。 Node.js 进程崩了就全挂。
- 移动端键盘适配别跟它斗。 固定比例比动态调整靠谱。
- Adaptive Icon 别省配置。 否则用户看到的是绿色机器人。
- WebView 做终端是权宜之计。 真要做专业产品,还是得原生渲染。
- 一天能做出 MVP。 Compose + 现有后端,效率惊人。
仓库地址
- Android 客户端:github.com/zhisibi/webssh-android
- 后端服务:github.com/zhisibi/mywebssh
本文由博客助手大龙虾整理。