性能优化

This commit is contained in:
2025-09-17 22:23:57 +08:00
parent db67024157
commit 6e2486a036
11 changed files with 274 additions and 90 deletions

2
.idea/iOSAI.iml generated
View File

@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (IOS-AI)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.12" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (IOS-AI)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
</project>

10
.idea/workspace.xml generated
View File

@@ -5,9 +5,17 @@
</component>
<component name="ChangeListManager">
<list default="true" id="eceeff5e-51c1-459c-a911-d21ec090a423" name="Changes" comment="20250904-初步功能已完成">
<change afterPath="$PROJECT_DIR$/Utils/SubprocessKit.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/resources/133bffc17635b7a3bd709492b7a519d96710a4a2/bgv.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/iOSAI.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/iOSAI.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Module/DeviceInfo.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/DeviceInfo.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Utils/ControlUtils.py" beforeDir="false" afterPath="$PROJECT_DIR$/Utils/ControlUtils.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Module/FlaskService.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/FlaskService.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Module/FlaskSubprocessManager.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/FlaskSubprocessManager.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Utils/ThreadManager.py" beforeDir="false" afterPath="$PROJECT_DIR$/Utils/ThreadManager.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/build.bat" beforeDir="false" afterPath="$PROJECT_DIR$/build.bat" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/FlashLink.exe" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />

View File

