fix: 大 APK 推送到设备时显示实时进度(原来是整段黑箱干等)

问题(用户反馈):从网页点「安装」到设备,包大的时候(抖音 336MB)只显示
一句「正在推送 APK...」,传了两分多钟全程没有任何进度,像卡死了。

根因:推送用的是 `adb push`,它**只在结束时**吐一行汇总
("1 file pushed, 3.0 MB/s"),中间过程什么都不给。

改法:分块写 + 实时进度
- core/apk_manager.py 新增 `_push_with_progress()`:改用 `adb exec-in "cat > 文件"`
  自己按 1MB 分块喂数据,每块更新一次状态(限流 0.8s 一次):
    `推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)`
  传完校验设备上文件大小;中途断流/exec-in 不可用时**退回原来的 adb push**(保证兼容)
- 代价:实测速度 2.6 vs 3.1 MB/s(慢 17%),换来看得见的进度,划算
- 「正在设备上安装...」补上预期时间说明 —— `pm install` 在设备端解包
  (336MB 实测 ~65s),系统没给进度接口,只能给预期值
- static/admin/apps.js:安装进度轮询 3s → 1.5s;表格里再加一条细进度条
  (大包要一两分钟,光有文字不够直观)
- doc/API.md:把安装状态机的两段式进度与耗时参考写进接口说明

实测(抖音 v40.4.0 336.1MB → 192.168.20.100,单台):
  推送 60%→100% 每 2 秒一档,速度稳定 2.6 MB/s,134s 传完;
  设备端安装 65s;端到端 ~200s,全程有进度可看。
全量回归 106 项通过。
This commit is contained in:
2026-09-13 15:38:14 +08:00
parent dad1af8b0b
commit cdf06e1929
3 changed files with 121 additions and 15 deletions
+104 -12
View File
@@ -308,20 +308,17 @@ class ApkManager:
"verifier_verify_adb_installs", "0")
except Exception:
pass
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
self._set_item(serial, "installing", "正在推送 APK...")
r = subprocess.run(
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT
)
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in push_out:
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
self._set_item(serial, "failed", msg)
_log.warning(f"[{serial}] APK 推送失败: {msg}")
# 2. 推送 APK 到设备(分块 + 实时进度)
ok_push, msg_push = self._push_with_progress(serial, apk_path)
if not ok_push:
self._set_item(serial, "failed", msg_push)
_log.warning(f"[{serial}] APK 推送失败: {msg_push}")
return
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
self._set_item(serial, "installing", "正在安装...")
# 这一步在**设备端**解包/优化,耗时随包大小增长(抖音 336MB 实测 ~65s),
# 但系统没给进度接口,只能给出预期时间,别让用户以为是卡住了。
self._set_item(serial, "installing",
"正在设备上安装...(大包要 1-2 分钟,无进度可读)")
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
"/data/local/tmp/_install.apk"],
@@ -385,6 +382,101 @@ class ApkManager:
# adb 连接保持即可,不影响设备和后续操作。
pass
def _push_with_progress(self, serial, apk_path,
remote="/data/local/tmp/_install.apk"):
"""把 APK 分块写进设备,边传边更新进度。返回 (ok, msg)。
为什么不用 `adb push`:它只在**结束时**吐一行汇总
("1 file pushed, 3.0 MB/s"),大包(抖音 336MB ≈ 1.8 分钟/台)整个传输过程
界面上只有一句"正在推送 APK...",用户只能干等 —— 这就是要优化的问题。
改用 `exec-in` 分块写:每块回调一次进度(百分比/已传/速度/剩余时间)。
实测速度略降(2.6 vs 3.1 MB/s),换来看得见的进度,划算。
传完校验设备上的文件大小;exec-in 不可用时退回原来的 `adb push`。
"""
total = os.path.getsize(apk_path)
chunk = 1 << 20 # 1MB:再大提升有限,再小 adb 调用开销明显
t0 = time.time()
sent = 0
last_report = 0.0
try:
proc = subprocess.Popen(
[ADB_PATH, "-s", serial, "exec-in", "cat > %s" % remote],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
except Exception as e:
_log.warning(f"[{serial}] exec-in 启动失败,退回 adb push: {e}")
return self._push_fallback(serial, apk_path, remote)
try:
with open(apk_path, "rb") as f:
while True:
buf = f.read(chunk)
if not buf:
break
proc.stdin.write(buf)
sent += len(buf)
now = time.time()
if now - last_report >= 0.8 or sent >= total:
last_report = now
spent = max(now - t0, 0.001)
speed = sent / spent / 1048576.0 # MB/s
left = int((total - sent) / max(sent / spent, 1))
self._set_item(
serial, "installing",
"推送中 %d%%(%.1f/%.1f MB · %.1f MB/s · 剩约 %ds)"
% (sent * 100 // max(total, 1), sent / 1048576.0,
total / 1048576.0, speed, left))
proc.stdin.close()
except Exception as e:
try:
proc.kill()
except Exception:
pass
return False, f"推送中断: {e}"
try:
rc = proc.wait(timeout=_INSTALL_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
return False, "推送超时(设备无响应)"
if rc != 0:
err = ""
try:
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[:150]
except Exception:
pass
_log.warning(f"[{serial}] exec-in 推送到一半失败(rc={rc}),退回 adb push")
return self._push_fallback(serial, apk_path, remote)
# 校验设备上文件大小一致(exec-in 中途断流可能"成功"但少字节)
try:
rs = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "stat", "-c", "%s", remote],
capture_output=True, timeout=30)
got = int((rs.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-1])
if got != total:
return False, f"推送不完整(设备 {got} / 本地 {total} 字节)"
except Exception:
pass # stat 参数各机型有差异,取不到就跳过校验
return True, "推送完成 %.1fMB / %.0fs" % (total / 1048576.0, time.time() - t0)
def _push_fallback(self, serial, apk_path, remote):
"""老 adb 或 exec-in 失败时的退路:原来的 `adb push`(无进度)。"""
self._set_item(serial, "installing", "正在推送 APK...(无进度显示)")
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "push", apk_path, remote],
capture_output=True, timeout=_INSTALL_TIMEOUT)
except subprocess.TimeoutExpired:
return False, "推送超时"
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in out:
return False, (out.strip().replace("\n", " ")[:150]
or f"推送失败(returncode={r.returncode})")
return True, "推送完成(adb push)"
def _set_item(self, serial, status, msg=""):
"""更新安装任务中某设备的状态。"""
if not self._install_task: