前言

  这篇文章就是好好分享一下开发 QBinder Github地址 框架一路过来的心路历程,以及框架里的实现机制。
  如果要更直接了解框架的使用方法,推荐前往框架的 wiki 文档进行了解。 链接

  我以前做过一些 Vue 和 React 的前端开发,前端领域的数据绑定框架目前是如日中天,谁用谁知道,好用得很。
  本来今年的毕设想要做一个 CG资产管理库 的。
  打算要做一个数据绑定的框架,实现类似前端开发的体验,否则界面复杂之后,组件的通信会变得很难受。
  虽然 Qt 也内置了比较古老的 MVC 框架,还是能够用一下的,但是使用场景比较限制。
  只支持几个 View 以及衍生出来的组件,对于日常的 LineEdit CheckBox 的数据绑定均是没有支持。
  我当时打算先把这个数据绑定框架做出来,再去做 CG 资产管理库。
  当时师弟他们还做一个类似库去打比赛,我也从中帮了一些忙。
  花了不少时间研究如何将 WebGL 嵌入到 Python Qt 框架里面 链接
  于是就把数据绑定框架给搁置了(加上因为自己懒(:з」∠))
  再就是自己也没有想好到底怎样的数据绑定才是好用的,还是在网上找了很多 相关的案例,以及 Python 能够实现的黑科技进行研究.
  也就是那个时候研究了 future-fstring 库的实现原理 链接
  其实还研究很了解了很多别的东西,比如 ast 、 dis 等等 Python 内置的包,它们的作用以及能够实现的黑科技。
  并且也找了很多 github 上的 PyQt 相关的数据绑定框架来参考学习。

  当时很多研究并没有记录下来,而是仅仅存放在了我自己的框架的 research 文件夹 链接

alt

  我记得还考虑在 Qt Designer 里面加入自定义 property 来配置数据绑定,导致引申出了个 unicode_literal 的问题 链接
  后来有放弃了上面的方案,使用装饰器加 字典 配置的方式 具体可以参考我仓库的 QMVVM 分支,后面再详细说一下 链接

alt

  当时的确是实现了比较粗糙的数据绑定,但是使用上不太理想。
  而且终究折腾太久,时间不够了(:з」∠)
  差不多 5 月份了,毕设还卡在前期的框架搭建中,而且当时已经复工了(没有请假回学校)
  所以后来临时改了毕设的方案,将资产管理库改为全栈开发,顺便学一下时下很热的 Go 语言,跳过数据绑定框架的开发。
  还好我的指导老师很随意,从来没有打听过我的毕设进度,我偷梁换柱也完全没有关系,在我匆匆赶工之下,做了一个月把毕设给赶出来了 链接
  所以毕设的水平也就那样,内容量其实很单薄,还好大学的老师都不懂,随意吹嘘一下就给忽悠过去了。
  毕设论文也是按照写博客的方式写的,一点问题也没有(/ω\)

  后来毕设过去了之后,这个数据绑定框架的制作也就搁置了,真就一鼓作气,再而衰,三而竭吧。
  其实自己也是一直记着的,但是当时工作的原因,要开始搞 Unreal 的 Python 流程,于是乎就这了。
  一直到 10 月份之后,我总算脱离了项目组,可以做一些更加框架性的东西了。
  并且和 Unreal 相关的需求也少了,每当做 Maya Qt 的界面就一直惦记着想要把这个框架重新撸起来。
  然而 10 月份的时候是计划开始开撸 Houdini 教程的,还花了很多时间整理了相关的教程。
  最后的导火索是 10 月底,我忍不住还是给 焕焕 演示了以前做的不成熟的数据绑定框架案例。
  虽然他表示了肯定,然而也没有掀起什么波澜~
  我回去反思了一下,还是觉得目前的阶段的框架太拉胯了,没法很好进行推广。
  于是我一意孤行地搁置了 Houdini 学习计划,将大部分的空余时间都投入到这个框架的重构上。
  最近还把 仓库的名字改为更贴切一点名字, 叫做 QBinder 。
  目前重构了一个月,感觉进入了可以发布推广的阶段了,毕竟闭门造车并不好,这段时间一直觉得很多东西不完善,所以一直没有和别人交流分享。
  稍微完善了点,所以写下文章记录一下。

Python Qt 相似框架的研究

  我其实不太喜欢造轮子的,拿别人做好的轮子,再花点研究一下底层的原理就可以了。
  所以既然要做框架也不可能上来就是莽,看看别人都是怎么做的。
  然而 github 上能找到的资料并不多,只找到两个比较相近的

  另外也需要感受一下前端框架的数据同步的感觉。这里拿 Vue 的官方文档进行演示 链接 ↓↓↓

