前言

  最近打算开发一个 DCC 间共同的资源浏览器,计划通过 WebGL 实现三维视窗的效果。
  Maya 内置的 PySide2 是可以构建浏览器的。
  但是对 WebGL 的支持非常糟糕 ,那么就需要嵌入一个通用的的三维浏览器窗口来实现。

  即便是 maya 之外的环境实现的 PyQt WebEngine 实现的效果都不太理想,比如 threejs 官方编辑器会没有显示白屏。


  于是我就开始想如何如何才能更好地将浏览器嵌入到 DCC 软件中,从而让前端技术可以引入进去。

  欢迎到我的 github 仓库 CefWidget上查看嵌入的源码

技术选型

  虽然 PyQt 的 WebGL 似乎有点问题,但是基础的一些显示起码是可以用的。
  但是要将 Maya 外部的 Python 跑起来的话需要解决将外部第三方的窗口嵌入到 Maya 窗口里面的问题。
  我看了网上大部分的解决方案都需要获取 窗口 hwnd 也就是 不同系统下的窗口 ID 然后只能通过 Qt5 的方案来嵌入。 QWindow.fromWinId()
  首先获取 hwnd 还没有多平台兼容的方案,而且由于只有Qt5支持嵌入,所以 Qt4 无法兼容了。

  后来我也尝试在 Maya 嵌入 winId 的,但是无法直接传入整形数,可能还需要一些 ctypes 或者 shiboken 转换方案。
  太麻烦了我就没有找解决方案。
  而且第三方软件接入的痛点在于完全独立于目前运行的程序,如果软件本身没有开放的接口就无法调用了。
  所有的 UI 程序都是一个不断监听进行事件处理的死循环,即便是 Qt 嵌入 Qt 两头都是源码清楚的情况下也无法交互。
  因为两者的事件监听是不共通的,这边 Qt 窗口的信号无法在另一头的 Qt 窗口上触发。
  所以就导致修改窗口大小不同步,点击按钮无法触发另一个窗口的事件诸如此类的问题。


  后来我转换思路,不再搜索嵌入第三方窗口,而是嵌入浏览器,反而是开阔了很多。
  其中就找到了很好的一个方案,那就是 CEF。
  CEF 全称是 Chromium Embeded framwork, 因此就是针对 Chromium 谷歌浏览器内核的嵌入第三方的框架
  并且 github 上还可以找到 cefpython 库实现了 CEF 框架的 python 绑定。

  看了一下官方的那里,不仅仅支持 Qt ,很多 Python 的图形化框架都是支持嵌入的,而且嵌入的方式是获取 Qt 的 winId , 背后的跨平台嵌入操作由框架完成
  因此我就决定使用 cefpython 方案了。

cefpython

  由于 cefpython 是由 C++ 绑定过来的,因此就不用想在 maya 这些软件上面调用了,不可能兼容的。
  当然有源码或许可以用 mayapy 重新编译一个针对 Maya 版本的 cefpython ,但是这样跨软件兼容的实现就失败了。
  因此一定要活用 cef 框架自带的嵌入功能,以第三方的形式嵌入到 Qt 的 Widget 里面。

cefpython 使用

  在研究如何使用 cefpython 之前还是需要了解清楚这个库是怎么使用的。
  网上可以找到国内的一些文章

1
2
3
4
5
6
7
8
9
10
from cefpython3 import cefpython as cef
import platform
import sys
def main():
cef.Initialize()
cef.CreateBrowserSync(url="http://www.baidu.com",window_title="Hello World!")
cef.MessageLoop()
cef.Shutdown()
if __name__ == '__main__':
main()

  运行 pip install cefpython3 安装库之后就可以调用了。
  我去掉了上面的版本检测,让代码看起来更加简单一些。


  上面的例子就是个 hello world 例子而已。
  那么如何才能将 浏览器 嵌入到 Qt 的窗口当中呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

import sys
import os
import ctypes

from PySide.QtGui import *
from PySide.QtCore import *
from cefpython3 import cefpython

class CefWidget(QWidget):
browser = None
def __init__(self, parent = None):
super(CefWidget, self).__init__(parent)
self.show()
self.createTimer()

