AIGC标识 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 界面。

工程仓库

关键信息

  • 开发语言: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 实际只有两条路:

  1. 远程方案:自己在电脑/NAS 上跑 code-server,手机浏览器访问——依赖网络与远端机器,不算"离线";
  2. 本地方案:把 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)。

使用

  1. 安装 APK,同意隐私政策、授予存储权限;
  2. 等待进度条走完(首次需解压约 170MB 内置资源,较慢;后续启动很快,脚本全部幂等);
  3. 自动跳转 WebView 即 VS Code;启动页点击屏幕可显示/隐藏底层终端日志;
  4. 装依赖就是在 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 界面
Screenshot_20261006_105220 Screenshot_20261006_105238

启动页为 Flutter 绘制的进度卡片(转圈 + 进度条 + 当前步骤文字),点击屏幕可切换到 xterm 终端观察解压/启动日志;code-server 就绪后整个页面被原生 WebView 覆盖,即完整的 VS Code 编辑器界面。

posted @ 2026-10-06 10:55  qsBye  阅读(5)  评论(0)    收藏  举报