Android开发笔记[22]-开箱即用的xfce桌面及novnc访问
摘要
记录一个装上 APK、点开就是 Linux 桌面的离线 Android 应用 AlpineDesktop:把完整的 Alpine Linux 3.20.10 + XFCE 4.18 根文件系统(rootfs.bin,294,058,676 字节的 tar.gz,解出约 673MB)整个塞进 APK 的 assets,首次启动用零依赖的手写 tar.gz 解包器释放到应用私有目录;proot 直接采用 Termux 生态的预编译套件(libproot.so + 外置 libloader.so + libtalloc + busybox + bash,以 jniLibs 形式释放到 nativeLibraryDir 再用符号链接引导),以 proot-distro login 同款命令行(--sysvipc -L --link2symlink --root-id)进入 guest,在里面拉起 TigerVNC 1.13.1 的 Xvnc(RFB 无密码、仅回环 5900)→ XFCE 会话 → websockify 0.11.0/noVNC 1.4.0(6080 WebSocket),最后由系统 WebView 加载 http://127.0.0.1:6080/vnc_lite.html 显示桌面。全程不需要 root、不申请存储权限、运行期零联网,arm64 真机(华为 TAS-AN00,Android 12)实测清数据后约 1 分钟进入完整桌面。同时记录两个只在 Android app 进程域里才会现形的坑:绝不能设 PROOT_NO_SECCOMP(纯 ptrace 路径下 getcwd() 返回值被改写,musl/python 集体报 ENOSYS),以及 dbus/websockify 不能双重 fork 守护化(孙进程脱离 proot 跟踪)。
声明
本文人类为第一作者, 龙虾为通讯作者. 本文有AI生成内容.
工程仓库
- 工程链接:[https://github.com/qsbye/vectras-vm-zulu](分支
alpine-xfce-novnc,提交d9da82f;分支根目录即本工程,与main上的 QEMU 工程并存) - proot 引导方式参考自 code_lfa(一个用 Flutter 封装 proot-distro + Ubuntu + code-server 的应用,包名
com.nightmare.code),本工程把它 jniLibs 里的 termux/proot 整套二进制原样复用,guest 侧由 code-server 换成 XFCE + VNC - 致谢:Termux(proot / proot-distro)、Alpine Linux、XFCE、TigerVNC、noVNC
资源下载
Release 页面:[https://github.com/qsbye/vectras-vm-zulu/releases/tag/alpine-desktop-v1.0]
| 文件 | 实测大小 | 说明 |
|---|---|---|
| alpine-desktop-release-signed_20261006_235621.apk | 295,912,780 字节(约 282MB) | arm64-v8a 单 APK,Android 8.0+;debug 自签名(v2/v3),安装时允许"未知来源"即可 |
只下载这一个 APK 就能复现全部效果:rootfs 与 proot 套件都在包内,无需任何额外资源。仓库里的 rootfs.bin(约 280MB)走 Git LFS,git clone 后需 git lfs pull;只装 APK 的话不用管它。
关键信息
- 开发语言:Java(17),4 个类共 943 行:
MainActivity(174 行,启动屏 + WebView)、LinuxService(375 行,前台服务/引导/proot)、TarExtractor(340 行,零依赖 tar.gz 解包)、Status(54 行,进程级状态单例) - Gradle:
gradle-8.10.2-bin.zip;com.android.application8.5.1;compileSdk 34 / targetSdk 28 / minSdk 26;abiFilters 'arm64-v8a' - applicationId
com.qsbye.alpinedesktop,versionCode 1,versionName 1.0;android.useAndroidX=false(整个 app 零外部依赖) - 权限只有 3 个:
INTERNET(WebView 访问回环 http 也要)、FOREGROUND_SERVICE、WAKE_LOCK;不申请存储权限;usesCleartextTraffic="true"(回环明文 http)、extractNativeLibs="true" - 打包三要素:
androidResources { noCompress 'bin' }(rootfs 资产原样存储)、jniLibs.useLegacyPackaging true(.so 解压到 nativeLibraryDir)、rootfs 资产扩展名用.bin而非.gz(aapt 会对.gz做改名/解压特殊处理) - jniLibs(
app/src/main/jniLibs/arm64-v8a/,5 个文件,合计约 3.6MB,文件名不可改):
| 文件 | 字节数 | 用途 |
|---|---|---|
libproot.so |
214,536 | Termux 版 proot(含上游没有的 sendmsg/SCM_CREDENTIALS 等补丁) |
libloader.so |
5,608 | proot 的外置 loader,必须经 PROOT_LOADER 指定 |
liblibtalloc.so.2.so |
31,128 | proot 依赖的 talloc(SONAME 是 libtalloc.so.2,文件名对不上,要建符号链接) |
libbusybox.so |
1,498,688 | 宿主侧备用工具(sh/tar/cat/cp 等 applet) |
libbash.so |
2,026,008 | bash |
- assets 只有 1 个文件:
rootfs.bin= 294,058,676 字节(docker export | gzip -1),Git LFS 管理;解出后落在files/rootfs/,以.ready空文件作为"已释放"哨兵 - guest 实测版本:Alpine 3.20.10、xfce4-session 4.18.3、TigerVNC 1.13.1-r5、noVNC 1.4.0、websockify 0.11.0-r3、D-Bus 1.14.10、Python 3.12.13(websockify 依赖链带入,首启解压日志里能看到 numpy);中文字体
font-noto-cjk - 账户
qsbye/ 密码qsbye,sudo 免密;桌面进程实际以 proot fake-root(uid 0)运行,HOME=/home/qsbye - 端口:guest 内 5900 = Xvnc(
-SecurityTypes None -localhost,无密码、仅回环),6080 = websockify 转 WebSocket;二者都绑 127.0.0.1,外部应用不可达 - release 构建会因 targetSdk 28 被 lintVital 判
ExpiredTargetSdkVersion致命错误,工程里显式lint { abortOnError false; checkReleaseBuilds false };最终 APK 用~/.android/debug.keystore经 apksigner 后签(v2/v3 均通过)
原理简介
总体链路
和上一篇 QEMU 工程一样,普通 Android 应用没有 CAP_SYS_CHROOT,只能靠 proot 在用户态伪造一个 Linux 根。区别在于这次 guest 不是再套一层 QEMU 去跑 Windows,而是直接跑与手机同架构(arm64)的 Alpine 原生程序——没有指令集翻译,桌面是"原生速度"的用户态仿真:
MainActivity(全屏 WebView)
│ http://127.0.0.1:6080/vnc_lite.html?autoconnect=1&resize=remote
▼
websockify :6080(WebSocket ↔ TCP)──► Xvnc :5900(虚拟显示 :0)──► XFCE 会话
▲ ▲
└──────── termux/proot(外置 loader)───┘ rootfs = assets/rootfs.bin 解出的 Alpine 3.20
VNC 是帧缓冲 + 输入事件的远程桌面协议;noVNC 是它的浏览器客户端(HTML5 Canvas + WebSocket);websockify 是 WebSocket 到原生 TCP socket 的桥。Xvnc 则是 TigerVNC 提供的"带 VNC 输出的虚拟 X 服务器"——它不需要真实屏幕,自己就是 :0 号显示,XFCE 画给它的每一帧都能通过 RFB 协议取走。选这条链路而不是 Termux-X11,是因为显示端零原生代码:Android 侧只用系统自带 WebView,不需要内置任何 X 客户端库。
为什么必须是 termux 版 proot,而且 loader 要外置
最初这一版用的是自己编译的上游静态 proot 5.4.0,桌面大体能起,但 xfsettingsd 稳定弹 Unable to contact settings server / Error sending credentials: Operation not permitted。根因是上游 proot 缺少 Termux 携带的一批 Android 补丁,其中 sendmsg/SCM_CREDENTIALS(Unix socket 传递进程凭据)相关补丁直接影响 D-Bus 握手。后来还试过在 Alpine 容器里自己编译 termux/proot(musl 缺 struct rlimit64/statfs64/MSG_COPY/TEMP_FAILURE_RETRY,loader 的 -m32 也要特殊处理,打了一堆补丁勉强编过),结果自编译 loader 无法执行 Alpine 的 musl 动态 ELF——直接 exec guest 程序报 ENOENT。此路废弃,直接用 code_lfa 里现成的 Termux 预编译套件,问题全部消失。
Termux 的 proot 是动态链接的,运行时需要两样东西:同目录的 libtalloc.so.2,以及一个外置 loader(proot 经 PROOT_LOADER 环境变量找到它,由它把 proot 自身载入并处理 seccomp/ptrace 装配)。这三个文件在 APK 里必须以 lib*.so 命名——Android 只把这种文件解压到 nativeLibraryDir(该目录在 targetSdk 28 下仍可执行),应用自己数据目录里的二进制在 W^X 策略下不可执行。应用能做的是在 files/bin 下建符号链接指过去,这正是 Termux 和 proot-distro 生态的标准引导姿势。
rootfs:docker export 打出来的完整系统
rootfs 不是 Alpine minirootfs 再联网 apk add,而是直接用 alpine:3.20 镜像 apk add 好全部桌面组件后 docker create + docker export 导出的完整根文件系统。选 docker export 而不是在容器里 tar czf 是因为 busybox tar 处理 Alpine /bin -> usr/bin 合并目录有 bug,会丢掉大量 busybox applet;docker export 保留的是镜像层合并后的正确目录树。导出流经 gzip -1(最快档,压缩率换速度),294MB 的包解出约 673MB。
实现
1. 引导工具链:files/bin 符号链接 + PROOT_LOADER
首启释放完 rootfs 后,LinuxService.boot() 做的第一件事是在 files/bin 里把 jniLibs 五件套链成 proot 生态认识的名字(LinuxService.java:219):
private void setupToolBin(File binDir, String libDir) throws Exception {
linkTool(binDir, "proot", libDir + "/libproot.so");
linkTool(binDir, "loader", libDir + "/libloader.so");
linkTool(binDir, "libtalloc.so.2", libDir + "/liblibtalloc.so.2.so");
linkTool(binDir, "busybox", libDir + "/libbusybox.so");
linkTool(binDir, "bash", libDir + "/libbash.so");
File busybox = new File(binDir, "busybox");
for (String applet : BUSYBOX_APPLETS) {
linkTool(binDir, applet, busybox.getAbsolutePath());
}
}
BUSYBOX_APPLETS 是 sh/tar/cat/cp/rm/mkdir/ln/grep/sed/awk/head/tail/chmod/stat/realpath/id/uname 共 17 个宿主侧备用 applet。linkTool 无条件重建链接——断链符号链接 exists() 返回 false 但仍占路径,不先删 createSymbolicLink 会抛 FileAlreadyExistsException:
private void linkTool(File binDir, String name, String target) throws Exception {
File link = new File(binDir, name);
// 无条件重建(断链符号链接 exists()=false 但仍占着路径)
Files.deleteIfExists(link.toPath());
Files.createSymbolicLink(link.toPath(), new File(target).toPath());
}
libDir 不硬编码,运行时取 getApplicationInfo().nativeLibraryDir(重装后路径里的随机后缀会变)。
2. rootfs 首启释放:手写 tar.gz 解包器与 .bin 技巧
boot() 以 files/rootfs/.ready 为哨兵决定是否解压(LinuxService.java:90)。解压器 TarExtractor 零依赖(不用 commons-compress),直接 GZIPInputStream 流式读 ustar 头,支持常规文件、目录、符号链接、硬链接,并处理 GNU long name(L/K)和 pax 扩展头(x/g,从里面取 path/linkpath)——docker export 产出的 tar 里这两种长路径条目都会出现:
// GNU long name / long link
if (type == 'L' || type == 'K') { ... }
// pax 扩展头(含可能的 path/linkpath)——解析后跳过
if (type == 'x' || type == 'g') {
...
if ("path".equals(key)) { pendingName = val; }
else if ("linkpath".equals(key)) { pendingLink = val; }
}
符号链接直接 Files.createSymbolicLink,硬链接 Files.createLink(个别跨目录失败不致命),设备节点等其余类型跳过。条目路径还要防目录穿越(TarExtractor.java:196):
private static Path resolve(Path dest, String name) {
String clean = name;
while (clean.startsWith("./")) { clean = clean.substring(2); }
while (clean.startsWith("/")) { clean = clean.substring(1); }
Path p = dest.resolve(clean).normalize();
if (!p.startsWith(dest)) {
throw new SecurityException("非法归档路径: " + name);
}
return p;
}
进度按压缩字节流的已读/总量回调(assets.openFd().getLength() 取总长),每约 5MB 刷一行 [xx%] 文件名 到启动屏,所以首启能看到百分比和实时文件名。资产之所以叫 rootfs.bin 而不是 rootfs.tar.gz:aapt/aapt2 会识别 .gz 后缀并做特殊处理(改名或处理内容),改个无人认识的扩展名,再配 androidResources { noCompress 'bin' } 让它在 APK 内存储不压缩,运行时才能从正确的偏移流式 gunzip。
3. proot 命令行:对齐 proot-distro login
命令拼装在 LinuxService.java:126-156,参数与 proot-distro login 的调用一致:
cmd.add("--sysvipc"); // System V IPC 仿真
cmd.add("-L"); // 修复 lstat(symlink 大小)
cmd.add("--link2symlink"); // 用 symlink 模拟 hardlink
cmd.add("--root-id"); // guest 内 uid/gid 恒为 0
cmd.add("--kill-on-exit");
cmd.add("--rootfs=" + rootfs.getAbsolutePath());
cmd.add("--cwd=/root");
cmd.add("--bind=/dev");
cmd.add("--bind=/dev/urandom:/dev/random");
cmd.add("--bind=/proc");
cmd.add("--bind=/proc/self/fd:/dev/fd");
// Android 上这些 /proc、/sys 节点不可读/不存在,用 rootfs 内假文件覆盖
cmd.add("--bind=" + new File(rootfs, "proc/.loadavg") + ":/proc/loadavg");
cmd.add("--bind=" + new File(rootfs, "proc/.stat") + ":/proc/stat");
cmd.add("--bind=" + new File(rootfs, "proc/.uptime") + ":/proc/uptime");
cmd.add("--bind=" + new File(rootfs, "proc/.version")+ ":/proc/version");
cmd.add("--bind=" + new File(rootfs, "sys/.empty") + ":/sys/fs/selinux");
cmd.add("--bind=" + tmpDir.getAbsolutePath() + ":/tmp");
假 /proc 文件由 setupFakeSysData() 在释放阶段写好(loadavg/stat/uptime/version 的固定模板),做法与 proot-distro 的 setup_fake_sysdata 相同——桌面组件读这些节点失败会刷错误对话框。/tmp 绑到 files/tmp(宿主侧建好 1777),guest 所有日志都落在这里。
宿主机侧环境先 pb.environment().clear() 再白名单设置,杜绝 Android 变量泄漏:
pb.environment().put("PATH", binDir.getAbsolutePath() + ":/system/bin");
pb.environment().put("PROOT_LOADER", new File(binDir, "loader").getAbsolutePath());
pb.environment().put("LD_LIBRARY_PATH", binDir.getAbsolutePath());
pb.environment().put("TMPDIR", tmpDir.getAbsolutePath());
pb.environment().put("PROOT_TMP_DIR", tmpDir.getAbsolutePath());
guest 入口则用 /usr/bin/env -i 再清一次,只放 HOME=/home/qsbye、固定 guest PATH、LANG=C.UTF-8、TERM=xterm-256color、TMPDIR=/tmp 和按屏幕实参生成的 GEOMETRY=<宽>x<高>(WindowManager.getRealSize),执行 /usr/local/bin/desktop-start.sh。
4. 两个只在 app 进程域现形的坑(全篇最关键)
坑一:不能设 PROOT_NO_SECCOMP。 Termux 版 proot 默认自动探测并走 seccomp 加速路径;很多老资料(以及上一版代码)出于"Android 内核 seccomp 必失败"的印象会加 PROOT_NO_SECCOMP=1 强制纯 ptrace。在 zygote 派生的 app 进程里,纯 ptrace 路径出现了确定性的返回值改写错误:guest 内裸 getcwd 系统调用缓冲区明明已被正确写入 /root,proot 却把返回值置成 -1(errno 还是 0);musl 只认返回长度 0/正值,于是 python、websockify 集体报 OSError: [Errno 38] Function not implemented,os.getgroups() 还会泄漏真实 Android 组(3003/9997 这类)而非 root 的 0。删掉这个环境变量、走默认 seccomp 路径后全部消失。
坑二:双重 fork 的守护进程会"跑出"proot。 桌面最初按常规做法用 dbus-launch 起总线、websockify -D 守护化。在 run-as 的 shell 域里一切正常,集成进 APK 后却必挂:dbus-daemon fork 出的孙进程 chdir/getgroups 直通 Android 宿主,报 Could not change to root directory: Function not implemented、Failed to get groups ... Function not implemented,最后 Memory allocation failure in message bus 自杀。app 进程域内 proot 无法可靠跟踪 daemon 双重 fork 出的孙进程。修法是所有守护进程一律前台运行再由 shell 放后台(见下节),不允许任何组件自己双 fork。
5. guest 入口:desktop-start.sh
rootfs-build/desktop-start.sh(共 70 行)就是 guest 的 /usr/local/bin/desktop-start.sh。开头固定环境与运行时目录(XDG_RUNTIME_DIR 必须 0700,/tmp/.X11-unix 必须 1777)。D-Bus 两条总线都 --nofork,会话总线用 --print-address=1 把地址写到文件,shell 轮询读出来导出——这是替代 dbus-launch 的关键:
dbus-daemon --system --nofork --nopidfile \
>/tmp/dbus-system.log 2>&1 &
dbus-daemon --session --nofork --nopidfile --print-address=1 \
>/tmp/.dbus-session-addr 2>/tmp/dbus-session.log &
i=0
while [ ! -s /tmp/.dbus-session-addr ] && [ $i -lt 100 ]; do
i=$((i + 1)); sleep 0.1
done
export DBUS_SESSION_BUS_ADDRESS="$(cat /tmp/.dbus-session-addr 2>/dev/null)"
Xvnc 以 root 身份跑(-nolock 要求 uid 0),RFB 无密码且只听回环,-ac 关 X 授权、-nolock 绕开 proot 下 lock 文件 link() 失败:
Xvnc :0 -geometry "${GEOMETRY}" -depth 24 -rfbport 5900 -SecurityTypes None \
-localhost -AlwaysShared -nolock -ac -desktop Alpine-XFCE \
-AcceptKeyEvents -AcceptPointerEvents -AcceptCutText -SendCutText \
> /tmp/vnc.log 2>&1 &
之后轮询 /tmp/.X11-unix/X0(最多 10 秒)确认 X 起来,再起 startxfce4 和 websockify。websockify 同样不能用 -D,普通 & 后台即可;最后 exec sleep infinity 让 proot 的 1 号进程常驻,--kill-on-exit 会在它结束时收掉整棵 guest 进程树:
websockify --web=/usr/share/novnc 6080 127.0.0.1:5900 > /tmp/websockify.log 2>&1 &
echo "=== Alpine XFCE desktop ready on 127.0.0.1:6080 ==="
exec sleep infinity
6. WebView 轮询接入
MainActivity 每 600ms 轮询一次 Status.stage(EXTRACTING/STARTING/RUNNING/...),RUNNING 后切到全屏 WebView。桌面 URL 带自动重连和远端自适应分辨率(MainActivity.java:26):
private static final String DESKTOP_URL =
"http://127.0.0.1:6080/vnc_lite.html?autoconnect=1&resize=remote"
+ "&reconnect=1&reconnect_delay=2000&show_dot=true";
RUNNING 的判据不是读 guest 日志里的 "ready",而是 LinuxService.waitForPort() 真去连 127.0.0.1:6080(150 秒超时,800ms 连接超时 + 700ms 间隔轮询),端口 accept 才置 RUNNING——比上一篇 QEMU 工程里"起服务后死等 2 秒再开画面"的时序凑合可靠得多。WebView 开了 JS/DOM storage,主帧加载失败只 Toast 提示"正在重连"(noVNC 自身也带 reconnect);返回键双击退到后台,Activity 真正销毁时 stopService,proot 整树退出。
7. rootfs 构建
rootfs-build/Dockerfile(24 行)基于 alpine:3.20,一条 apk add 装齐桌面与远程访问组件,并建好 qsbye 账户:
RUN apk add --no-cache \
xfce4 xfce4-terminal xfce4-screenshooter mousepad \
dbus dbus-x11 sudo coreutils procps \
tigervnc novnc websockify \
xkeyboard-config xkbcomp xauth \
ttf-dejavu font-noto font-noto-cjk adwaita-icon-theme
RUN adduser -D -s /bin/sh qsbye \
&& echo 'qsbye:qsbye' | chpasswd \
&& echo 'qsbye ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qsbye
COPY desktop-start.sh /usr/local/bin/desktop-start.sh
scripts/build-rootfs.sh(19 行)负责构建镜像并导出资产,关键就一行:
cid=$(docker create alpine-xfce-rootfs:latest)
docker export "$cid" | gzip -1 > "$ASSETS/rootfs.bin"
构建与测试
# 1)(可选)重建 rootfs:需要本机 Docker 为 arm64
./scripts/build-rootfs.sh # → app/src/main/assets/rootfs.bin,约 280MB
# 2) 构建 APK(macOS 显式指定 JDK 17)
JAVA_HOME=/path/to/jbr-17 ./gradlew :app:assembleDebug
# → app/build/outputs/apk/debug/app-debug.apk(约 571MB;APK 内 rootfs 存储不压缩)
工程无单元测试,验证方式就是真机装包:清数据 → 启动看解压进度 → 约 1 分钟内 WebView 进入 XFCE → 检查 guest 内 Xvnc/xfce4-session/xfwm4/xfce4-panel/xfdesktop/xfsettingsd/Thunar/dbus-daemon/websockify 进程齐全、/tmp/*.log 无 Function not implemented 与 credentials 报错。Release 包的构建差异只有两点:lint 已在 app/build.gradle 关闭(targetSdk 28 是有意保留),产物用 build-tools 的 zipalign -p 4 + apksigner(debug keystore,v2/v3)后签。
复刻时三个容易翻车的点:
- jniLibs 五个文件名一个都不能改,缺
PROOT_LOADER或libtalloc.so.2链接名对不上时 proot 直接 exec 失败;files/bin的链接要在每次启动时无条件重建; rootfs.bin必须存储不压缩(.bin扩展名 +noCompress 'bin'),否则运行时 gunzip 读到的是被 aapt 处理过的数据;- 不要自作主张加
PROOT_NO_SECCOMP=1,也不要把 dbus/websockify 改回dbus-launch/-D守护化——这两条在普通 Linux/run-as shell 里都"看起来正常",只在 app 进程域翻车。
使用
- 安装 APK(约 282MB,arm64,Android 8.0+),无任何权限弹窗(仅有联网/前台服务/唤醒锁三个普通权限);
- 打开应用:启动屏显示"正在释放系统文件 NN%"和实时文件日志,实测华为 TAS-AN00(Android 12)约 1 分钟内完成;
- 自动进入全屏 XFCE:顶栏 Applications、桌面 File System/Home 图标、鼠标壁纸均可正常使用;双指/单指操作由 noVNC 映射为鼠标,右上角有 "Send CtrlAltDel" 按钮;
- 终端里
su - qsbye或login可切到 qsbye 账户(密码qsbye,sudo 免密);桌面会话本身以 fake-root 跑、HOME=/home/qsbye; - 第二次启动秒进(
.ready哨兵在就跳过解压);双击返回键退后台,前台服务+24 小时 wakelock 保活,划掉应用则 guest 整树退出。
常见问题
Q: 启动日志出现大量 Function not implemented,python/websockify 起不来?
A: 检查宿主环境里是否被设置了 PROOT_NO_SECCOMP。这个变量在本方案里必须不设:termux/proot 在 app 进程中走纯 ptrace 路径会错误改写 getcwd() 返回值,musl 据此抛 ENOSYS。保持默认(自动探测 seccomp)即可。
Q: dbus 报 Memory allocation failure in message bus,或日志有 Could not change to root directory: Function not implemented?
A: 这是守护进程双重 fork 的孙进程脱离了 proot 跟踪。不要用 dbus-launch/dbus-daemon --fork,也不要 websockify -D;照 desktop-start.sh 用 dbus-daemon --nofork --print-address=1 ... &、websockify 普通 & 后台。dbus 日志里单独出现 Failed to set fd limit to 65536: Operation not permitted 是 setrlimit 被拒的无害提示,不影响使用。
Q: 为什么不直接用上游官方 proot?
A: 上游 proot 缺 Termux 的 sendmsg/SCM_CREDENTIALS 补丁,xfsettingsd 会报 Unable to contact settings server / Error sending credentials: Operation not permitted,桌面设置服务起不来。自己在 Alpine/musl 里编 termux/proot 又会踩 loader 无法执行 musl 动态 ELF 的坑。直接复用 Termux 生态(code_lfa 已验证的)预编译套件是最省事且已真机验证的路线。
Q: 5900/6080 端口会不会被别的应用连上?安全吗?
A: Xvnc 带 -localhost、RFB SecurityTypes None,websockify 也只转发到 127.0.0.1:5900,两个端口都只绑回环,同机其它应用理论上可访问本机端口但无系统级暴露面;WebView 之外没有任何入口。INTERNET 权限只是 WebView 访问 http://127.0.0.1 所必需,应用运行期不发起任何外网请求。
Q: 只有 arm64 吗?x86 手机/模拟器能用吗?
A: 是。abiFilters 只保留 arm64-v8a,jniLibs 里的 proot 套件也只有 arm64 一份。proot 是用户态仿真(同架构直接跑原生程序),桌面性能远好于 QEMU/TCG 方案;代价是 guest/host 架构必须一致。
Q: 为什么 targetSdk 停在 28?
A: targetSdk ≥ 29 后应用数据目录内二进制不可执行(W^X),而 proot 必须作为可执行文件直接跑。把二进制以 lib*.so 之名放进 jniLibs、释放到 nativeLibraryDir 后再从 files/bin 符号链接过去,是 Termux 同款解法;代价是无法上架 Google Play,本工程定位就是离线直装分发。
运行效果图
实拍环境:华为 TAS-AN00(TAS_AN00),arm64,Android 12;APK 即 Release alpine-desktop-v1.0,清数据后首次启动。
| 首启释放 rootfs(实时百分比与文件日志) | WebView 内的 XFCE 桌面 |
|---|---|
![]() |
![]() |
Thunar 文件管理器(可见 root 警告与真实磁盘容量,guest 文件系统完整可写):

小结
- 282MB 单 APK、只申请 3 个普通权限(无存储/无敏感权限)、零运行期联网,点开即进完整 Alpine 3.20 + XFCE 4.18 桌面;proot 做同架构用户态仿真,桌面是原生速度而非 TCG 翻译。
- Termux 生态的 proot 套件(外置 loader + jniLibs 命名 + 符号链接引导)是 Android 上跑桌面的成熟地基,比自编译上游 proot 少走大量弯路。
- 两条血泪经验:app 进程域里不要设
PROOT_NO_SECCOMP,不要让守护进程双重 fork——run-as shell 域无法复现这两个问题,排查时务必以 app 自身进程为准。 - 显示侧选 Xvnc + noVNC + 系统 WebView,Android 端不用写任何原生 X/VNC 代码;就绪判据用真实端口探测比固定 sleep 可靠。
- 大资产打包含金量:
docker export出完整 rootfs、.bin扩展名 +noCompress避 aapt、Git LFS 管 280MB 资产,三者缺一复刻就会翻车。

记录一个**装上 APK、点开就是 Linux 桌面**的离线 Android 应用 **AlpineDesktop**:把完整的 **Alpine Linux 3.20.10 + XFCE 4.18** 根文件系统(`rootfs.bin`,294,058,676 字节的 tar.gz,解出约 673MB)整个塞进 APK 的 assets,全程**不需要 root、不申请存储权限、运行期零联网**,arm64 真机(华为 TAS-AN00,Android 12)实测清数据后约 1 分钟进入完整桌面。


浙公网安备 33010602011771号