alt

dayu_widgets - MFieldMixin

  dayu_widgets 强烈推荐,我的 PyToolkit 的 qt 界面就是基于这个组件库做的。
  在它的案例里面可以找到一些使用 MFieldMixin 进行数据绑定的例子。
  比如 field_mixin_example.py

alt

  可以看到上面输入的时候下面的邮箱也可以进行数据同步。
  不过有个小 BUG , 输入的指针位置会因为数据更新被挪到了最后,我做框架的时候也遇到了这个问题。
  实现上很 pythonic ,但是用起来比起前端框架就繁琐很多。

dayu_widgets 需要预先在组件层提供好 qt 的 property 属性
注册用到的数据名称和初始值
使用 MFieldMixin 混合之后需要用 bind 方法来绑组件和 property 属性
最后还要通过调用函数来修改数据

alt

  毕竟 Python Qt 里面少了 前端的 html 模板作为界面描述,所以数据绑定的时候就需要用户提供更多信息,来满足绑定。
  我认为这是很难平衡的痛点。

pyqtconfig

  发现 pyqtconfig 这个框架也是很偶然
  某天在国外某个大佬教学网站里面找到了他的 github,然后随手搜了一下大佬的仓库找到的。链接

alt

  这个框架比起 MFiledMixin 要更加完善,还实现了 QSettings json xml 不同的格式来存储。
  调用上比起你 MFiledMixin 更加简洁一点。
  因为不需要提供 信号槽, 代码上通过字典的方式将 Qt 自带组件的信号槽实现了自动绑定。
  不过编写上基本还是和 MFiledMixin 一样的。

QMVVM 数据绑定

  结合上面两个框架,我想要数据绑定框架的使用更加简单,使用的代价降到最小。
  参考 Vue 框架的做法,会通过字典将所有相关的配置作为参数传入框架内。
  然后框架解析 dom 生成出 virtualdom 将配置关系和 templete 里面添加的属性进行匹配绑定。
  通过我也没搞清楚的 diff 算法,算出更新的 dom 对象实现高效更新。
  当然这种写法都是单独引入 vue 作为 js 脚本的用法。
  如果使用 vue 脚手架结合 node.js 就可以直接解析 vue 文件代码,让 MVVM 框架更加紧凑。

  然而目前 Python Qt 的代码环境下,缺失了 Html 层来描述组件状态,所以绑定就很难做得精简。
  Qt 之下其实可以使用 qml 作为代替,但是传统代码无法兼容,迁移也不方便。
  我其实还想到 ui 文件,毕竟是个 xml ,可以用 python 来解析当 view 来用,实现 view model。
  但是在 UI 文件上修改还是很蛋疼,而且 ui 可以转代码,但是代码没有转 ui 的工具,还不如上面两个框架通过代码解决好。
  所以我最后的想法回归到如何精简代码绑定数据的过程。

  参考了 Vue 的写法之后,我想通过 装饰器加上配置信息来进行绑定。

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
import QMVVM
from Qt import QtGui,QtCore,QtWidgets
class WidgetTest(QtWidgets.QWidget):

@QMVVM.store({
"state": {
"message": "", # 初始化数据
},
"methods": {
"label.setText":{ # 绑定 setter
"args":["message"], # action 传递的参数
"action": lambda a: "message is %s" % a, # action 作为响应传递的数据
},
"edit.setText":"message", # 直接绑定 等价 下面的扩充写法 ↓↓↓
# "edit.setText":{
# "args":["message"],
# "action" : lambda a: a ,
# },
},
})
def __init__(self):
super(WidgetTest, self).__init__()
self.initialize()

def initialize(self):
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)

self.edit = QtWidgets.QLineEdit()
self.label = QtWidgets.QLabel()
layout.addWidget(self.edit)
layout.addWidget(self.label)

if __name__ == "__main__":
app = QtWidgets.QApplication([])
widget = WidgetTest()
widget.show()
app.exec_()

alt

state 里面初始化响应的绑定变量。
methods 定义绑定的方法以及响应的参数和数据

  python 有很灵活的反射系统,可以通过字符串结合 getattr 可以获取到相应的属性。
  但是为了更加灵活,更好区分地获取不同的变量数据,就需要加入更多标识符来区分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@QMVVM.store({
"state":{
"count":0,
"count2":6,
},
"methods":{
"label.setText":"count",
"@label2.setText":{
"args":["$count","count"],
"action":lambda a,b: "<center>%s %s</center>"%(a,b),
},
"@label4.setText":{
"args":["$count","count2"],
"action":"$calculate",
},
"@label5.setText":{
"args":["count2"],
"action":lambda a :str(a),
},
},
})

