Android开发笔记[19]-手机离线运行VSCode代码编辑器
摘要
记录如何在不 root 的 Android 手机上完全离线运行 VS Code 代码编辑器:基于开源项目 Code FA,把 code-server(linux-arm64 版)、Ubuntu 24.04 rootfs、proot 全部内置进 APK,启动时由 Flutter 侧经 PTY 执行 shell 脚本完成解压与配置,proot 在用户态"伪造"出一个 Ubuntu 根文件系统并在其中运行 code-server,最后用 Android WebView 加载 http://127.0.0.1:20000 得到完整的 VS Code 界面。
工程仓库
- 本文所分析的实现: [https://github.com/nightmare-space/code_lfa](Code FA,Flutter 上层 + 原生 WebView)
- code-server: [https://github.com/coder/code-server]
- proot: [https://github.com/termux/proot]
- proot-distro: [https://github.com/termux/proot-distro]
- xterm.dart / flutter_pty: [https://github.com/TerminalStudio/xterm.dart] [https://github.com/TerminalStudio/flutter_pty]
关键信息
- 开发语言:Dart,Java(工程无 Kotlin 源码,
kotlin-android插件仅占位) - Flutter:3.24.4(CI 使用版本)
- Gradle:distributionUrl=https://services.gradle.org/distributions/gradle-8.4-bin.zip
- com.android.application:8.3.0 / org.jetbrains.kotlin.android:2.0.0
- jvmTarget = '17'
- minSdk 24
- targetSdk 35
- compileSdk = flutter.compileSdkVersion
- applicationId
com.nightmare.code - ABI:仅
arm64-v8a - code-server:4.103.1(linux-arm64,约 111MB)
- Ubuntu rootfs:
ubuntu-noble-aarch64-pd-v4.18.0.tar.xz(约 61MB) - proot-distro 脚本包:
proot-distro.zip(约 73KB) - 监听端口:20000,
auth: none
原理简介
为什么不能直接"跑 VS Code"
VS Code 桌面版是 Electron 应用(Chromium + Node.js + 原生模块),Android 上没有 Electron 运行时,也没有官方移植版。想在手机上用 VS Code 实际只有两条路:
- 远程方案:自己在电脑/NAS 上跑 code-server,手机浏览器访问——依赖网络与远端机器,不算"离线";
- 本地方案:把 code-server 整个塞进手机里跑,再用 WebView 当它的"显示终端"。
本方案(Code FA)属于第 2 条。它的本质一句话概括:VS Code 编辑器本体其实是一个 HTTP 服务,谁来显示它都行——手机上的 WebView 就是浏览器。
code-server 简介
[https://github.com/coder/code-server]
code-server 是 Coder 公司开源的"VS Code on the server":VS Code 的服务端(Web 版 UI + 扩展宿主 + 终端 + 文件系统访问)打包成一个 Node.js 程序,监听一个 HTTP 端口,任何浏览器访问它就是完整 VS Code。关键点:
- 官方直接提供
code-server-<版本>-linux-arm64.tar.gz预编译包,ARM 手机天然能跑(同架构无需模拟); - 通过
config.yaml配置监听地址与鉴权:
bind-addr: 0.0.0.0:20000
auth: none
password: none
cert: false
本方案里它被配置为无密码、明文 HTTP、端口 20000,因为它只作为本机 WebView 的后端使用(安全性见文末常见问题)。
proot 简介
[https://github.com/termux/proot]
Android 普通应用没有 root 权限,chroot() 需要 CAP_SYS_CHROOT,namespaces/cgroups 容器方案同样需要特权——所以"在手机里跑一个 Ubuntu"不能靠系统机制。proot 给出的思路是骗:
- proot 是一个普通用户进程,内部用
ptrace()系统调用拦截子进程的所有系统调用; - 子进程调用
chroot/stat/open时遇到绝对路径,proot 把路径改写成真实路径(rootfs 目录下的相对路径)再放行; - 于是子进程"以为"自己 root 在别的根目录里,实际只是路径被偷偷重写。
典型调用形态:
proot --rootfs=/path/to/ubuntu-rootfs \
--bind=/dev --bind=/proc --bind=/sys --change-id=0:0 \
--link2symlink --isolated \
/bin/bash -c "要执行的命令"
代价是每次路径解析都多一层拦截,IO 密集操作会变慢;收益是无需 root、无需任何特权,普通 APK 就能跑完整 Ubuntu 用户态。
proot-distro 简介
[https://github.com/termux/proot-distro]
proot-distro 是 Termux 生态下管理 proot 发行版的脚本工具:负责下载/校验 rootfs、解压到 $PREFIX/var/lib/proot-distro/installed-rootfs/<发行版>、生成 proot-distro login <发行版> 登录命令。本方案直接内置了它的脚本与 Ubuntu noble 的 rootfs(-pd 后缀即 proot-distro 格式),并绕过了它联网下载的安装命令,改为本地解压,从而实现全离线。
WebView + Flutter 混合架构简介
上层 UI(启动进度、隐藏终端)用 Flutter 写,VS Code 本体则必须是原生 android.webkit.WebView 才有足够控制力(JS 注入、@JavascriptInterface 桥接、localStorage 开关),两者通过 MethodChannel 通信。没有使用任何 Flutter webview 插件——因为最终要"把 Flutter 整个页面用 WebView 覆盖掉",这在原生 Fragment 层做最干净。
整体流程
启动 APK
└─ Flutter: TerminalPage(进度卡片 + 隐藏的 xterm 终端)
├─ 把 jniLibs 里的 libbash/libbusybox/libproot.so... 链接成 usr/bin 下的可执行文件
├─ flutter_pty 启动一个 bash(执行器)
├─ assets → 沙盒:proot-distro.zip / ubuntu rootfs / code-server tar.gz
├─ 解析 tar 硬链接表,生成修复脚本,写出 home/common.sh
├─ 向 bash 写入 "source common.sh; start_vs_code"
│ ├─ 安装 proot-distro(本地 zip)
│ ├─ 解压 Ubuntu rootfs + 切清华源 + 修 DNS
│ ├─ tar zxfh 解压 code-server 到 rootfs/opt + 修硬链接
│ ├─ 写 config.yaml(0.0.0.0:20000, auth: none)
│ └─ proot-distro login ubuntu -- /opt/code-server-.../bin/code-server ← 阻塞
├─ 监听 PTY 输出出现 "http://0.0.0.0:20000" → 判定启动成功
└─ MethodChannel "vscode_channel" → open_webview
└─ Fragment replace:Flutter 页面被 WebViewFragment 覆盖
└─ WebView.loadUrl("http://127.0.0.1:20000") → VS Code 界面
实现
核心代码
1. 把 ELF 伪装成 .so:绕过"数据目录禁止执行"
targetSdk > 28(即面向 Android 10+)之后,应用可写的主目录 /data/data/<pkg>/files 被挂为 noexec,文件不允许直接执行,但 nativeLibraryDir(APK 解压出的原生库目录)是放行的。于是把 bash/busybox/proot 等可执行文件当成动态库打进 jniLibs,运行时再建符号链接:
android/app/src/main/AndroidManifest.xml
<application
android:extractNativeLibs="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
...>
extractNativeLibs="true" 必须开——保证 so 是真实文件(而非压缩在 APK 内),才有路径可链。
android/app/src/main/jniLibs/arm64-v8a/ 下六个文件(全部 aarch64 ELF):
libbash.so libbusybox.so liblibtalloc.so.2.so
libloader.so libproot.so libsudo.so
lib/terminal_controller.dart(initEnvir,启动时执行)
Future<void> initEnvir() async {
List<String> androidFiles = ['libbash.so', 'libbusybox.so', 'liblibtalloc.so.2.so',
'libloader.so', 'libproot.so', 'libsudo.so'];
String libPath = await getLibPath(); // MethodChannel → applicationInfo.nativeLibraryDir
for (int i = 0; i < androidFiles.length; i++) {
// when android target sdk > 28
// cannot execute file in /data/data/com.xxx/files/usr/bin
// so we need create a link to /data/data/com.xxx/files/usr/bin
final sourcePath = '$libPath/${androidFiles[i]}';
String fileName = androidFiles[i].replaceAll(RegExp('^lib|\\.so\$'), ''); // libbash.so → bash
String filePath = '${RuntimeEnvir.binPath}/$fileName';
FileSystemEntityType type = await FileSystemEntity.type(filePath);
if (type != FileSystemEntityType.notFound && type != FileSystemEntityType.link) {
await File(filePath).delete(); // 清理旧版本残留的实体文件
}
Link link = Link(filePath);
if (link.existsSync()) link.deleteSync();
link.createSync(sourcePath); // usr/bin/bash → .../lib/arm64/libbash.so
}
}
配套还有一步 createBusyboxLink():给 busybox 建 awk、tar、curl、xz、grep 等约 30 个 applet 软链接(file 链到 /system/bin/file)。原因很具体——proot-distro.sh 启动前会自检这些命令是否 command -v 得到,缺一个就拒绝运行。
关键点:
libsudo.so只有 2 字节,是占位空文件(proot-distro 的依赖检查要求存在sudo命令名);- 总共六个 so 仅约 3.7MB,远小于整套 Termux 环境(该项目 1.5.0 版本曾移除内置 Termux 环境,省下约 26MB)。
2. 用 flutter_pty 起一个 bash 当"执行器"
后续所有安装动作都不是在 Dart 里 Process.run,而是往一个 PTY 里写命令字符串,好处是安装日志天然就是终端输出,可直接回显给用户、也可被 Dart 监听判断成败:
lib/utils.dart(createPTY)
Pty createPTY({String? shell, int rows = 25, int columns = 80}) {
Map<String, String> envir = Map.from(Platform.environment);
envir['HOME'] = RuntimeEnvir.homePath;
envir['TERMUX_PREFIX'] = RuntimeEnvir.usrPath; // proot-distro 安装脚本需要
envir['TERM'] = 'xterm-256color';
envir['PATH'] = RuntimeEnvir.path; // usr/bin : 系统 PATH
envir['PROOT_LOADER'] = '${RuntimeEnvir.binPath}/loader'; // libloader.so
envir['LD_LIBRARY_PATH'] = RuntimeEnvir.binPath; // 让 proot 找到 libtalloc.so.2
return Pty.start(
'${RuntimeEnvir.binPath}/${shell ?? 'bash'}', // 即 usr/bin/bash(符号链接)
arguments: [],
environment: envir,
workingDirectory: RuntimeEnvir.homePath,
rows: rows, columns: columns,
);
}
环境变量各司其职:LD_LIBRARY_PATH 是因为 proot 的 ELF 依赖 libtalloc.so.2,而 Android linker 不会去 app 私有目录找库;PROOT_LOADER 指向 libloader.so,是 proot 的引导器。
终端界面用 xterm.dart 渲染,release 包默认隐藏,点击屏幕可切换显示,用于排查卡住的问题:
lib/terminal_page.dart(节选)
bool visible = false || kDebugMode;
...
GestureDetector(
onTap: () { visible = !visible; setState(() {}); },
child: TerminalView(controller.terminal, readOnly: false, theme: ManjaroTerminalTheme()),
)
3. 资源落盘:APK assets → 应用沙盒
三个内置资源(pubspec.yaml 声明整个 assets/ 目录):
| 文件 | 大小 | 用途 |
|---|---|---|
ubuntu-noble-aarch64-pd-v4.18.0.tar.xz |
约 61MB | Ubuntu 24.04 rootfs |
code-server-4.103.1-linux-arm64.tar.gz |
约 111MB(Git LFS) | code-server 本体 |
proot-distro.zip |
约 73KB | proot-distro 安装脚本 |
lib/terminal_controller.dart(loadCodeServer 主流程,节选)
Future<void> loadCodeServer() async {
await loadCodeVersion(); // 读 /sdcard/code_version
Directory(RuntimeEnvir.tmpPath).createSync(recursive: true);
Directory(RuntimeEnvir.homePath).createSync(recursive: true);
Directory(RuntimeEnvir.binPath).createSync(recursive: true);
await initEnvir(); // 步骤 1:建 so 链接
pseudoTerminal = createPTY(...); // 步骤 2:起 bash
await AssetsUtils.copyAssetToPath('assets/proot-distro.zip',
'${RuntimeEnvir.homePath}/proot-distro.zip');
await AssetsUtils.copyAssetToPath('assets/${Config.ubuntuFileName}',
'${RuntimeEnvir.homePath}/${Config.ubuntuFileName}');
createBusyboxLink();
// code-server:内置包 或 /sdcard 上的自定义包(按文件大小增量拷贝)
String codeServerName = 'code-server-${Config.codeServerVersion}-linux-arm64.tar.gz';
String sourcePath = useCustomCodeServer ? '/sdcard/$codeServerName' : 'assets/$codeServerName';
...
Map<String, String> hardLinks = await getHardLinkMap(codeServerPath); // 步骤 5
...
vsCodeStartWhenSuccessBind(); // 步骤 7:监听启动成功
File('${RuntimeEnvir.homePath}/common.sh')
.writeAsStringSync('$commonScript\n$fixHardLinkShell');
startVsCode(pseudoTerminal!); // 向 PTY 写入启动命令
}
AssetsUtils.copyAssetToPath(来自 global_repository)会比对目标文件大小,已存在且一致则跳过——上百 MB 的资源只在首次启动时真正落盘。
4. shell 脚本完成安装与启动
Dart 把一段 shell 模板写成 home/common.sh,然后向 PTY 写两行字:
pseudoTerminal.writeString('source ${RuntimeEnvir.homePath}/common.sh\nstart_vs_code\n');
lib/script.dart(start_vs_code,五步全部幂等)
start_vs_code(){
install_proot_distro # 1. 安装 proot-distro 脚本
sleep 1; bump_progress
install_ubuntu # 2. 解压 Ubuntu rootfs + 切源 + 修 DNS
sleep 1; bump_progress
install_vs_code # 3. 解压 code-server 到 rootfs/opt + 修硬链接
sleep 1; bump_progress
gen_code_server_config # 4. 写 code-server 配置
sleep 1; bump_progress
login_ubuntu # 5. 进入 proot 启动 code-server(阻塞)
}
第 2 步是全离线的关键——不用 proot-distro install(它会联网下载校验 rootfs),而是本地直接解压:
lib/script.dart(install_ubuntu)
install_ubuntu(){
mkdir -p $UBUNTU_PATH 2>/dev/null
if [ -z "$(ls -A $UBUNTU_PATH)" ]; then
progress_echo "Ubuntu $L_NOT_INSTALLED, $L_INSTALLING..."
busybox tar xvf ~/$UBUNTU -C $UBUNTU_PATH/ | while read line; do
echo -ne "\033[2K\r$line"
done
mv $UBUNTU_PATH/$UBUNTU_NAME/* $UBUNTU_PATH/
rm -rf $UBUNTU_PATH/$UBUNTU_NAME
echo 'export PATH=/opt/code-server-$CSVERSION-linux-arm64/bin:$PATH' >> $UBUNTU_PATH/root/.bashrc
echo 'export ANDROID_DATA=/home/' >> $UBUNTU_PATH/root/.bashrc
else
VERSION=`cat $UBUNTU_PATH/etc/issue.net 2>/dev/null`
progress_echo "Ubuntu $L_INSTALLED -> $VERSION"
fi
change_ubuntu_source # 每次启动都切清华源
echo 'nameserver 8.8.8.8' > $UBUNTU_PATH/etc/resolv.conf # rootfs 里是 127.0.0.53(proot 内无效)
}
第 4 步生成的配置决定了"本机免密直连":
lib/script.dart(gen_code_server_config)
gen_code_server_config(){
mkdir -p $UBUNTU_PATH/root/.config/code-server 2>/dev/null
echo "
bind-addr: 0.0.0.0:$CSPORT
auth: none
password: none
cert: false
" > $UBUNTU_PATH/root/.config/code-server/config.yaml
}
5. code-server 的硬链接坑
code-server 官方 tar 包里有大量硬链接(native 模块的重复文件)。在 Android 文件系统上直接 tar -x,硬链接经常解丢、变成空文件,node 起服务时直接崩溃。项目做了双保险:
先动态解析 tar 里所有硬链接条目(key=链接路径,value=目标路径):
lib/utils.dart
Future<Map<String, String>> getHardLinkMap(String tarGzPath) async {
final result = <String, String>{};
final stream = File(tarGzPath).openRead().transform(gzip.decoder);
final reader = TarReader(stream);
while (await reader.moveNext()) {
final entry = reader.current;
if (entry.type == TypeFlag.link) {
final name = entry.header.name;
final target = entry.header.linkName ?? '';
if (name.isNotEmpty && target.isNotEmpty) {
result[name] = target;
}
}
}
return result;
}
再生成修复函数拼进 common.sh:
lib/script.dart
String genFixCodeServerHardLinkShell(Map<String, String> map) {
final buf = StringBuffer();
buf.writeln(r'fix_code_server_hard_link(){');
buf.writeln(r' cd $UBUNTU_PATH/opt');
map.forEach((key, value) {
buf.writeln(' cp $value $key'); // cp 一遍,硬链接退化成普通文件
});
buf.writeln('}');
return buf.toString();
}
解压时再加 -h 参数(解到硬链接就解引用复制一份):
install_vs_code(){
if [ ! -d "$UBUNTU_PATH/opt/code-server-$CSVERSION-linux-arm64" ];then
tar zxfh $TMPDIR/code-server-$CSVERSION-linux-arm64.tar.gz -C $UBUNTU_PATH/opt
fix_code_server_hard_link
fi
}
工程里还留了个一行调试脚本 scripts/check_hardlink.sh:tar tvf code-server-*.tar.gz | grep '^hr',用来核对包内硬链接条目。
6. proot 登录并启动 code-server
整个流程的终点是一行命令:
lib/script.dart(login_ubuntu)
login_ubuntu(){
bash $BIN/proot-distro login --bind /storage/emulated/0:/sdcard/ ubuntu --isolated \
-- /opt/code-server-$CSVERSION-linux-arm64/bin/code-server
}
proot-distro login 展开后大致等价于:
proot --rootfs=<沙盒>/usr/var/lib/proot-distro/installed-rootfs/ubuntu \
--cwd=/root --change-id=0:0 \
--bind=/dev --bind=/proc --bind=/sys --bind=<rootfs>/tmp:/dev/shm \
--link2symlink --sysvipc --kernel-release=6.2.1-PRoot-Distro \
/bin/bash -l -c " /opt/code-server-.../bin/code-server"
关键点:
--rootfs改写根目录,--bind把真机/dev、/proc、/sys和手机存储(/storage/emulated/0 → /sdcard/)映射进去,所以 Ubuntu 里能直接读写 SD 卡;--change-id=0:0让进程自认为 uid 0,实际仍是受限的普通 app 进程,受 SELinux 约束——这就是为什么必须显式--bind /sdcard;--isolated关掉 proot-distro 默认对 Termux home 的绑定,但自定义--bind仍生效;--link2symlink把硬链接语义模拟成软链接,规避不同文件系统差异;- 该命令阻塞当前 shell(code-server 是前台服务),符合预期。
7. 监听启动成功 → 切换到 WebView
Dart 侧监听同一个 PTY 的输出,看到监听地址即判定成功:
lib/terminal_controller.dart(vsCodeStartWhenSuccessBind,节选)
pseudoTerminal!.output.cast<List<int>>()
.transform(const Utf8Decoder(allowMalformed: true)).listen((event) async {
if (event.contains('http://0.0.0.0:${Config.port}')) { // code-server 打印的监听地址
if (!completer.isCompleted) completer.complete();
}
if (event.contains('already')) { // 端口已被占用 → 服务已在跑
if (!completer.isCompleted) completer.complete();
}
terminal.write(event); // 回显到 xterm 终端
});
await completer.future;
bumpProgress();
openWebView(); // MethodChannel 通知原生
lib/utils.dart
MethodChannel _channel = const MethodChannel('vscode_channel');
void openWebView() => _channel.invokeMethod('open_webview');
原生侧接收后用 WebView Fragment 整个替换掉 Flutter Fragment:
android/app/src/main/java/com/nightmare/code/MainActivity.java
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "vscode_channel")
.setMethodCallHandler((call, result) -> {
switch (call.method) {
case "open_webview": {
runOnUiThread(() -> {
fragmentManager.beginTransaction()
.replace(R.id.fl_container, new WebViewFragment()) // 覆盖 Flutter 页面
.commit();
result.success("success");
});
} break;
case "lib_path": {
result.success(mContext.getApplicationContext().getApplicationInfo().nativeLibraryDir);
} break;
default: result.notImplemented();
}
});
这也解释了工程结构上的一个选择:MainActivity 继承 FragmentActivity 并手动管理 FlutterEngine(缓存到 FlutterEngineCache),而不用默认的 FlutterActivity——否则没法做这种原地替换。
8. WebView 配置与剪贴板桥
android/app/src/main/java/com/nightmare/code/WebViewFragment.java(核心部分)
mWebView = new WebView(getContext());
WebSettings mWebSettings = mWebView.getSettings();
mWebSettings.setJavaScriptEnabled(true);
mWebSettings.setUseWideViewPort(true);
mWebSettings.setAllowFileAccess(true);
// 下面这行不写不得行
mWebSettings.setDomStorageEnabled(true); // VS Code 工作台状态依赖 localStorage
mWebSettings.setLoadWithOverviewMode(true);
mWebSettings.setDefaultTextEncodingName("utf-8");
mWebSettings.setSupportMultipleWindows(true);
mWebView.addJavascriptInterface(new JavaScriptBridge(getContext()), "Android");
mWebView.setWebViewClient(new WebViewClient() {
@Override
public void onPageFinished(WebView view, String url) {
// feat 剪切板内容获取的hook:覆写 navigator.clipboard.readText
String jsCode = "const originalReadText = navigator.clipboard.readText; " +
"navigator.clipboard.readText = function () { " +
"return Android.getClipboardData(); };";
view.evaluateJavascript(jsCode, null);
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
view.loadUrl(url); // 站内链接仍在同一 WebView 打开
return true;
}
});
mWebView.loadUrl("http://127.0.0.1:20000");
JS → 原生桥(同一文件内)
public static class JavaScriptBridge {
@JavascriptInterface
public String getClipboardData() {
ClipboardManager clipboard = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = clipboard.getPrimaryClip();
if (clip != null && clip.getItemCount() > 0) {
return clip.getItemAt(0).getText().toString();
}
return "";
}
}
Android 的剪贴板 API 无法在页面 JS 里直接调用,VS Code 又大量依赖 navigator.clipboard.readText,于是注入 JS 把它重定向到原生桥;@JavascriptInterface 暴露的 Android.getClipboardData() 读系统剪贴板返回。
其他细节:
usesCleartextTraffic="true"+ network security config:放行http://127.0.0.1明文请求;WebChromeClient.onCreateWindow里新建子 WebView,把window.open的外链改用Intent.ACTION_VIEW交给系统浏览器;- 监听系统"自动旋转"开关 +
OrientationListener,让编辑器跟随物理方向旋转; - 明文 HTTP 允许意味着同一局域网的其他设备也能连你的 20000 端口(因为
bind-addr: 0.0.0.0且auth: none),只在可信网络使用。
9. 进度条:Dart 和 shell 跨语言同步
安装流程一半在 Dart、一半在 shell 执行,进度靠两个普通文件同步(避免复杂的双向通信):
lib/script.dart(shell 侧)
bump_progress(){
current=0
[ -f "$TMPDIR/progress" ] && current=$(cat "$TMPDIR/progress" 2>/dev/null || echo 0)
printf "$((current + 1))" > "$TMPDIR/progress"
}
progress_echo(){
echo -e "\033[31m- $@\033[0m"
echo "$@" > "$TMPDIR/progress_des" # 当前步骤描述
}
lib/terminal_controller.dart(Dart 侧)
void bumpProgress() {
int current = 0;
if (progressFile.existsSync()) {
final content = progressFile.readAsStringSync().trim();
if (content.isNotEmpty) current = int.tryParse(content) ?? 0;
}
progressFile.writeAsStringSync('${current + 1}');
update();
}
void syncProgress() {
progressFile.watch(events: FileSystemEvent.all).listen((event) async {
if (event.type == FileSystemEvent.modify) {
String content = await progressFile.readAsString();
if (content.isEmpty) return;
progress = int.parse(content) / step; // step = 17
update(); // 驱动进度条动画
}
});
progressDesFile.watch(events: FileSystemEvent.all).listen((event) async {
if (event.type == FileSystemEvent.modify) {
currentProgress = await progressDesFile.readAsString();
update();
}
});
}
Dart 侧 File.watch 监听 modify 事件即拿到 shell 的每次 +1,界面进度条宽度就是 count / 17。
10. 更换 code-server 版本
版本号来自 /sdcard/code_version 文本文件,读到后与内置默认版本比对:不同则改用 /sdcard 上的包:
lib/terminal_controller.dart
Future<void> loadCodeVersion() async {
if (GetPlatform.isAndroid) {
PermissionStatus status = await Permission.manageExternalStorage.request(); // 读 /sdcard 需要
if (!status.isGranted) return;
}
File file = File('/sdcard/code_version');
if (!file.existsSync()) {
file.createSync();
file.writeAsStringSync(Config.defaultCodeServerVersion); // 写入默认版本号
}
if (file.existsSync()) Config.codeServerVersion = file.readAsStringSync(); // 注意:没有 trim()
if (Config.codeServerVersion.isEmpty) Config.codeServerVersion = Config.defaultCodeServerVersion;
}
bool get useCustomCodeServer => Config.codeServerVersion != Config.defaultCodeServerVersion;
使用方式:/sdcard/code_version 写版本号(不能有换行,因为没 trim()),对应 code-server-<版本>-linux-arm64.tar.gz 原样放到 /sdcard(不解压、不改名),启动即加载。首次之外还会按文件大小比对跳过重复拷贝。
构建
flutter pub get
dart run intl_utils:generate # 修改 lib/l10n 后生成文案
flutter build apk --release --split-per-abi \
--dart-define=VERSION=1.6.0 --dart-define=CSVERSION=4.103.1
注意三点:
android/key.properties保存签名信息,本地编译需自建(build.gradle里直接 load,没有该文件会报错);pubspec.yaml顶部有个自定义字段code_server: 4.103.1,只有 CI 读它——GitHub Actions 构建时会wget官方 code-server tar.gz 覆盖 assets 里的 Git LFS 指针文件;- 只有
jniLibs/arm64-v8a,所以只有 arm64 产物有实际意义(CI 也只发布*_arm64.apk)。
使用
- 安装 APK,同意隐私政策、授予存储权限;
- 等待进度条走完(首次需解压约 170MB 内置资源,较慢;后续启动很快,脚本全部幂等);
- 自动跳转 WebView 即 VS Code;启动页点击屏幕可显示/隐藏底层终端日志;
- 装依赖就是在 VS Code 内置终端里执行 Ubuntu 命令:
apt update && apt install python3(rootfs 每次启动自动切清华源、DNS 改 8.8.8.8)。
常见问题
Q: 为什么 APK 这么大?
A: 内置了约 61MB 的 Ubuntu rootfs + 约 111MB 的 code-server + proot 脚本,合计 170MB 以上。这些是首启必需的资源,做成"启动后联网下载"反而会破坏离线可用性,所以直接打包进 assets。
Q: 打开后白屏?
A: 多半是系统 WebView(Android System WebView)版本过旧,VS Code Web UI 用到了较新的 CSS/JS 特性。去应用商店升级 WebView 后重开;也可以直接用浏览器访问 http://127.0.0.1:20000 验证服务本身是否正常。
Q: VS Code 里"打开文件"选不到手机上的文件?
A: 该实现的 WebViewFragment 没有实现 WebChromeClient.onShowFileChooser,文件选择器不可用。改用侧边栏资源管理器输入路径,或直接操作已绑定进来的 /sdcard 目录。
Q: 换版本后没生效?
A: 检查 /sdcard/code_version 末尾是否带了换行(读取时没有 trim(),换行会让路径拼接错误),以及 tar.gz 文件名是否严格为 code-server-<版本号>-linux-arm64.tar.gz。
Q: 安全吗?局域网别人能连我的编辑器吗?
A: 默认 bind-addr: 0.0.0.0 且 auth: none——同一 Wi-Fi 下其他设备访问你的 20000 端口是无密码的。仅在可信网络使用,或自行修改 rootfs 内 root/.config/code-server/config.yaml 设置密码。
Q: 非 arm64 手机能用吗?
A: 不能。so 与 rootfs 都只有 aarch64 版本,x86/32 位设备既没有对应的 proot 二进制,架构不匹配时 proot-distro 虽能退回 QEMU 模拟,但本工程未提供。
Q: 全程不需要网络吗?
A: 运行 code-server 本身完全不需要网络(首次安装完成之后)。只有两处需要网:下载 APK,以及在 VS Code 终端里用 apt install 装依赖。
效果
| 启动进度页 | VS Code 界面 |
|---|---|
![]() |
![]() |
启动页为 Flutter 绘制的进度卡片(转圈 + 进度条 + 当前步骤文字),点击屏幕可切换到 xterm 终端观察解压/启动日志;code-server 就绪后整个页面被原生 WebView 覆盖,即完整的 VS Code 编辑器界面。

记录如何在不 root 的 Android 手机上**完全离线**运行 VS Code 代码编辑器:基于开源项目 Code FA,把 code-server(linux-arm64 版)、Ubuntu 24.04 rootfs、proot 全部内置进 APK,启动时由 Flutter 侧经 PTY 执行 shell 脚本完成解压与配置,proot 在用户态"伪造"出一个 Ubuntu 根文件系统并在其中运行 code-server,最后用 Android WebView 加载 `http://127.0.0.1:20000` 得到完整的 VS Code 界面。


浙公网安备 33010602011771号