def createTimer(self):
self.timer = QTimer()
self.timer.timeout.connect(self.onTimer)
self.timer.start(10)

def onTimer(self):
cefpython.MessageLoopWork()

def embed(self):
# it needs to be called after setupping the layout,
windowInfo = cefpython.WindowInfo()
windowInfo.SetAsChild(int(self.winIdFixed()))
self.browser = cefpython.CreateBrowserSync(windowInfo, navigateUrl="https://blog.l0v0.com/my_work/OPENGL_homework/old_Method/")

def winIdFixed(self):
# PySide bug: QWidget.winId() returns <PyCObject object at 0x02FD8788>,
# there is no easy way to convert it to int.
try:
return int(self.winId())
except:
if sys.version_info[0] == 2:
ctypes.pythonapi.PyCObject_AsVoidPtr.restype = ctypes.c_void_p
ctypes.pythonapi.PyCObject_AsVoidPtr.argtypes = [ctypes.py_object]
return ctypes.pythonapi.PyCObject_AsVoidPtr(self.winId())
elif sys.version_info[0] == 3:
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object]
return ctypes.pythonapi.PyCapsule_GetPointer(self.winId(), None)

def moveEvent(self, event):
cefpython.WindowUtils.OnSize(int(self.winIdFixed()), 0, 0, 0)

def resizeEvent(self, event):
cefpython.WindowUtils.OnSize(int(self.winIdFixed()), 0, 0, 0)

class MainWindow(QMainWindow):
def __init__(self, parent = None):
super(MainWindow, self).__init__(parent)
self.setGeometry(150,150, 800, 800)

self.view = CefWidget(self)

m_vbox = QVBoxLayout()
m_label = QLabel("Another Widget")
m_label.setMaximumHeight(100)

m_vbox = QVBoxLayout()
m_vbox.addWidget(m_label)
m_vbox.addWidget(self.view)

frame = QFrame()
frame.setLayout(m_vbox)
self.setCentralWidget(frame)

self.view.embed()

if __name__ == "__main__":

settings = {}
settings["browser_subprocess_path"] = "%s/%s" % (
cefpython.GetModuleDirectory(), "subprocess")
settings["context_menu"] = {
"enabled": False,
"navigation": False, # Back, Forward, Reload
"print": False,
"view_source": False,
"external_browser": False, # Open in external browser
"devtools": False, # Developer Tools
}

cefpython.Initialize(settings)

app = QApplication(sys.argv)
win = MainWindow()
win.show()
app.exec_()

  没错主要是通过 ctypes 转换 self.winId 的方法来获取窗口的整数 ID
  后面只需要将 id 传入到 SetAsChild 方法里面就可以实现嵌入了。
  需要注意的是需要用定时器来 MessageLoopWork 的方法来不断同步浏览器的状态。

  上面的例子还只是个简化版的嵌入,更进一步的多平台兼容可以参照官方提供的 qt.py

cefpython 打包

  当然上面的例子是无法在 DCC 软件里面跨平台使用的,因此需要通过 pyinstaller 打包成可执行的 exe 文件。
  好在官方提供了 cefpython 用 pyinstaller 打包的例子
  不过由于要打包自己的脚本还需要修改一下 spec 文件里面定位的编译源代码路径。
  并且我自己打包测试的时候出了一些 Bug 打包失败,发现 hook-cefpython3.py 的输出路径缺少了个 “.”

  另外编译的时候虽然是直接运行 pyinstaller.py 文件,但是命令行运行路径必须要切换到当前脚本的目录,否则就无法找到 hook-cefpython3.py 脚本。
  我上传到 github 的仓库已经修复了这个问题。


  打包完成之后会有 100 多M那么大(:з」∠)
  其中主要是 libcef.dll 这个就有 90+M 的大小,没办法这个就是使用 cef 的代价。