alt

后来引入 @ 前缀来获取本地变量的 widget
methods 的参数上用 $ 符号只带本地的变量以及类方法。

  结果可以看到上面的配置越来越复杂,而且配置文件必须和代码对应,否则就会报错。
  报错还无法判断到底是哪一行配置出错了,为此我还专门定义了一个 SchemaError 的类来进行这种配置错误的处理。
  实现了一定程度的错误定位功能,总体而言用起来可能比之前两个框架还要繁琐。
  而且方法绑定无法做到 js 一般直接上匿名函数,所以还要用字符串来指向类方法。
  比较复杂的配置还需要通过正则来匹配配置的字符串数据,我写起来也很难受。
  虽然绑定关系可能因为全部放到一起会更加清晰,但是规则复杂,配置起来也不友好。

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
@QBinding.store({
"state": {
"selected": "",
"option_A": "A",
"option_B": "B",
"option_C": "C",
},
"computed":{
"item_list": [
"${selected}",
"${option_A}",
"${option_B}",
"${option_C}"
],
"*item_model": [
["${option_A}","${option_B}"],
"${option_B}",
"${option_C}",
["12"],
["asd","1234"]
],
},
"signals":{
"line.textChanged":"option_B",
# "combo.currentTextChanged":"selected",
}
})

alt

最后为了兼容 Qt 内置的 model 还做了更进一步的规则扩展
coumpted 参数可以通过 $符号获取上面定义 state 构建新的数据
* 前缀生成 model
singals 进行数据更新的绑定绑定

  做好了 model 这个 demo 之后,开发进度就停滞了。
  当时我觉得这个配置过于复杂了,研究 future-fstrings 之后一直想通过里面的黑科技实现代码转换。
  利用 python coding 的机制将 python 源码当做一个 html 进行绑定数据的提取,自动修改源码实现自动绑定。
  不过这个想法太复杂了,一点也不好实现。

  后来有和 dayu_widgets 的作者 鬼猫猫 聊过一下数据绑定相关的话题。
  她想要实现绑定之后还能保留代码的提示功能,确保代码文件的 pylint 不会报错。
  我觉得这个要求很合理,但是不好去实现。
  不过至少她给我指了个方向,无论用上多少黑科技,至少得兼容 pylint 才是好的写法。
  为了实现这个有点奢侈的想法,我又研究了一段时间。

  当时我的配置需要加上 methods 来配置 setter 数据响应的行为。
  后来我想到其实可以直接将我的 lambda 方法加到 setter 上。

1
self.label.setText(lambda:"CheckedNames: %s" % self.state.checkedNames)

  这样绑定 method 更加直观,我只要通过装饰器扩展一下 setText 方法让它接受 lambda 参数即可。
  加入 lambda 之后由于数据不是立刻算好传递的,所以每次 lambda 触发的时候都会用 self.state.checkedNames 最新的数值组合成字符串。
  也达到了我想要的数据更新的效果。
  这个想法对我后来重写出 QBinder 有很大启发~

QBinder

  时隔 5 个月之后,我决定重写之前 QMVVM 框架。
  而且框架命名也名不副实, 因为我根本就没有 Vue 这种的 ViewModel 对象。
  回想起之前定下的大方向目标,要 pylint 支持不会报错,并且最好还能自动提示出绑定的变量。
  那我这套字符串配置是不可能实现的了。

  既然要重构,就要玩大的,这次我将之前的装饰器加配置的方案彻底给改了。
  后来我觉得在 Qt 的 setter 上通过装饰器支持 lambda 绑定参数还挺好的写法,虽然看着很 evil
  但是耐不住用起来很方便。

  后来因为一些契机研究了 pymel 初始化过程 链接 ,在这里我发现 python 的 __clousure__ 黑科技。
  于是我觉得 lambda 的玩法还能进一步扩展,实现更好的支持,

QBinder 特性

  • 使用简单,修改代价小
  • 自动记录数据 进行持久化存储
  • lambda 特性简化绑定
  • 数据绑定可以实现动态 stylesheet
  • QEventHook qapp 全局事件钩子

数据绑定案例

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
from QBinder import Binder
from Qt import QtWidgets
from Qt import QtCore
from Qt import QtGui

class WidgetTest(QtWidgets.QWidget):

# NOTE 注册 绑定变量 binding
state = Binder()
state.text = "empty"
state.num = 1
state.val = 2.0

def __init__(self):
super(WidgetTest, self).__init__()

layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)

self.edit = QtWidgets.QLineEdit()
self.label = QtWidgets.QLabel()
self.button = QtWidgets.QPushButton("change Text")
layout.addWidget(self.edit)
layout.addWidget(self.label)
layout.addWidget(self.button)


self.button.clicked.connect(self.change_text)
# NOTE 数据绑定
self.edit.setText(lambda: self.state.text)
self.label.setText(lambda: "message is %s" % self.state.text)

self.spin = QtWidgets.QSpinBox(self)
self.label = QtWidgets.QLabel()
layout.addWidget(self.spin)
layout.addWidget(self.label)
# NOTE 数据绑定
self.spin.setValue(lambda: self.state.num)
self.label.setText(lambda: "num is %s" % self.state.num)

self.spin = QtWidgets.QDoubleSpinBox(self)
self.label = QtWidgets.QLabel()
layout.addWidget(self.spin)
layout.addWidget(self.label)
# NOTE 数据绑定
self.spin.setValue(lambda: self.state.val)
self.label.setText(lambda: "val is %s" % self.state.val)

def change_text(self):
self.state.text = "asd"

if __name__ == "__main__":
app = QtWidgets.QApplication([])
widget = WidgetTest()
widget.show()
app.exec_()

alt

  QBinder 实现了 Python 最小改动来实现数据绑定的效果。
  仅仅使用非常 tricky 的方式让 组件的 setter 可以支持 lambda 函数
  lambda 函数注入 Binder 注册的变量,就可以实现的数据绑定。
  当 binder 的变量改变的时候会自动触发 setter 进行更新~

结合 QEventHook 案例

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
from QBinder import Binder,QEventHook
from QBinder.handler import Set

from Qt import QtWidgets
from Qt import QtCore
from Qt import QtGui

event_hook = QEventHook()

class WidgetTest(QtWidgets.QWidget):
state = Binder()
state.text = "aasdsd"
state.num = 1
state.val = 2.0
state.color = "black"
state.spin_color = "black"

def __init__(self):
super(WidgetTest, self).__init__()
self.initialize()

def initialize(self):
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)

self.edit = QtWidgets.QLineEdit()
self.label = QtWidgets.QLabel()
self.button = QtWidgets.QPushButton("change Text")
layout.addWidget(self.edit)
layout.addWidget(self.label)
layout.addWidget(self.button)

self.button.clicked.connect(lambda:self.state.text >> Set("asd"))
self.edit.setText(lambda: self.state.text)
self.label.setText(lambda: "message is %s" % self.state.text)

event_hook.add_hook(self.edit,QtCore.QEvent.FocusIn,lambda:self.state.color >> Set("red"))
event_hook.add_hook(self.edit,QtCore.QEvent.FocusOut,lambda:self.state.color >> Set("black"))
self.label.setStyleSheet(lambda:"color:%s" % self.state.color)

self.spin = QtWidgets.QSpinBox(self)
self.label = QtWidgets.QLabel()
layout.addWidget(self.spin)
layout.addWidget(self.label)
self.spin.setValue(lambda: self.state.num)
self.label.setText(lambda: "num is %s" % self.state.num)

self.spin >> event_hook("HoverEnter",lambda:self.state.spin_color >> Set("pink"))
self.spin >> event_hook("HoverLeave",lambda:self.state.spin_color >> Set("blue"))
self.label.setStyleSheet(lambda:"color:%s" % self.state.spin_color)

self.spin = QtWidgets.QDoubleSpinBox(self)
self.label = QtWidgets.QLabel()
layout.addWidget(self.spin)
layout.addWidget(self.label)
self.spin.setValue(lambda: self.state.val)
self.label.setText(lambda: "val is %s" % self.state.val)

alt

  上面的案例结合 handler 和 QEventHook 可以实现更加灵活方便的组件写法。
  handler 的使用场景是通过 >> 运算符重载对 binding 进行操作。
  上面使用 Set 方法类就是用来解决 lambda 无法赋值的兼容方案。
  简单的数据操作就不需要声明冗余的函数

  QEventHook 会劫持 Qt 全局的事件响应,这样不需要继承组件来实现。
  可以通过我的 劫持 钩子来直接扩展组件的行为响应。
  QEventHook 使用单例模式,无论在哪里实例化都只获取同一个实例。(避免多重劫持导致性能下降)

  QEventHook 支持两种写法 add_hook 或者 >> 运算符
  >> 运算符重载的写法更加简洁,只是如果前面的组件有重载 __rshift__ 运算符会导致出错。
  事件绑定上支持 QEvent 和 字符串 两种写法。