@@ -3,24 +3,38 @@ import os
import signal
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import wda
import threading
import subprocess
from pathlib import Path
from typing import List, Dict, Optional
from tidevice import Usbmux, ConnectionType
from tidevice._device import BaseDevice
from Entity.DeviceModel import DeviceModel
from Entity.Variables import WdaAppBundleId
from Module.FlaskSubprocessManager import FlaskSubprocessManager
from Utils.LogManager import LogManager
from Utils.SubprocessKit import check_output as sp_check_output, popen as sp_popen
class Deviceinfo(object):
"""设备生命周期管理:以 deviceModelList 为唯一真理源"""
def __init__(self):
...
# ✅ 新增:连接线程池(最大 6 并发)
self._connect_pool = ThreadPoolExecutor(max_workers=6)
...
if os.name == "nt":
self._si = subprocess.STARTUPINFO()
self._si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
self._si.wShowWindow = subprocess.SW_HIDE # 0
else:
self._si = None
self.deviceIndex = 0
self.screenProxy = 9110
self.pidList: List[Dict] = [] # 仅记录 iproxy 进程
@@ -46,13 +60,14 @@ class Deviceinfo(object):
pass
self._creationflags = 0x08000000 if os.name == "nt" else 0
self._popen_kwargs = dict(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(self.iproxy_dir),
shell=False,
text=True,
creationflags=self._creationflags,
creationflags=0x08000000 if os.name == "nt" else 0, # CREATE_NO_WINDOW
encoding="utf-8",
bufsize=1,
)
@@ -84,10 +99,10 @@ class Deviceinfo(object):
# endregion
# ------------------------------------------------------------------
# ✅ 2. 主监听循环(已用“时间窗口+USB 层兜底”重写)
# 主监听循环 → 只负责“发现”和“提交任务”
# ------------------------------------------------------------------
def startDeviceListener(self):
MISS_WINDOW = 5.0 # 5 秒连续失踪才判死刑
MISS_WINDOW = 5.0
while True:
try:
lists = Usbmux().device_list()
@@ -97,38 +112,36 @@ class Deviceinfo(object):
continue
now_udids = {d.udid for d in lists if d.conn_type == ConnectionType.USB}
# ✅ USB 层真断兜底
usb_sn_set = self._usb_enumerate_sn()
need_remove = None
# 1. 失踪判定(同旧逻辑)
need_remove = []
with self._lock:
for udid in list(self._model_index.keys()):
if udid not in now_udids:
last = self._last_seen.get(udid, time.time())
if time.time() - last > MISS_WINDOW and udid not in usb_sn_set:
need_remove = udid
need_remove.append(udid)
else:
self._last_seen[udid] = time.time()
for udid in need_remove:
self._remove_model(udid)
if need_remove:
self._remove_model(need_remove)
# 新增设备(原逻辑不变)
for d in lists:
if d.conn_type != ConnectionType.USB:
continue
udid = d.udid
with self._lock:
if udid in self._model_index:
continue
if not self.is_device_trusted(udid):
continue
if len(self.deviceModelList) >= self.maxDeviceCount:
continue
try:
self.connectDevice(udid)
except Exception as e:
LogManager.error(f"连接设备失败 {udid}: {e}", udid)
# 2. 发现新设备 → 并发连接
with self._lock:
new_udids = [d.udid for d in lists
if d.conn_type == ConnectionType.USB and
d.udid not in self._model_index and
len(self.deviceModelList) < self.maxDeviceCount]
if new_udids:
futures = {self._connect_pool.submit(self._connect_device_task, udid): udid
for udid in new_udids}
for f in as_completed(futures, timeout=10):
udid = futures[f]
try:
f.result(timeout=8) # 单台 8 s 硬截止
except Exception as e:
LogManager.error(f"连接任务超时/失败: {e}", udid)
time.sleep(1)
@@ -137,7 +150,7 @@ class Deviceinfo(object):
# ------------------------------------------------------------------
def _usb_enumerate_sn(self) -> set[str]:
try:
out = subprocess.check_output(["idevice_id", "-l"], text=True, timeout=3)
out = sp_check_output(["idevice_id", "-l"], text=True, timeout=3)
return {line.strip() for line in out.splitlines() if line.strip()}
except Exception:
return set()
@@ -259,8 +272,10 @@ class Deviceinfo(object):
self._port_in_use.remove(port)
self._port_pool.append(port)
# -------------------- 单台设备连接(未改动) --------------------
def connectDevice(self, udid: str):
# ------------------------------------------------------------------
# 线程池里真正干活的地方(原 connectDevice 逻辑搬过来)
# ------------------------------------------------------------------
def _connect_device_task(self, udid: str):
if not self.is_device_trusted(udid):
LogManager.warning("设备未信任,跳过 WDA 启动", udid)
return
@@ -269,6 +284,7 @@ class Deviceinfo(object):
except Exception as e:
LogManager.error(f"启动 WDA 失败: {e}", udid)
return
width, height, scale = 0, 0, 1.0
try:
size = d.window_size()
@@ -276,19 +292,35 @@ class Deviceinfo(object):
scale = d.scale
except Exception as e:
LogManager.warning(f"读取屏幕信息失败:{e}", udid)
port = self._alloc_port()
model = DeviceModel(udid, port, width, height, scale, type=1)
self._add_model(model)
# 先做完所有 IO再抢锁写内存
try:
d.app_start(WdaAppBundleId)
d.home()
except Exception as e:
LogManager.warning(f"启动/切回桌面失败:{e}", udid)
time.sleep(2)
self.pidList = [item for item in self.pidList if item.get("id") != udid]
time.sleep(2) # 原逻辑保留
target = self.relayDeviceScreenPort(udid, port)
if target:
self.pidList.append({"target": target, "id": udid})
# 毫秒级临界区
with self._lock:
if udid in self._model_index: # 并发防重
return
self._add_model(model)
if target:
self.pidList.append({"target": target, "id": udid})
# ------------------------------------------------------------------
# 原函数保留(改名即可)
# ------------------------------------------------------------------
def connectDevice(self, udid: str):
"""对外保留接口,实际走线程池"""
self._connect_pool.submit(self._connect_device_task, udid)
# -------------------- 工具方法(未改动) --------------------
def is_device_trusted(self, udid: str) -> bool:
@@ -327,14 +359,12 @@ class Deviceinfo(object):
def _get_pid_by_port(self, port: int) -> Optional[int]:
try:
if os.name == "nt":
cmd = ["netstat", "-ano", "-p", "tcp"]
out = subprocess.check_output(cmd, text=True)
out = sp_check_output(["netstat", "-ano", "-p", "tcp"], text=True)
for line in out.splitlines():
if f"127.0.0.1:{port}" in line and "LISTENING" in line:
return int(line.strip().split()[-1])
else:
cmd = ["lsof", "-t", f"-iTCP:{port}", "-sTCP:LISTEN"]
out = subprocess.check_output(cmd, text=True)
out = sp_check_output(["lsof", "-t", f"-iTCP:{port}", "-sTCP:LISTEN"], text=True)
return int(out.strip().split()[0])
except Exception:
return None
@@ -375,4 +405,4 @@ class Deviceinfo(object):
for p in candidates:
if p.exists():
return p
raise FileNotFoundError(f"iproxy not found, tried: {[str(c) for c in candidates]}")
raise FileNotFoundError(f"iproxy not found, tried: {[str(c) for c in candidates]}")

View File

@@ -266,8 +266,9 @@ def watchLiveForGrowth():
def stopScript():
body = request.get_json()
udid = body.get("udid")
code, massage = ThreadManager.stop(udid)
return ResultData(code=code, data="", massage=massage).toJson()
LogManager.method_info(f"接口收到 /stopScript udid={udid}", method="task")
code, msg = ThreadManager.stop(udid)
return ResultData(code=code, data="", massage=msg).toJson()
# 关注打招呼

View File

