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:
+104
-12
@@ -308,20 +308,17 @@ class ApkManager:
|
|||||||
"verifier_verify_adb_installs", "0")
|
"verifier_verify_adb_installs", "0")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
|
# 2. 推送 APK 到设备(分块 + 实时进度)
|
||||||
self._set_item(serial, "installing", "正在推送 APK...")
|
ok_push, msg_push = self._push_with_progress(serial, apk_path)
|
||||||
r = subprocess.run(
|
if not ok_push:
|
||||||
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
|
self._set_item(serial, "failed", msg_push)
|
||||||
capture_output=True, timeout=_INSTALL_TIMEOUT
|
_log.warning(f"[{serial}] APK 推送失败: {msg_push}")
|
||||||
)
|
|
||||||
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}")
|
|
||||||
return
|
return
|
||||||
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
|
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
|
||||||
self._set_item(serial, "installing", "正在安装...")
|
# 这一步在**设备端**解包/优化,耗时随包大小增长(抖音 336MB 实测 ~65s),
|
||||||
|
# 但系统没给进度接口,只能给出预期时间,别让用户以为是卡住了。
|
||||||
|
self._set_item(serial, "installing",
|
||||||
|
"正在设备上安装...(大包要 1-2 分钟,无进度可读)")
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
|
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
|
||||||
"/data/local/tmp/_install.apk"],
|
"/data/local/tmp/_install.apk"],
|
||||||
@@ -385,6 +382,101 @@ class ApkManager:
|
|||||||
# adb 连接保持即可,不影响设备和后续操作。
|
# adb 连接保持即可,不影响设备和后续操作。
|
||||||
pass
|
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=""):
|
def _set_item(self, serial, status, msg=""):
|
||||||
"""更新安装任务中某设备的状态。"""
|
"""更新安装任务中某设备的状态。"""
|
||||||
if not self._install_task:
|
if not self._install_task:
|
||||||
|
|||||||
+12
-1
@@ -517,7 +517,18 @@
|
|||||||
| `DELETE /api/apks/<apk_id>` | — | 删文件 + 记录 |
|
| `DELETE /api/apks/<apk_id>` | — | 删文件 + 记录 |
|
||||||
| `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 |
|
| `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 |
|
||||||
| `GET /api/apks/install/devices` | — | 可安装设备(pool / usb / adb 三个来源) |
|
| `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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ function showInstallProgress(){
|
|||||||
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
|
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
|
||||||
pollInstallStatus();
|
pollInstallStatus();
|
||||||
if(_installTimer)clearInterval(_installTimer);
|
if(_installTimer)clearInterval(_installTimer);
|
||||||
_installTimer=setInterval(pollInstallStatus,3000);
|
_installTimer=setInterval(pollInstallStatus,1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollInstallStatus(){
|
async function pollInstallStatus(){
|
||||||
@@ -226,10 +226,13 @@ async function pollInstallStatus(){
|
|||||||
const items=st.items||{};
|
const items=st.items||{};
|
||||||
const itemRows=Object.entries(items).map(([serial,info])=>{
|
const itemRows=Object.entries(items).map(([serial,info])=>{
|
||||||
const s=SS[info.status]||{t:info.status,c:'default'};
|
const s=SS[info.status]||{t:info.status,c:'default'};
|
||||||
|
// 推送进度带百分比时补一条细进度条(大包传输要一两分钟,光有文字不够直观)
|
||||||
|
const m=/推送中 (\d+)%/.exec(info.msg||'');
|
||||||
|
const bar=m?'<div class="progress" style="height:6px;margin-top:4px;max-width:240px"><div class="progress-bar" style="width:'+m[1]+'%"></div></div>':'';
|
||||||
return '<tr><td>'+esc(info.name||serial)+'</td>'+
|
return '<tr><td>'+esc(info.name||serial)+'</td>'+
|
||||||
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
|
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
|
||||||
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
|
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
|
||||||
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>';
|
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+bar+'</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
|
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
|
||||||
|
|||||||
Reference in New Issue
Block a user