复杂使用场景

  后面我模仿 Vue 做的 todo 案例写了一个 QBinder 实现的版本。

alt

  除了部分样式效果没有同步, Todo 相关的所有功能已经全部用 QBinder 绑定实现了~
  另外之前 QMVVM 架构留下的一些历史功能还是支持的。
  比如直接绑定一个 Model 变量,实现多个 View 同步数据。

QBinder 实现原理

  上面看到的效果是第一个成熟版本实现的绑定效果。
  中间的实现并不是一帆风顺的,磕磕绊绊地走过来,也踩了不少的坑。

自动绑定

  最初的想法我是参考了 pyqtconfig 的实现方式。
  通过字典来描述 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
import Qt
HOOKS = {}
HOOKS.update({
"QtWidgets.QWidget": {
"setStyleSheet": {
"type": str,
},
"setVisible": {
"type": bool,
},
},
"QtWidgets.QLabel": {
"setText": {"type": str, "getter": "text"},
},
"QtWidgets.QCheckBox": {
"setChecked": {
"type": bool,
"getter": "isChecked",
"updater": "stateChanged",
},
"setText": {"type": str, "getter": "text"},
},
})
def hook_initialize(hooks):
for widget, setters in hooks.items():
lib, widget = widget.split(".")
widget = getattr(getattr(Qt, lib), widget)
for setter, options in setters.items():
wrapper = binding_handler(getattr(widget, setter), options)
setattr(widget, setter, wrapper)
hook_initialize(HOOKS)

  大致的操作如上面所示
  默认只需要添加上 setter 即可绑定,倘若要双向数据绑定的话则需要添加上 updater
  但是这样需要手动配置 Qt 所有的方法,感觉不太现实。
  后来研究了一下 Qt 内置的机制,发现 Qt 有 meta 反射机制用来给 C++ 获取自身属性的。 链接
  虽然日常 Python 就带有反射特性,平时根本用不上,
  但是通过 Qt 的 meta 特性可以判断函数是否为 signal 对象,这给我双向绑定提供了自动化操作的可能。
  并且如果要兼容 Qt 的老版本,通过 meta 特性获取的函数比起手动配置考虑兼容要更好。

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
from collections import defaultdict
# NOTE https://stackoverflow.com/a/8702435
nestdict = lambda: defaultdict(nestdict)
HOOKS = nestdict()
_HOOKS_REL = nestdict()
qt_dict = {"QtWidgets.%s" % n: m for n, m in inspect.getmembers(QtWidgets)}
qt_dict.update({"QtCore.%s" % n: m for n, m in inspect.getmembers(QtCore)})
qt_dict.update({"QtGui.%s" % n: m for n, m in inspect.getmembers(QtGui)})

def byte2str(text):
# NOTE compat python 2 and 3
return str(text, encoding="utf-8") if sys.hexversion >= 0x3000000 else str(text)


def get_method_name(method):
# NOTE compat Qt 4 and 5
version = QtCore.qVersion()
name = ""
count = False
if version.startswith("5"):
name = method.name()
count = method.parameterCount()
elif version.startswith("4"):
name = method.signature()
name = name.split("(")[0]
count = method.parameterNames()
return byte2str(name), count

for name, member in qt_dict.items():
if not hasattr(member, "staticMetaObject"):
continue
meta_obj = getattr(member, "staticMetaObject")

# NOTE 目前最新版本使用了 json 配置进行 hook
for i in range(meta_obj.methodCount()):
method = meta_obj.method(i)
method_name, count = get_method_name(method)
if count and method.methodType() != QtCore.QMetaMethod.Signal:
if hasattr(member, method_name):
HOOKS[name][method_name] = {}
_HOOKS_REL[name][method_name.lower()] = method_name

for i in range(meta_obj.propertyCount()):
property = meta_obj.property(i)
if not property.hasNotifySignal():
continue
property_name = property.name()
method_name = _HOOKS_REL[name].get("set%s" % property_name.lower())
data = HOOKS[name].get(method_name)
if isinstance(data, dict):
updater, _ = get_method_name(property.notifySignal())
if updater:
data.update({"updater": updater, "property": property_name})


  上面的代码可以获取到对应的 updater 和 property ,结构可以参考这个 链接
  python3 之后 inspect 可以获取到函数的 signature 参数,但是要兼容 python2 的话只能使用这个。


  写文章的时候,我测试了代码,感觉到不对劲。
  Qt 的 Meta 机制并没有记录所有的函数,只是记录特别少的部分,所以无法 hook 到所有的方法上。
  我当时测试了 QLineEdit 的 setSelection 函数,就是没有 hook 到的。
  所以只能修改方案。

  然而 Qt 不能无脑 hook 所有的函数,因为 Qt 有些函数是 static 类型的。
  我 hook 了之后,测试一下案例就会出现 QApplication.postEvent 的错误。
  而这个错误是因为静态方法变成了 unbouded method 导致的。

  于是为了解决这个问题走了很大的弯路。
  首先我拿 QApplication 的 setStyleSheet 和 postEvent 进行方法类型对比。
  发现在不同的环境下的获取的类型不一样