@@ -9,6 +9,8 @@ import time
from pathlib import Path
from typing import Optional, Union, Dict, List
import psutil
from Utils.LogManager import LogManager
@@ -28,9 +30,39 @@ class FlaskSubprocessManager:
self.comm_port = 34566
self._stop_event = threading.Event()
self._monitor_thread: Optional[threading.Thread] = None
# 新增:启动前先把可能残留的 Flask 干掉
self._kill_orphan_flask()
atexit.register(self.stop)
LogManager.info("FlaskSubprocessManager 单例已初始化", udid="system")
def _kill_orphan_flask(self):
"""根据端口 34566 把遗留进程全部杀掉"""
try:
if os.name == "nt":
# Windows
out = subprocess.check_output(
["netstat", "-ano"],
text=True, startupinfo=self._si
)
for line in out.splitlines():
if f"127.0.0.1:{self.comm_port}" in line and "LISTENING" in line:
pid = int(line.strip().split()[-1])
if pid != os.getpid():
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
startupinfo=self._si,
capture_output=True)
else:
# macOS / Linux
out = subprocess.check_output(
["lsof", "-t", f"-iTCP:{self.comm_port}", "-sTCP:LISTEN"],
text=True
)
for pid in map(int, out.split()):
if pid != os.getpid():
os.kill(pid, 9)
except Exception:
pass
# ---------- 启动 ----------
def start(self):
with self._lock:
@@ -108,27 +140,24 @@ class FlaskSubprocessManager:
# ---------- 停止 ----------
def stop(self):
with self._lock:
if getattr(self, 'process', None) is None:
LogManager.info("无子进程需要停止", udid="system")
if not self.process:
return
pid = self.process.pid
LogManager.info(f"正在停止 Flask 子进程 PID={pid}", udid="system")
try:
self.process.terminate()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
LogManager.warning("软杀超时,强制杀进程树", udid="system")
import psutil
parent = psutil.Process(pid)
for child in parent.children(recursive=True):
child.kill()
parent.kill()
self.process.wait()
LogManager.info("Flask 子进程已停止", udid="system")
# 1. 杀整棵树Windows 也适用)
parent = psutil.Process(pid)
for child in parent.children(recursive=True):
child.kill()
parent.kill()
gone, alive = psutil.wait_procs([parent] + parent.children(), timeout=3)
for p in alive:
p.kill() # 保险再补一刀
self.process.wait()
except psutil.NoSuchProcess:
pass
except Exception as e:
LogManager.error(f"停止子进程异常:{e}", udid="system")
LogManager.error(f"停止子进程异常:{e}", udid="system")
finally:
self.process = None
self._stop_event.set()

24
Utils/SubprocessKit.py Normal file
View File

@@ -0,0 +1,24 @@
import os
import subprocess
__all__ = ['check_output', 'popen', 'PIPE']
# 模块级单例,导入时只创建一次
if os.name == "nt":
_si = subprocess.STARTUPINFO()
_si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
_si.wShowWindow = subprocess.SW_HIDE
else:
_si = None
PIPE = subprocess.PIPE
def check_output(cmd, **kw):
if os.name == "nt":
kw.setdefault('startupinfo', _si)
return subprocess.check_output(cmd, **kw)
def popen(*args, **kw):
if os.name == "nt":
kw.setdefault('startupinfo', _si)
return subprocess.Popen(*args, **kw)

View File

