# 前言
目標:在 Linux 電腦上跑抖音的 APK,可以登入、看直播、點讚。
第一個念頭是 Waydroid,在 Linux 上跑一個 Android 容器,直接裝 APK。結果閃退、卡住、網路失敗輪著來,追到最後發現是抖音的 native code 跟 ARM 轉譯層本質上不合,不是設定能解的。
最後改走 scrcpy:手機自己跑抖音,畫面投到電腦操作。順手用 adb 注入觸控做了個自動點讚的小腳本。
環境:Arch Linux、i7-6700、RTX 2060(NVIDIA 專有驅動)、KDE Wayland、手機是 Pixel 6。
# 測試用 Waydroid
Waydroid 的 image 是 x86_64,抖音的 APK 只有 ARM 版本(完整版 arm64-v8a、Lite 版 armeabi-v7a),所以要靠轉譯層把 ARM native code 翻成 x86 執行。Waydroid 能裝的轉譯層有兩套:Intel 的 libhoudini 和 Google 的 libndk_translation。
三種組合都試了,結果全部不同、全部失敗:
| 版本 | 轉譯層 | 結果 |
|---|---|---|
| Lite (armv7) | ndk_translation |
啟動即 abort |
| 完整版 (arm64) | libhoudini |
啟動即 SIGSEGV |
| 完整版 (arm64) | ndk_translation |
能開,所有 API 請求「網路失敗」 |
前兩種從 tombstone 就看得出來是死在轉譯層內部。Lite 的 abort 訊息是 Unknown ARM sa_restorer in guest sigaction!,抖音自己裝了個非標準的 signal handler(反除錯機制的一部分),ndk_translation 不認得就直接 abort;完整版在 libhoudini 下則是 libhoudini 自己 segfault。
第三種比較有意思,因為 app 說「網路失敗」,直覺會去查網路。先把主機端的 firewalld、NAT、DNS 查過一輪,再用 adb 進容器裡測:ping 閘道、解析抖音的網域、HTTP 拿到 204,Android 的 ConnectivityService 也判定網路 VALIDATED。全部正常,所以網路本身沒問題,是抖音自己連不出去。
那就看抖音到底在哪一步失敗。logcat 裡它每個請求都留了這樣的紀錄:
net::ERR_CONNECTION_FAILED (-104)
attempts= <抖音伺服器 IP>:443 result:-104 ... dns=0, connect=0, ssl=-1
dns=0 是 DNS 成功、connect=0 是 TCP 連上、ssl=-1 是 TLS 握手沒完成。也就是說它連到伺服器了,握手時被斷線。
剩下的問題是:是這台機器的 TLS 出不去,還是只有抖音的 TLS 出不去?用容器內原生 x86 的 curl 對同一批主機(aweme.snssdk.com、webcast.amemv.com)做 HTTPS,全部握手成功。同一個容器、同一條網路、同一批伺服器,x86 的 curl 可以,ARM 轉譯的抖音不行,差別只剩轉譯層。
抖音不用系統的網路 API,自帶一套 TTNet(ByteDance 版的 Cronet + BoringSSL),是 arm64 的 native lib,經 ndk_translation 轉譯執行。BoringSSL 的握手算出來的東西對方驗不過就直接斷線,app 只看到「連線失敗」。這是轉譯層對加密指令的模擬問題,沒有設定可以調。
另外一個背景因素:NVIDIA 專有驅動下 Waydroid 沒有 GPU 加速,ro.hardware.egl=swiftshader 純軟體渲染,就算轉譯過關,影片播放也會很吃力。
結論是 x86 上跑 ARM-only、自帶加密網路堆疊又有反除錯的 app,Waydroid 這條路本質上走不通。
# 改用 scrcpy
scrcpy 是 Genymobile 開源的工具,透過 adb 把手機畫面即時串流到電腦,電腦的滑鼠鍵盤操作再回傳給手機。抖音在手機上跑,電腦只是顯示器加遙控器,沒有相容性問題。
# 安裝
sudo pacman -S scrcpy android-udev
sudo usermod -aG adbusers $USER
android-udev 提供 USB 裝置的權限規則,裝完要重載 udev(或重新登入):
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=usb
手機端:開啟開發人員選項,進去開「USB 偵錯」。用 USB 線把手機接到電腦,手機會跳出「允許 USB 偵錯?」,勾「一律允許這台電腦」後按允許,之後這台電腦就能透過 adb 連手機。
adb devices # 狀態應為 device,不是 unauthorized
scrcpy
# 切成無線
adb 除了走 USB,也可以走 TCP:手機的 adbd 改成監聽 5555 port,電腦就能透過網路連上去。scrcpy --tcpip 會做完整套:把 adbd 切到 TCP、查手機的 Wi-Fi IP、改走無線連線。
- 手機和電腦在同一個區域網路,電腦要能直接連到手機的 IP。電腦走有線接同一台路由器也可以
adbd預設是 USB 模式,切成 TCP 模式的指令要透過 USB 下,所以第一次一定要接線,連上後才能拔- 手機重開機後
adbd會回到 USB 模式,要再接線切一次
第一次(手機接著 USB):
scrcpy --tcpip
之後(不用接線):
scrcpy --tcpip=<手機IP>
# 關手機螢幕但保持投影
想要的效果是手機螢幕關著、電腦上照常看。這牽涉兩件事:關背光,以及不讓手機到了螢幕逾時就休眠鎖定(休眠後串流畫面會變成鎖定畫面)。三個參數各管一件:
| 參數 | 作用 | 生效條件 |
|---|---|---|
-S(--turn-screen-off) |
啟動時關掉手機背光,畫面照樣串流 | 一律 |
--stay-awake |
不休眠(打開 Android 的「充電時保持喚醒」) | 只在接著電源時 |
--screen-off-timeout=秒數 |
把手機的螢幕逾時改成指定秒數,結束時還原 | 一律 |
一開始只用 -S --stay-awake,USB 接著時沒問題,切成無線後手機還是到逾時就鎖定,視窗跟著變成鎖定畫面。原因是 --stay-awake 改的是 Android 的 stay_on_while_plugged_in 設定,沒充電就沒作用。所以無線模式 --screen-off-timeout 一定要有;--stay-awake 留著不衝突,接著電源時就完全不逾時:
scrcpy -S --stay-awake --screen-off-timeout=1800
執行中 MOD+o(左 Alt+o)關手機螢幕、MOD+Shift+o 開回來。
# 沒聲音
scrcpy 預設會轉發音訊,PipeWire 裡看得到 scrcpy 這條串流。沒聲音的原因通常是兩個:一是主機的預設輸出裝置不是你以為的那個(我的是跑到藍牙耳機),二是手機本身媒體音量太小。用 pactl list sink-inputs 確認串流在哪個 sink,然後把手機音量調大。
# 啟動 script
每次要 adb connect 再 scrcpy -s ... 太麻煩,包成一個 script,Wi-Fi 連不上就自動退回 USB 重切:
#!/bin/bash
# 無線投影。Wi-Fi 連不上時(手機重開機過)退回 USB 並重新切成 TCP 模式。
IP=192.168.x.x # 手機的 Wi-Fi IP
OPTS=(-S --stay-awake --screen-off-timeout=1800 --window-title "Pixel 6")
if adb connect "$IP:5555" 2>&1 | grep -q connected; then
exec scrcpy -s "$IP:5555" "${OPTS[@]}"
fi
if adb devices | grep -qE '^[0-9A-Z]+\s+device$'; then
exec scrcpy --tcpip "${OPTS[@]}"
fi
notify-send "scrcpy" "Wi-Fi 連不上 $IP,且沒有 USB 裝置。請接 USB 線後再試。"
exit 1
放在 ~/.local/bin/scrcpy-pixel,再寫一個 .desktop 檔就能從應用程式選單點兩下啟動:
[Desktop Entry]
Type=Application
Name=Pixel 6 投影
Exec=/home/User/.local/bin/scrcpy-pixel
Icon=phone
Terminal=false
Categories=Utility;
# 自動點讚
adb 可以直接對手機注入觸控事件,手機把它當成真人手指:
adb shell input tap 540 900
直播間的點讚是連點畫面出愛心。幾個實作上的細節:
雙擊要在同一次 shell 裡送。兩次 adb shell input tap 各自有 Wi-Fi 往返加 input 啟動時間,間隔超過 Android 的雙擊判定,會變成兩次單擊。放在同一個 adb shell '...' 裡連發就沒問題。
只在直播間才點。用 dumpsys activity activities 看 topResumedActivity,抖音的直播間是獨立的 Activity(LivePlayActivity),不在這個畫面就暫停,不然切去別的畫面它會照座標亂點。它分不出同一個 Activity 上浮的 Dialog(留言框、禮物面板),這部分沒處理。
PK 時座標要避開對手。PK 畫面左右分割,點在中央會碰到右邊對手的區域,跳出對手資料卡。改點左半邊主播區 (270, 900),非 PK 時左半邊一樣是影片區。
#!/bin/bash
# 抖音直播間自動點讚:每輪連點數下,隨機間隔。Ctrl+C 停止。
# 只在前景是直播頁 (LivePlayActivity) 時才點,其他畫面暫停等待。
# 座標 (270,900) 落在左半邊主播區,避開 PK 時右側對手(Pixel 6, 1080x2400)。
D=${D:-192.168.x.x:5555} # 手機的 Wi-Fi IP
MIN=${MIN:-1} # 每輪間隔下限(秒)
MAX=${MAX:-3} # 每輪間隔上限(秒)
adb -s "$D" get-state >/dev/null 2>&1 || { echo "手機未連線: $D"; exit 1; }
n=0
while true; do
taps=$((RANDOM % 4 + 3)) # 每輪 3 到 6 下
x=$((270 + RANDOM % 61 - 30)); y=$((900 + RANDOM % 61 - 30)) # 座標抖動 ±30px
out=$(adb -s "$D" shell "
if dumpsys activity activities | grep 'topResumedActivity=.*LivePlayActivity' >/dev/null; then
for i in \$(seq $taps); do input tap $x $y; done; echo LIVE
else
dumpsys activity activities | grep -o 'topResumedActivity=ActivityRecord{[^ ]* u0 [^ ]*' | sed 's/.* u0 //'
fi")
if [ "$out" = "LIVE" ]; then
n=$((n + taps)); printf '\r\033[K已點 %d 下' "$n"
else
printf '\r\033[K不在直播間 (%s),暫停中… 已點 %d 下' "${out:-unknown}" "$n"
fi
sleep $((RANDOM % (MAX - MIN + 1) + MIN))
done
腳本跑的時候電腦可以照常用,觸控是透過 adb 注入手機,跟電腦的滑鼠鍵盤無關;只要不把滑鼠點進 scrcpy 視窗就不會互相干擾。
# 結語
從 Waydroid 到 scrcpy,中間繞了一大圈,手機投影其實是最不折騰的做法。