setStyleSheet
PySide  <type 'method_descriptor'>
PyQt4   <type 'builtin_function_or_method'>
PySide2 <class 'method_descriptor'>
PyQt5   <class 'builtin_function_or_method'>

postEvent
PySide  <type 'builtin_function_or_method'>
PyQt4   <type 'builtin_function_or_method'>
PySide2 <class 'builtin_function_or_method'>
PyQt5   <class 'builtin_function_or_method'>

  所以在 PySide 下可以利用这个类型的不同过滤出可以 hook 的函数。
  但是在 PyQt 下的环境不可以区隔,如果用常规的装饰器写法会出错。
  最难受的是 hook 的时候不会出错,出错在后面执行方法的时候,所以无法用异常处理来调整 hook 的过程。
  我考虑到是 unbounded method 的问题其实可以让装饰器的 wrapper 函数提升为 类方法 去解决。
  然后就用了很特殊的方法来写装饰器。

1
2
3
4
5
6
7
8
9
10
11
class deco(object):
def __init__(self,func):
self.func = func

def wrapper(self,*args,**kwargs):
return self.func(*args,**kwargs)

def func():
pass

func = deco(func).wrapper

  好好的装饰器被我魔改成类方法,就可以解决 unbondedmethod 的问题。
  因为类已经实例化了, wrapper 返回的不是 unboundedmethod
  这可以解决 postEvent 的问题。
  但是在一些普通的 Qt 组件方法 比如 addWidget setLayout 就会出错
  因为传入 func 变成 Qt 没有实例化的方法了。
  所以这些方法需要用过去的装饰器来包裹才可以。

  于是折腾了好久又回到 static 函数区分的问题上。
  最后来回折腾,决定还是使用白名单过滤来解决问题,直接简单。
  反正我在 PySide 环境下已经获取出可以 hook 的名单,借助 Qt.py 的兼容,就可以直接过滤出 PyQt 的白名单。
  然后读取 json 名单进行过滤即可。

  所以绕了一圈其实和最初的做法差不多,只是借助 Meta 机制自动绑定了 Signal 实现双向数据绑定。

Binder 数据绑定容器

  过去 QMVVM 框架下,容器被我固定命名为了 self.state 获取。
  一方面 pylint 找不到 self.state 的定义,另外 self.state 变量如果已经有别的用途,会造成冲突。
  所以这次重构的容器 Binder 作为一个可以任意命名的实例化对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# NOTE 需要 QApplication 启动的环境下运行
from __future__ import print_function
from QBinder import Binder

state = Binder()
state.num = 1

print(type(state.num))
# <type 'int'>
print(type(state["num"]))
# <class 'QBinder.binding.Binding'>

# NOTE 注册事件
state["num"].connect(lambda:print('num change'))
state.num = 2
# num change

  Binder 重载 __setattr__ 这样给类对象添加 member 的时候可以自动将 member 转换为 binding 对象。
  确保外部的写法保持简洁。

  利用 binding 对象的 __get__ 方法确保每次从 binder 获取的值都是值的本身,而不是 binding 对象。
  但是一些特殊的情况,比如 binding 注册自定义更新事件的方法还是需要获取 binding 对象来执行。
  于是我重载了 __getitem__ 和 __setitem__ 来用字典取值的方式获取变量的 binding 对象。

1
2
3
4
5
6
from QBinder import Binder

state = Binder()
dispatcher = state('dispatcher')
dumper = state('dumper')

  为了避免 Binder 绑定 binding 对象的时候和自身命名的方法产生冲突
  Binder 类重载 __call__ 方法,然后通过 命令 模式。
  将对应方法 通过 BinderDispatcher 类进行分发调用。

  输入对应字符串会调用 BinderDispatcher 对象下的方法
  输入 ‘dispatcher’ 是返回自己,获取到 BinderDispatcher 对象。
  输入 ‘dumper’ 则是获取 BinderDumper 类来进行特殊存储标记。
  具体的调用规则可以参考文档,如果输入非法调用名称会直接报错。
  往后也可以考虑可以扩展用户自定义的方法~