@@ -1,33 +1,126 @@
from threading import Thread, Event
import os
import signal
import sys
import threading
import time
import psutil
import subprocess
from pathlib import Path
from threading import Event, Thread
from typing import Dict, Optional
from Utils.LogManager import LogManager
from script.ScriptManager import ScriptManager
class ThreadManager():
threads = {}
class ThreadManager:
"""
对调用方完全透明:
add(udid, thread_obj, stop_event) 保持原签名
stop(udid) 保持原签名
但内部把 thread_obj 当成“壳”,真正拉起的是子进程。
"""
_pool: Dict[str, psutil.Process] = {}
_lock = threading.Lock()
@classmethod
def add(cls, udid, t: Thread, stopEvent: Event):
if udid in cls.threads:
print("▲ 线程已存在")
return
cls.threads[udid] = {"thread": t, "stopEvent": stopEvent}
def add(cls, udid: str, dummy_thread, dummy_event: Event) -> None:
LogManager.method_info(f"【1】入口 udid={udid} 长度={len(udid)}", method="task")
if udid in cls._pool:
LogManager.method_warning(f"{udid} 仍在运行,先强制清理旧任务", method="task")
cls.stop(udid)
LogManager.method_info(f"【2】判断旧任务后 udid={udid} 长度={len(udid)}", method="task")
port = cls._find_free_port()
LogManager.method_info(f"【3】找端口后 udid={udid} 长度={len(udid)}", method="task")
proc = cls._start_worker_process(udid, port)
LogManager.method_info(f"【4】子进程启动后 udid={udid} 长度={len(udid)}", method="task")
cls._pool[udid] = proc
LogManager.method_info(f"【5】已写入字典udid={udid} 长度={len(udid)}", method="task")
@classmethod
def stop(cls, udid):
try:
info = cls.threads[udid]
if info:
info["stopEvent"].set() # 停止线程
info["thread"].join(timeout=3) # 等待线程退出
del cls.threads[udid]
LogManager.info("停止线程成功", udid)
return 200, "停止线程成功 " + udid
else:
LogManager.info("无此线程,无需关闭", udid)
return 1001, "无此线程,无需关闭 " + udid
except KeyError as e:
LogManager.info("无此线程,无需关闭", udid)
return 1001, "停止脚本失败 " + udid
def stop(cls, udid: str) -> tuple[int, str]:
with cls._lock: # 类级锁
proc = cls._pool.get(udid) # 1. 只读,不删
if proc is None:
return 1001, f"无此任务 {udid}"
try:
proc.terminate()
gone, alive = psutil.wait_procs([proc], timeout=3)
if alive:
for p in alive:
for child in p.children(recursive=True):
child.kill()
p.kill()
psutil.wait_procs(alive, timeout=2)
# 正常退出
cls._pool.pop(udid)
LogManager.method_info("任务停止成功", method="task")
return 200, f"停止线程成功 {udid}"
except psutil.NoSuchProcess: # 精准捕获
cls._pool.pop(udid)
LogManager.method_info("进程已自然退出", method="task")
return 200, f"进程已退出 {udid}"
except Exception as e: # 真正的异常
LogManager.method_error(f"停止异常: {e}", method="task")
return 1002, f"停止异常 {udid}"
# ------------------------------------------------------
# 以下全是内部工具,外部无需调用
# ------------------------------------------------------
@staticmethod
def _find_free_port(start: int = 50000) -> int:
"""找个随机空闲端口,给子进程当通信口(可选)"""
import socket
for p in range(start, start + 1000):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("127.0.0.1", p)) != 0:
return p
raise RuntimeError("无可用端口")
@staticmethod
def _start_worker_process(udid: str, port: int) -> psutil.Process:
"""
真正拉起子进程:
打包环境exe --udid=xxx
源码环境python -m Module.Worker --udid=xxx
"""
exe_path = Path(sys.executable).resolve()
is_frozen = exe_path.suffix.lower() == ".exe" and exe_path.exists()
if is_frozen:
# 打包后
cmd = [str(exe_path), "--role=worker", f"--udid={udid}", f"--port={port}"]
cwd = str(exe_path.parent)
else:
# 源码运行
cmd = [sys.executable, "-u", "-m", "Module.Worker", f"--udid={udid}", f"--port={port}"]
cwd = str(Path(__file__).resolve().parent.parent)
# 核心CREATE_NO_WINDOW + 独立会话,父进程死也不影响
creation_flags = 0x08000000 if os.name == "nt" else 0
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
cwd=cwd,
start_new_session=True, # 独立进程组
creationflags=creation_flags
)
# 守护线程:把子进程 stdout 实时打到日志
Thread(target=lambda: ThreadManager._log_stdout(proc, udid), daemon=True).start()
return psutil.Process(proc.pid)
@staticmethod
def _log_stdout(proc: subprocess.Popen, udid: str):
for line in iter(proc.stdout.readline, ""):
if line:
LogManager.info(line.rstrip(), udid)
proc.stdout.close()

View File

@@ -3,7 +3,6 @@ python -m nuitka "Module/Main.py" ^
--msvc=latest ^
--windows-console-mode=disable ^
--remove-output ^
--output-dir="F:/company code/AI item/20250820/iOSAI/out" ^
--output-filename=IOSAI ^
--include-package=Module,Utils,Entity,script ^
--include-module=flask ^
@@ -18,7 +17,7 @@ python -m nuitka "Module/Main.py" ^
--include-module=urllib3 ^
--include-module=certifi ^
--include-module=idna ^
--include-data-dir="F:/company code/AI item/20250820/iOSAI/SupportFiles=SupportFiles" ^
--include-data-dir="F:/company code/AI item/20250820/iOSAI/resources=resources" ^
--include-data-files="F:/company code/AI item/20250820/iOSAI/resources/iproxy/*=resources/iproxy/" ^
--windows-icon-from-ico="F:/company code/AI item/20250820/iOSAI/resources/icon.ico"
--include-data-dir="E:/code/Python/iOSAI/SupportFiles=SupportFiles" ^
--include-data-dir="E:/code/Python/iOSAI/resources=resources" ^
--include-data-files="E:/code/Python/iOSAI/resources/iproxy/*=resources/iproxy/" ^
--windows-icon-from-ico="E:/code/Python/iOSAI/resources/icon.ico"

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.