diff --git a/core/apk_manager.py b/core/apk_manager.py index f4ebff9..6778eed 100644 --- a/core/apk_manager.py +++ b/core/apk_manager.py @@ -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: diff --git a/doc/API.md b/doc/API.md index bd165d3..ad0c9e4 100644 --- a/doc/API.md +++ b/doc/API.md @@ -517,7 +517,18 @@ | `DELETE /api/apks/` | — | 删文件 + 记录 | | `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 | | `GET /api/apks/install/devices` | — | 可安装设备(pool / usb / adb 三个来源) | -| `GET /api/apks/install/status` | — | 安装进度(前端 3s 轮询) | +| `GET /api/apks/install/status` | — | 安装进度(前端 1.5s 轮询) | + +> 每台设备的状态机:`pending`(等待)→ `installing`(**带文字的实时进度**)→ `success` / `failed` / `skipped`。 +> 安装分两段,`items[serial].msg` 会跟着更新: +> 1. **推送到设备**:`推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)` —— 用分块写 +> (`adb exec-in`)拿字节级进度;不用 `adb push` 是因为它只在结束时吐一行汇总, +> 大包(抖音 336MB≈2 分钟)整个传输过程界面上只能干等 +> 2. **设备上安装**:`正在设备上安装...(大包要 1-2 分钟,无进度可读)` —— `pm install` +> 在设备端解包,系统没给进度接口 +> +> 参考耗时(336MB 抖音 / 单台):推送 ~134s + 安装 ~65s ≈ 3.3 分钟;多台并发(上限 5)时 +> 带宽共享,整体时间取决于 WiFi。 --- diff --git a/static/admin/apps.js b/static/admin/apps.js index c8f5efb..f1d8ec3 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -210,7 +210,7 @@ function showInstallProgress(){ ''; pollInstallStatus(); if(_installTimer)clearInterval(_installTimer); - _installTimer=setInterval(pollInstallStatus,3000); + _installTimer=setInterval(pollInstallStatus,1500); } async function pollInstallStatus(){ @@ -226,10 +226,13 @@ async function pollInstallStatus(){ const items=st.items||{}; const itemRows=Object.entries(items).map(([serial,info])=>{ const s=SS[info.status]||{t:info.status,c:'default'}; + // 推送进度带百分比时补一条细进度条(大包传输要一两分钟,光有文字不够直观) + const m=/推送中 (\d+)%/.exec(info.msg||''); + const bar=m?'
':''; return ''+esc(info.name||serial)+''+ ''+esc(serial)+''+ ''+s.t+''+ - ''+esc(info.msg||'')+''; + ''+esc(info.msg||'')+bar+''; }).join(''); const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;