Binding 绑定对象 & lambda 绑定

  最初我先想法是通过 lambda 函数的 __closure__ 获取闭包的数据。
  然而这个方法只能获取到传递到 lambda 的变量字符串。
  也无法判断这些变量是否对应到实现定义的 binder 里面。
  并不是那么好从这个方向获取 lambda 里面绑定的变量。

  后来我测试 Python 类的 __get__ 变量描述符可以在 lambda 里面触发。
  那么当 __get__ 触发的时候我可以将触发的 binding 存储到一个数组里面。

alt

  我用 contextmanager 通过 with 语句来简化代码

alt

  触发 lambda 的时候包裹一下 with 追踪一下 binding 变量就可以知道 binding 和 setter 关联的数组。
  借助这个方法就可以给 binding 绑定 setter 函数
  binding 则模仿 Qt 信号槽接口写了个很简易的函数注册执行功能

alt

  connect 注册 setter 更新函数。
  emit 则遍历执行所有的 更新函数。
  当 binding 的数值发生修改的时候会触发 BinderBase 的 __setattr__ 方法
  __setattr__ 再触发 binding 的 set 方法来 emit 实现数据同步更新。

  Binding 对象本身是继承 QStandardItem 的,重载了部分相关方法,用来兼容 Qt 的 MVC 框架。
  Binding 在设置对象的时候会根据对象类型自动重载相关的运算符。

  Binding 因为继承 QStandardItem 并不是继承 QObject 的。
  因此无法使用 Qt 自带的 signal , 我这里用 Python 的方式简单实现了 signal 的 API。
  不过并没有考虑到多线程交互的情况,可能还是有必要改为 signal 来触发函数比较好。

FnBinding 函数绑定

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
from QBinder import Binder

state = Binder()
state.callback = FnBinding()

@state.callback
def _():
print('call')

state.callback()
# call

state.cls_method = FnBinding()
class Test(object):
@state.cls_method
def callback(self):
print('class callback',self)

instance = Test()
# NOTE 因为绑定的是 类方法 需要注入 instance
state.cls_method(instance)
# ('class callback', <__main__.Test object at 0x00000259E1E93FD0>)
# NOTE 如此注入 instance 方便 signal 链接
state.cls_method[instance]

# NOTE 最初的函数绑定方法 | 不需要拆开成两行 但是 pylint 无法识别定义
@state('fn_bind')
def test():
pass

  FnBinding 可以绑定组件的方法,方便其他组件进行调用。
  目前类方法绑定的方式还是有点不友好,需要将实例传进函数里面,有待优化。

  另外之前的绑定方式是使用 dispatcher 驱动的 fn_bind 函数。
  后来还是因为确保能够代码更加优雅,还是不推荐使用了。

  函数绑定的实现原理比较取巧。
  配合 __call__ 方法实现装饰器调用和函数触发调用。
  之前还写过一个 FnHook 的版本 和 FnBinding 类是分开的,后来觉得其实是可以合并到一起的。
  只是会把 FnBinding 给写得稍微复杂了点。

alt

  为了向前支持 fn_bind 的写法,所以 __init__ 保留了绑定的判断
  如果不是老方案进行函数绑定。
  那么装饰器触发的 __call__ 会进入 self.binded 的判断里面进行函数绑定。
  最后会将当前的类信息和方法名 存储到字典里面。
  后面会在 binder 对象里面找有没有添加到 binder 的类实例,实现实例的自动注入。
  但是如果 binder 存在多个实例,那么默认只会拿第一个找到的实例,不是很好的方案,有待改进。

  connect_binder 函数会在 state.callback = FnBinding() 赋值语句中通过 __setattr__ 触发。
  将后面绑定函数需要的信息补充完整。

  __getitem__ 的操作方便注入 instance 给 signal 进行连接。

QEventHook 全局事件钩子

  这个实现活用了 Qt 的 eventFilter 机制 链接

alt

  给 QApplicaiton 的 instance 执行 installEventFilter 就可以 hook 到当前应用下所有的 Qt 事件。
  这个就非常灵活, DCC 端也是完全支持的。
  根据这个方法就可以捕获到特定组件的特定事件。
  后来开发这个框架的过程中,突发奇想,如果我可以提供这些 event 事件接口的话。
  我就不需要写一个新类去重载 虚函数事件了。(虚函数事件指的是 mousePressEvent 等的函数)
  有时候就是想要在默认的组件上扩展一些小功能。
  那么全局事件过滤来实现就会方便很多。

  QEventHook 的用法已经在上面领略过了。