rpyc 通信

  成功打包之后的确可以实现浏览器嵌入到任意的 Qt 窗口了。
  但是正如我在技术选型上提到的,组件是完全独立,如何解决两个窗口之间的通信问题就很关键。
  最原始的方法其实可以通过本地的 IO 操作,不过网上查了资料之后还是用了 socket 端口的方案。
  轮子是不可能自己去造的,于是就用了现成的 rpyc 方案,实现 python 程序的相互通信。
  而且 rpyc 没有 C 相关的调用,以你可以再不同的平台下通用。

  原本是打算将 rpyc 和 浏览器 两者的调用合并到一起的。
  但是问题就来了, rpyc 也会开启一个死循环进行监听,这和 浏览器 的事件调用冲突了。
  所以没办法,最后也将 rpyc 的 server 单独分离出来调用。

  由于运行的 CefBrowser 是死循环的缘故,因此我只能让 CefBrowser 不停地连接 rpyc 保持状态的同步。
  这个操作增加了很多非必要的开销。

socket 通信

  写文章的时候感觉通过 rpyc 独立成三份代码有点奇怪,应该可以将 浏览器和 rpyc 调用合并到一起的。
  毕竟 rpyc 的阻塞肯定也是类似于 while True 的方式实现的,刚好可以和 浏览器的 UI 事件处理合并,就不需要单独分出来了。
  而且这样也就不需要 浏览器 不断地连接 rpyc 实现同步了,调用起来更加高效。

  花了大半天的代码修改,最后决定使用 python 自带的 socket 模块, 根本不需要 笨重的 rpyc 模块了。
  当然转换到 socket 之后也遇到了很多坑,花了我贼多的时间。
  特别是 创建浏览器嵌入的过程,我生成 uuid 记录到 服务端的字典里面,下次调用就知道这个 CefBrowser 对应的 CEF 框架的浏览器了。
  一开始 uuid 是在服务端生成的,所以我要通过 socket 将这个 uuid 数据返回到 客户端。
  但是这个操作会有个奇葩的BUG,我调试了好久才发现只要是获取服务端返回值并且添加了窗口的嵌入 CreateBrowserSync 这个函数就会直接卡死。
  但是如果不嵌入浏览器的话又完全没有问题,不获取返回值也没有问题,让我着急得干瞪眼了。
  后来测试了好久,灵机一动,uuid其实可以在生成 CefBrowser 类的时候生成然后传进 服务端,这样就不需要 获取 返回值了。
  通过这个方法解决了这个坑爹的卡死问题。

CefBrowser 调用

  整体的原理已经在上面说了。
  后续就是做一个 QWidget 包裹成 CefBrowser 来完成 rpyc 相关的通信处理。
  简化 API 调用的复杂度。

  • loadUrl str 加载网页路径
  • reload 刷新当前页面
  • goBack 后退
  • goForward 前进
  • getUrl 获取当前的网页地址
  • embed int str 传入窗口 id 嵌入窗口,后一个是初始化网页的传参

  另外需要注意的是 embed 必须要所有的窗口构建完成才可以使用。
  否则获取的 winId 不对无法嵌入会报错的。
  我特意做了一个装饰器 autoCefEmbed 可以嵌入到主窗口 UI 的 init 函数里面,自动实现窗口的 embed
  autoCefEmbed 装饰器会先开启 Widget 的显示,然后再隐藏,否则嵌入浏览器将会出错。

  具体的运行例子可以参照 master 仓库下的 test 文件夹中的 importTest.py
  只要将路径改为自己本地的 CefWIdget 路径就可以运行起来了。

CEF 框架缺点

  CEF实在是太大了,虽然完美实现了跨平台多端兼容,但是 libcef.dll 的大小显然非常的不友好。
  主要是因为集成了很多不需要的功能比如说js的后台调试器之类的。

  而且大小几乎接近便携版的 Chrome 却依然无法支持 h.264 等诸多视频格式,H5视频都不支持,貌似是商业license的缘故

  后来我在网上找到了更好的嵌入方案 miniblink
  这个对 CEF 框架做了极致的大小优化,而且还可以将 node.js 集成实现 electron 的运行。
  只可惜是纯 C++ 的调用,也不知道是否可以做到类似 CEF 一样嵌入的效果。
  以后有时间再进一步研究 C++ 的问题。