SetParent 是单行道:为什么 Java/AWT 窗口一旦嵌入就再也回不去
一句话结论:对 Java AWT 窗口调用
SetParent之后,任何"脱父子"事件都会让 AWT 销毁旧 HWND 并重建一个空壳------新窗口有标题栏、有正确尺寸、能响应消息,但游戏的 DirectX 渲染层永远不会再挂接回去。这不是 bug,不是版本问题,不是你写错了。这是 AWT 的架构决定的,而这个结论花了我 6 个版本、两天时间才买到。
本文记录一次真实排查的完整过程,包含诊断日志、误判弯路和最终实锤。所有代码和日志来自实际项目。
一、你可能也写过这样的代码
需求很自然:想把几个游戏客户端窗口"贴"到一个统一的容器窗口里,做成浏览器标签页的样子。于是:
c
SetParent(gameHwnd, frameHwnd);
MoveWindow(gameHwnd, 0, TABBAR_H, w, h, TRUE);
SetWindowLong(gameHwnd, GWL_STYLE, WS_POPUP | WS_VISIBLE);
嵌入成功,运行正常,鼠标精准,一切看起来没问题。
然后你去点"把窗口恢复到桌面",或者更糟------你直接关掉了容器。
游戏窗口变成了一个白框:标题栏还在、大小正确、能拖动,但里面什么都没有。任务栏还挂着图标,游戏进程还在跑,可画面永远是黑的。
你觉得是自己恢复逻辑写错了。于是开始排查。
二、排查过程:五级递进,每一级都以为找到了答案
第 1 级:句柄失效
恢复时发现原来的 HWND 没了:
ini
恢复诊断:旧句柄 0x00120B4C 无效(样式仍含 WS_CHILD=0x06CA0000)
合理------跨进程改父子关系,SetParent 之后窗口变成了子窗口,销毁重建很正常。解决方案:按 PID 重新枚举窗口。
第 2 级:新窗口起初不可见
找到了新窗口,但看不见:
scss
类=SunAwtToolkit 标题=theAwtToolkitWindow
可见=0 位置=(0,0) 尺寸=1399x796
合理------AWT 重建窗口时可能先创建成隐藏状态再显示。解决方案:搜索时不要求 IsWindowVisible()。
第 3 级:幽灵窗口(最坑的一个)
现在能找到了,但出现"任务栏有图标、桌面看不见、点不动、关不掉"的幽灵窗口。
诊断输出了这样一行:
恢复日志:类=D3DFocusWindow 标题=D3DFocusWindow
这是游戏的 DirectX 渲染层焦点辅助窗口,不是游戏主窗口。
它的恶劣之处在于:也是 WS_POPUP | WS_VISIBLE,尺寸跟主窗口一模一样(1399x796),在"面积最大优先"的搜索策略里跟真主窗口打成平手甚至胜出。于是代码高高兴兴地把"游戏"切到了一个幕后辅助窗口上,真主窗口被晾在一边。
解决方案:两级严格匹配。
c
// 嵌入时记住主窗口类名
GetClassNameW(hwnd, g_origCls, 64);
// 恢复时严格匹配
// 严格级:同 PID + 类名 == g_origCls
// 兜底级:同 PID + 有标题,但显式排除辅助类
if (wcscmp(cls, L"D3DFocusWindow") == 0) continue;
if (wcsstr(cls, L"JavaAWT")) continue;
if (wcsstr(cls, L"MSCTFIME")) continue;
第 4 级:抓到了,但画面回不来
严格匹配成功命中了真主窗口:
类=SunAwtToolkit 标题=theAwtToolkitWindow
窗口属性一切正常:可见=1、尺寸=1399x796、位置正确、标题正确、样式完整。
但它是白屏的。
游戏画面(D3D 渲染层)没有跟回来。
到这里就可以定性了:不是搜索逻辑的问题,不是可见性问题,不是句柄问题。是 AWT 重建出来的这个窗口本身就是残废的。 它的原生窗口框架(AWT 的窗口管理部分)活着,但渲染层挂接(D3D Device 绑定到这个 HWND 的部分)已经丢了。
无论怎么 ShowWindow、怎么 SetForegroundWindow、怎么 RedrawWindow,画面永远不会回来。"还原到桌面"在技术上不可行。
第 5 级:实锤------不是我操作的问题,是架构的问题
到这里还差最后一环:会不会是我的恢复逻辑有问题,只要换个写法就能救回来?
于是做了一个决定性的测试:不做任何恢复操作,直接点 × 关闭多开器。
结果:游戏同样变成白屏空壳窗口(theAwtToolkitWindow)。
原因很清楚:容器销毁时,跨进程子窗口被系统自动脱父子 (effectively SetParent(NULL)),一样触发 AWT 重建空壳。
结论升格:只要游戏被 SetParent 过,无论你是主动恢复、关闭宿主、切换显示器、还是任何其他脱父子事件,游戏渲染都会死。
SetParent 是单行道。没有例外。
三、为什么会这样:AWT 的窗口所有权模型
要理解为什么,得知道 AWT 怎么管理窗口。
AWT 窗口是 Java 侧的"代理"
Win32 原生窗口(HWND)在 Java 里没有直接对应物。AWT 的做法是:Java 的 Frame 在底层对应一个 ComponentPeer,ComponentPeer 持有一个真正的 HWND。Java 代码调 frame.setSize(),最终是 peer 去调 SetWindowPos。
关键在于:Frame 对象和 HWND 的绑定关系由 Java 侧维护。Java 侧记录了"我的 Frame 在屏幕上的位置、尺寸、Z 序",这些状态在 peer 里。
AWT 缓存了窗口坐标
一个隐蔽但致命的行为:AWT 内部缓存了窗口的屏幕坐标。
ComponentPeer 里有 savedWindowState 之类的结构,Java 侧随时可能根据缓存值把窗口挪回去。所以你会看到"嵌得好好的,游戏突然自己跑回原位"------不是因为有人调用了 SetWindowPos,而是 AWT 在某个时刻重新 apply 了自己的缓存状态。
脱父子 = 状态失效
当 SetParent(NULL) 把窗口还回桌面时:
- AWT 检测到自己的 peer 状态和实际 HWND 不匹配
- 它决定销毁旧 HWND,重建一个
- 重建时,Java 侧的渲染绑定(DirectX Device ↔ 旧 HWND)不会重新建立
结果就是:新 HWND 存在、Java 侧 Frame 对象也活着、但渲染上下文丢了。
你可以把它理解为:SetParent 在跨进程场景下破坏了 AWT 的不变量。 AWT 假设"一个 Frame 始终对应同一个 HWND",你把这个假设打破了,它只能重建来恢复一致性------代价是渲染上下文丢失。
四、正确的做法:零 SetParent
既然"嵌入后无法还原",那就根本不建立父子关系。
核心原则
游戏窗口永远是顶层窗口。永远不调用
SetParent。
实现方式
┌────────────────────────────────────────┐
│ 容器窗口(自绘页签条,纯外框) │
├────────────────────────────────────────┤
│ 游戏窗口(顶层,只 SetWindowPos 平移) │
└────────────────────────────────────────┘
- 游戏窗口保持顶层身份,AWT 完全无感知;
- 容器用
SetWindowPos把游戏窗口摆到自己的客户区内------只平移,不改尺寸,不改样式; - 容器在 z 序上压在游戏下方(不遮住游戏);
- 页签切换 = 对各游戏窗口
SW_HIDE/SW_SHOW------对顶层窗口隐藏不会触发 AWT 重建。
效果上跟嵌入几乎一样(页签条盖住游戏原生标题栏),但:
- 关闭多开器 → 游戏窗口各自独立存活,天然可还原
- 切换显示器 → 不触发任何脱父子
- 零风险:Java 侧感知不到任何异常
一个反直觉的发现
一开始我以为"嵌入"(真的 SetParent)比"遮挡"(视觉上伪装成嵌入)更彻底,所以更优雅。四步实验教了我:
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 仅 SetParent,零样式改动 |
✅ 正常 |
| 2 | 加页签条、整体平移 34px(尺寸不变) | ✅ 正常 |
| 3 | 去边框改 WS_POPUP(客户区变高 39px) |
❌ 鼠标上下不精准、画面多出一截 |
| 4 | 恢复原样 | ❌ 白屏空壳 |
视觉上"更像嵌入"的第 3 步,直接把游戏搞坏了。 改客户区尺寸对 AWT 是禁区------它在布局计算里假设了自己的窗口尺寸。
所以对 Java/AWT 客户端的铁律是:
只允许
SetParent+ 平移,绝不能改尺寸或样式。更进一步:连
SetParent都别用。
五、这套结论的适用范围
不限于 Java。任何自己管理原生窗口、且有自己的渲染上下文的程序,都可能有类似问题:
| 目标窗口类型 | SetParent 风险 |
建议 |
|---|---|---|
| Win32 原生窗口(自己写的) | 低 | 可用,但要自己处理 WM_PARENTNOTIFY |
| Java AWT(老版客户端常见) | 高,单行道 | 零 SetParent |
| JavaFX / Swing(较新) | 中 | 谨慎,先做退出实验 |
| DirectX / OpenGL 全屏程序 | 极高 | 绝对不要 SetParent |
| Electron / Chromium | 中 | 建议查其文档,渲染进程模型复杂 |
| 远程桌面容器(把别的进程窗口收进来) | 视目标而定 | 先做第 5 级那个测试 |
那个必做的测试(30 秒)
在写任何 SetParent 代码之前,先做这个验证:
- 把目标窗口
SetParent到你的容器; - 什么都不做,直接关闭你的容器进程;
- 看目标窗口是不是变成白屏/黑屏/残废状态。
如果残废了------恭喜,你省下了接下来两天的调试时间。
我把这个测试放在文章的最后,因为它只需要 30 秒,但它能在写代码之前就告诉你答案。而我,是在写了 6 个版本之后才补上这个测试的。
六、完整代码与实验记录
上面每一步的实验代码、诊断日志、版本演进(含被否决的方案)都在:
github.com/kkyule12322/dahua2-multi-boxing-tool
experiments/embed_test.c--- 四步实验:真嵌入 vs 遮挡式定责experiments/embed_test3.c--- 恢复/快捷键实验,发现单行道(614 行诊断日志)experiments/embed_test4.c--- 零SetParent架构验证器docs/DEVELOPMENT.md--- 完整开发记录(约 100+ 条日志级记录)
最终成品是一个 40KB 单文件 Win32 C 程序(零运行时依赖),做了 8 页签、多实例、托盘、快捷键路由等功能。
但如果你只想解决"窗口嵌进去出不来"这个问题,直接看 embed_test4.c 就够了 ------224 行,验证了零 SetParent 方案可行。
附:同系列文章
这个项目里还有 7 条同类的跨进程窗口管理踩坑,都写在 README 第一节。它们与具体游戏无关,适用于任何"把别的进程窗口收编到自己框架里"的场景:
SetParent是单行道(本文)- AWT 缓存了窗口坐标,会"跑位"
- 隐藏前台窗口会引发激活乱流 → 必须先置前新号、再隐藏旧号
- 跨进程
SetForegroundWindow被拒时的三层递进权限方案 - 抢前台是异步的,需要守护定时器
RegisterHotKey全局唯一 → 多实例必须改用WH_KEYBOARD_LL- 用
SetPropW做窗口归属标记 - owned window 跟随 owner 的 Z 序 → 弹窗总被游戏挡住
有想深挖的哪一条,留言或者开 issue 都可以。