alt

  代码上使用 单例模式, 类在任何地方实例化都不会重复 添加事件过滤
  重载了 类 的运算符,使得扩展组件的写法更加好看。
  不过如果组件也重载了相关的运算符会出错的,所以这里保留了 函数 添加方案。
  事件定义支持 QEvent 对象以及 字符串。

数据自动存储加载

1
2
3
4
5
6
7
8

state = Binder()

dumper = state('dumper')
with state('dumper'):
state.num = 1
state.text = "text"

  上面有演示过 state 通过命令调用 dispatcher 的方法
  state(‘dumper’) 会直接返回 dumper 对象。
  默认配置下 dumper 会在事件循环里面自动对双向绑cc定的 binding 进行读取和输出。
  如果要对特殊的 binding 属性进行绑定,可以用 给 dumper 对象使用 with 语句。
  with 语句下添加的 binding 会自动记录进行存储。

  实现原理其实类似于 lambda 绑定一样。
  会在 binder __setattr__ 下查是否开启 trace ,开启就会将相关的 命名 存储到数组里面。
  再通过 __exit__ 将数组的数据提取存放到 dumper 的名单里面进行 dump


  自动存储是通过 lambda 绑定实现的。
  如果 lambda 绑定发现数据属于双向绑定状态会自动加入定时器触发 自动 读取和存储 功能
  通过 QTimer.singleShot 确保在下一个 eventloop 里面实现存储数据的添加。

alt

  自动存储会将文件存储到 临时目录,通过 md5 以 json 的格式进行存储。
  其实用数据库比如 Python 自带的 sqlite 也是完全可以的,后期有时间可以把这些功能都补充一下。

  上面的自动存储 md5 对应的唯一计算方案也是我想了好久用比较取巧的方式实现的。
  首先 Binder 实例化的时候会自动将实例存储到 BinderCollector 里面。
  而这个添加的过程是和 Qt eventloop 关联起来的。
  每次 eventloop 执行的时候就会将 Binder 添加到 BinderCollector 对应的字典里面,字典的值用 uuid 实现,确保不会碰撞。
  那么每次 eventloop 跑完之后的数组序号对应的 Binder 都是可以匹配上的。
  这样可以确保在 DCC 软件里面每次启动界面 Binder 实例都是对应同样的序号。

  最后为了区分脚本, json 文件的名称是 脚本路径+序号 再用 md5 算出来的。
  这样我在 DCC 环境下多开个工具的时候读取的数据还是固定路径的数据。

handler 扩展操作

  handler 函数是用来简化 binding 数据操作的类。
  通过重写 __rrshift__ 函数重载了 >> 运算符。
  实现上其实也没有什么特殊的,运算符重载的好处就是可以兼容 lambda 里面对 binding 赋值。
  目前有考虑将 handler 做成可扩展的 API ,方便对 binding 做更复杂的操作。

  另外 handler 是如何获取到 binding 对象的,毕竟直接 state.属性 借助 __get__ 方法获取的是实实在在的值。
  但是 handler 却可以通过这个获取到 binding 对象。
  其实还是利用 binding 的 __get__ 方法将自身存放到数组里面。

alt

  handler 就可以通过 类的 _inst_ 属性来获取获取到数组。
  之所以用数组而不是直接存储 self ,是应为直接获取 self 还是出发了 __get__ 导致获取的不是 binding 对象。
  用数组包裹一层才是获取到 binding 的对象。

  handler 里面就是 binding = Binding._inst_.pop() 这样就可以为所欲为地对 binding 对象进行操作了。

总结

  我过去重来没有做过这么复杂的框架,也就年初的时候搞了两个 Maya 工具 mpdb CommandLauncher
  要做比较庞大的框架,感觉自己的经验很有限。
  以前看过网上一些 Design Pattern 设计模式的教程,都是一通讲解猛如虎,实践才知不清楚。
  毕竟是闭门造车,只能自己摸着石头过河了。
  所以这个框架用到的设计模式我自己也说不清楚,就是怎么方便调用怎么来的。

  这篇文章也断断续续写了 1 个多星期,因为写的时候发现 框架出现了一些问题。
  于是又去把框架优化好再继续写。

  这个框架的目标打算是兼容 python2&3 同时兼容 PySide 和 PyQt 多个版本。
  希望能借助 github 的生态让更多人参与到这个框架的开发。
  也希望这个数据绑定框架能够方便到我们这些工具人,提高界面开发的效率~

  这个只是个人填坑的记录,写得太冗长了,而且还把以前的历史版本都翻出来鞭尸了。
  后续还要做个更精简的 wiki 文档~ ▄︻┻┳═一