# Technical Documentation: liangxiegame/QFramework

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/liangxiegame/QFramework)

### Overview (`README.md`)

[](https://github.com/liangxiegame/QFramework/blob/master/LICENSE)

# QFramework 简介 Intro

[中文]()|[English](README_EN.md)

[QFramework](https://github.com/liangxiegame/QFramework) 是提供一套简单、强大、易上手、符合 SOLID 原则、支持领域驱动设计（DDD）、事件驱动、数据驱动、分层、MVC 、CQRS、模块化、易扩展的架构，她的源码只有不到 1000 行，你可以完全把她放在你的笔记应用里保存。

## 提供的架构图



## 举个例子（一图胜千言😂）



## 各种情况的示意图



## 架构使用规范

**QFramework系统设计架构分为四层及其规则：**

* 表现层：ViewController层。IController接口，负责接收输入和状态变化时的表现，一般情况下，MonoBehaviour 均为表现层
    * 可以获取System
    * 可以获取Model
    * 可以发送Command
    * 可以监听Event
* 系统层：System层。ISystem接口，帮助IController承担一部分逻辑，在多个表现层共享的逻辑，比如计时系统、商城系统、成就系统等
    * 可以获取System
    * 可以获取Model
    * 可以监听Event
    * 可以发送Event
* 数据层：Model层。IModel接口，负责数据的定义、数据的增删查改方法的提供
    * 可以获取Utility
    * 可以发送Event
* 工具层：Utility层。IUtility接口，负责提供基础设施，比如存储方法、序列化方法、网络连接方法、蓝牙方法、SDK、框架继承等。啥都干不了，可以集成第三方库，或者封装API
* 除了四个层级，还有一个核心概念——Command
    * 可以获取System
    * 可以获取Model
    * 可以发送Event
    * 可以发送Command
* 层级规则：
    * IController 更改 ISystem、IModel 的状态必须用Command
    * ISystem、IModel状态发生变更后通知IController必须用事件或BindableProperty
    * IController可以获取ISystem、IModel对象来进行数据查询
    * ICommand不能有状态
    * 上层可以直接获取下层，下层不能获取上层对象
    * 下层向上层通信用事件
    * 上层向下层通信用方法调用（只是做查询，状态变更用Command），IController的交互逻辑为特别情况，只能用Command

（照抄自：[学生课堂笔记1](https://github.com/Haogehaojiu/FrameworkDesign)）

### 运行环境

* Unity 2018.4.x ~ Unity 6.x

## 安装

* QFramework.cs 
    * 直接复制[此代码](QFramework.cs)到自己项目中的任意脚本中
* QFramework.cs 与 官方示例
    * [点此下载 unitypackage](./QFramework.cs.Examples.unitypackage)

* QFramework.ToolKits
    * [点此下载 unitypackage](./QFramework.Toolkits.unitypackage)
* QFramework.ToolKitsPro
    * 从 [AssetStore](http://u3d.as/SJ9) 安装



## 资源

| **版本** |                                      |                                                          |
| ----------------------  | ---------------------------------------- | ------------------------------------------------------------ |
| QFramework.cs | QFramework 本体架构的实现 | [文件](QFramework.cs) |
| QFramework.cs  示例 | QFramework.cs 与官方示例： CounterApp、《点点点》、FlappyBird、CubeMaster、ShootingEditor2D、贪吃蛇等 | [点此下载 unitypackage](./QFramework.cs.Examples.unitypackage) |
| QFramework.Toolkits | QFramework  集成 CoreKit/UIKit/ActionKit/ResKit/PackageKit/AudioKit 等全部官方工具（已包含 QFramework.cs 和 示例) | [点此下载 unitypackage](./QFramework.Toolkits.unitypackage) |
| QFramework.Toolkits.Demo.WuZiQi | 使用 QFramework.Toolkits 开发的五子棋 Demo（需要安装好  QFramework.Toolkits） | [点此下载 unitypackage](./QFramework.Toolkits.Demo.WuZiQi.unitypackage) |
| QFramework.Toolkits.Demo.Saolei | 使用 QFramework.Toolkits 开发的扫雷 Demo（需要安装好  QFramework.Toolkits） | [点此下载 unitypackage](./QFramework.Toolkits.Demo.SaoLei.unitypackage) |
| QFramework.ToolKitsPro | 在 ToolKits 基础上集成更多好用的工具的版本（已包含 QFramework.Toolkits） | [AssetStore](http://u3d.as/SJ9) |
| **群友案例** |  |  |
| 赛车游戏《Crazy Car》 | 群友 [TastSong](https://github.com/TastSong) 使用 QF 进行重构的开源赛车游戏 | [游戏主页(Github](https://github.com/TastSong/CrazyCar)) |
| **社区** |  |  |
| QQ 群：623597263 | 交流群 | [点击加群](https://qm.qq.com/cgi-bin/qm/qr?k=G4DZ_6qMbN8miP8RcRD9NdhNcVyrD88F&jump_from=webapi&authKey=P8WO3esK/KbDxWPHI5F3EC04IPT6jDSkk/tl73/EHIcRkMTvnLhwpTK1PtHr6V+p) |
| 腾讯频道 | 中文社区 | [点击加入频道](https://pd.qq.com/s/cfe1690jf) |
| Discord | 英文社区 | [点击加入频道](https://discord.gg/PHqHX5v5SE) |
| **教程** |  |  |
| 《框架搭建 决定版》    | 教程 QFramework  的核心架构是怎么演化过来的？ | [课程主页](https://learn.u3d.cn/tutorial/framework_design)\|[学生课堂笔记1](https://github.com/Haogehaojiu/FrameworkDesign)\|[学生课堂笔记2](https://github.com/Haogehaojiu/ShootingEditor2D) |
| **产品案例** | 如果用了 qf 并且想要在如下列表中登记，可以在 github/gitee 的 issue 里发帖子,也可以加 qq 群，也可以用邮箱联系凉鞋 liangxiegame@163.com，当然如果看到使用 qf 的项目，我也会先去尝试征得同意再放到如下列表。 |  |
| **精选置顶** |  | |
|  | 独立游戏《盒子面包坊》 | [TapTap](https://www.taptap.cn/app/384085) \| [Steam](https://store.steampowered.com/app/2942950/_/) |
|  | 独立游戏《观鸟笔记》 | [Steam](https://store.steampowered.com/app/4111370/) |
|  | 独立游戏《乌合之众》 | [Steam](https://store.steampowered.com/app/1741170/_/) |
| **按登记时间** |  | |
|  | 独立游戏《三国掷：崛起》 | [Steam](https://store.steampowered.com/app/4789730/_/) |
|  | 独立游戏《银河摸鱼人》 | [Steam](https://store.steampowered.com/app/1731000/) |
|  | 独立游戏《ScaleBox》 | [Steam](https://store.steampowered.com/app/3528380/ScaleBox/) |
|  | 独立游戏《我的玫瑰》 | [Steam](https://store.steampowered.com/app/3246640/_/) |
|  | 独立游戏《蚀界档案》 | [TapTap](https://www.taptap.cn/app/222365) \|[Steam](https://store.steampowered.com/app/1976540/_/) |
|  | 独立游戏《X-teriods》 | [Steam](https://store.steampowered.com/app/3342540/Xteroids/) |
|  | 独立游戏《你好茄子：精灵的诞生》 | [Steam](https://store.steampowered.com/app/2375290/_/) |
|  | 独立游戏《当火车鸣笛三秒》 | [Steam](https://store.steampowered.com/app/1563700/_/)\|[TapTap](https://www.taptap.cn/app/208258) |
|  | 独立游戏《你好茄子》 | [Steam](https://store.steampowered.com/app/2091640/Hi_Eggplant/) |
|  | 独立游戏《第一座山》 | [Steam](https://store.steampowered.com/app/2149980/The_First_Mountain/) |
|  | 独立游戏《推灭泡泡姆》 | [TapTap](https://www.taptap.com/app/233228) |
|  | 独立游戏《鬼山之下》 | [Steam](https://store.steampowered.com/app/1517160/_/) |
|  | 手机游戏《谐音梗挑战》 | [TapTap](https://www.taptap.com/app/201075)        |
| **其他相关教程** |  |  |
| 《原创独立游戏制作：类星露谷》（凉鞋） | 原创独立游戏制作教程，有用到 PlayMaker、QFramework.Tookits | [b 站](https://space.bilibili.com/60450548/channel/collectiondetail?sid=919279) |
| 《QFramework 教程年会员》 | QFramework 相关的课程案例 | [GamePix](https://www.gamepixedu.com/vip/?levelId=1) |
| 《QFramework 教程永久会员》 | QFramework 相关的课程案例 | [GamePix](https://www.gamepixedu.com/goods/show/88) |



## Star 趋势（如果项目有帮到您欢迎点赞）

[](https://starchart.cc/liangxiegame/QFramework)



### 作者

* [凉鞋 liangxiegame](https://github.com/liangxiegame)

### 贡献者

* [京产肠饭]( https://gitee.com/JingChanChangFan/hk_-unity-tools)

* [猫叔(一只皮皮虾)]( https://space.bilibili.com/656352/)

* [TastSong]( https://github.com/TastSong)

* [misakiMeiii](https://github.com/misakiMeiii)

* [soso](https://github.com/so-sos-so)

* [蓝色孤舟 gdtdftdqtd](https://github.com/gdtdftdqtd)

* [h3166179](https://github.com/h3166179)

* [葫芦 WangEdgar](https://github.com/WangEdgar)

* New一天

* 幽飞冷凝雪～冷


### 优秀的 Unity 库、框架

- [YokiFrame](https://github.com/HinataYoki/YokiFrame):  支持i Unity 与 Godot 的 C# 游戏框架，提供架构分层、事件系统、状态机、对象池、资源管理、UI 工具和编辑器扩展，帮助开发者构建更清晰的游戏项目。
- [StellarFramework](https://github.com/StarrDream/StellarFramework): 一套专为独立开发者项目打造的高性能 Unity 开发框架
- [ET](https://github.com/egametang/ET)：ET Unity3D Client And C# Server Framework
- [IFramework（OnClick）](https://github.com/OnClick9927/IFramework) Simple Unity Tools
- [JEngine](https://github.com/JasonXuDeveloper/JEngine)  使Unity开发的游戏支持热更新的解决方案。
- [TinaX Framework](https://tinax.corala.space/) “开箱即用”的Unity独立游戏开发工具

### 代码规范完全遵循:

[QCSharpStyleGuide](https://github.com/liangxiegame/QCSharpStyleGuide)


### 赞助 Donate:

* 如果觉得不错可以在 [这里 Asset Store](http://u3d.as/SJ9) 给个 5 星哦~ give 5 star
* 或者给此仓库一个小小的 Star~ star this repository
* 以上这些都会转化成我们的动力,提供更好的技术服务! 



### 鸣谢

感谢 JetBrains 公司提供的使用许可证！

<p><a href="https://www.jetbrains.com/?from=QFramework ">
<img src="https://file.liangxiegame.com/2bf40802-c296-4bdc-bc8a-718000503771.png" alt="JetBrains的Logo" width="20%" height="20%"></a></p>

感谢 OpenAI 通过 Codex for OSS 计划对本项目的开源维护提供支持。

[](https://openai.com/codex/)

感谢 GitHub Copilot 对本项目开源维护提供支持。

[](https://github.com/features/copilot)

感谢小米 MiMo 对本项目开源维护提供 Token 赞助。

[](https://mimo.mi.com/)

本开源项目由 [QFramework 教程年会员](https://www.gamepixedu.com/vip/?levelId=1) 提供资助

---

### Overview (`QFramework.Godot4.5+/tests/README.md`)

# 单元测试

本项目使用 [GUT（Godot Unit Test）](https://github.com/bitwes/Gut) **v9.5.0** 进行单元测试，目标引擎 **Godot 4.5**。

> **版本说明**：GUT v9.6.0+ 的 `gut_plugin.gd` 使用了 `EditorDock` / `add_dock()` 等 Godot **4.6+** 才有的编辑器 API，在 4.5 下编辑器加载会报解析错误并禁用插件。v9.5.0 是兼容 Godot 4.5 的最新版本——命令行 `gut_cmdln.gd` 不受影响，但编辑器面板需要 4.5 兼容版。**升级到 Godot 4.6+ 后可换用 GUT 最新版。**

## 目录结构

- `tests/unit/` —— 单元测试脚本（`extends GutTest`）
- `.gutconfig.json` —— GUT 命令行运行配置
- `addons/gut/` —— GUT 插件本体

## 运行测试

### 命令行（headless，可接入 CI）

macOS 下若 `godot` 未进 PATH，用 `.app` 内的可执行文件：

```bash
/Applications/Godot_v4.5.2.app/Contents/MacOS/Godot --headless \
  -s res://addons/gut/gut_cmdln.gd \
  -gdir=res://tests/unit -gexit
```

若已把 `godot` 加入 PATH：

```bash
godot --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://tests/unit -gexit
```

退出码非 0 表示有测试失败，便于 CI 判定。

### 编辑器内

启用 GUT 插件后，编辑器底部出现 GUT 面板，点 Run 即可。

面板的测试目录配置存储在 `user://gut_temp_directory/gut_editor_config.json`（即 `~/Library/Application Support/Godot/app_userdata/QFramework.GDScript/gut_temp_directory/`，**不在项目 git 内**，每台机器独立）。首次使用若面板提示 `no directories set`：

- 在面板 **Settings** 标签里把 `res://tests/unit` 加到 Directories，或
- 用面板的"加载 `.gutconfig.json`"功能导入项目根目录的 `.gutconfig.json`，或
- 重启编辑器（本仓库已预置上述 `gut_editor_config.json`）。

> 命令行（`.gutconfig.json`）与编辑器面板（`gut_editor_config.json`）是两套独立配置，需分别设置。

## 编写约定

- 测试脚本置于 `tests/unit/`，`extends GutTest`，测试函数以 `test_` 开头。
- **GDScript lambda 对 `int` 按值捕获**：需要在回调内累加的计数器必须用数组引用语义，例如 `var count = [0]`，回调内 `count[0] += 1`；直接用 `var count = 0` 会在 lambda 内改到副本，外部读不到变化。
- 涉及对象生命周期的断言，用 `weakref(obj).get_ref()` 配合 `is_instance_valid(obj)` 观察是否被回收。

## 现有覆盖

| 文件 | 覆盖 |
|------|------|
| `test_easy_event.gd` | 基础 Event 注册/触发/带参触发/注销 + 注销幂等 |
| `test_or_event.gd` | OrEvent 功能（or 语义）/ 注销正确性 / **幂等闪退回归** / **生命周期（不依赖手动引用计数）** |

---

### Overview (`QFramework.Unity2018+/Assets/QFramework/Toolkits/_CoreKit/JsonKit/readme.md`)

# Author

 Matt Schoen <schoen@defectivestudios.com> of [Defective Studios](http://www.defectivestudios.com)


# Intro

I came across the need to send structured data to and from a server on one of my projects, and figured it would be worth my while to use JSON.  When I looked into the issue, I tried a few of the C# implementations listed on http://json.org, but found them to be too complicated to work with and expand upon.  So, I've written a very simple JSONObject class, which can be generically used to encode/decode data into a simple container.  This page assumes that you know what JSON is, and how it works.  It's rather simple, just go to json.org for a visual description of the encoding format.

# Usage

Users should not have to modify the JSONObject class themselves, and must follow the very simple procedures outlined below:

Sample data (in JSON format):
```JSON
{
    "TestObject": {
        "SomeText": "Blah",
        "SomeObject": {
            "SomeNumber": 42,
            "SomeBool": true,
            "SomeNull": null
        },
        
        "SomeEmptyObject": { },
        "SomeEmptyArray": [ ],
        "EmbeddedObject": "{\"field\":\"Value with \\\"escaped quotes\\\"\"}"
    }
}
```

The test classes provide the best examples for how the API is intended to be used.

## Features

* Decode JSON-formatted strings into a usable data structure
* Encode structured data into a JSON-formatted string
* Interoperable with `Dictionary` and `WWWForm`
* Optimized `parse`/`stringify` functions -- minimal (unavoidable) garbage creation
* Asynchronous `stringify` function for serializing lots of data without frame drops
* `MaxDepth` parsing will skip over nested data that you don't need
* Special (non-compliant) `Baked` object type can store stringified data within parsed objects
* Copy to new `JSONObject`
* Merge with another `JSONObject` (experimental)
* Random access (with `int` or `string`)
* `ToString()` returns JSON data with optional "pretty" flag to include newlines and tabs
* Switch between double and float for numeric storage depending on level of precision needed (and to ensure that numbers are parsed/stringified correctly)
* Supports `Infinity` and `NaN` values
* `JSONTemplates` static class provides serialization functions for common classes like `Vector3`, `Matrix4x4` 
* Object pool implementation (experimental)
* Handy `JSONChecker` window to test parsing on sample data

It should be pretty obvious what this parser can and cannot do.  If anyone reading this is a JSON buff (is there such a thing?) please feel free to expand and modify the parser to be more compliant.  Currently I am using the .NET `System.Convert` namespace functions for parsing the data itself.  It parses strings and numbers, which was all that I needed of it, but unless the formatting is supported by `System.Convert`, it may not incorporate all proper JSON strings.  Also, having never written a JSON parser before, I don't doubt that I could improve the efficiency or correctness of the parser.  It serves my purpose, and hopefully will help you with your project!  Let me know if you make any improvements :)

Also, you JSON buffs (really, who would admit to being a JSON buff...) might also notice from my feature list that this thing isn't exactly to specifications. Here is where it differs:
* "a string" is considered valid JSON.  There is an optional "strict" parameter to the parser which will bomb out on such input, in case that matters to you.
* The `Baked` mode is totally made up.
* The `MaxDepth` parsing is totally made up.
* `NaN` and `Infinity` were introduced in a later version of the standard, and some linters will report them as errors


## Encoding

Encoding is something of a hard-coded process.  This is because I have no idea what your data is!  It would be great if this were some sort of interface for taking an entire class and encoding it's number/string fields, but it's not.  I've come up with a few clever ways of using loops and/or recursive methods to cut down of the amount of code I have to write when I use this tool, but they're pretty project-specific.

The constructor, Add, and AddField functions now support a nested delegate structure.  This is useful if you need to create a nested JSONObject in a single line.  For example:


```C#
void DoRequest(string url, string jsonString) {
	// Web Request logic
}

void Test(string url) {
	DoRequest(url, new JSONObject(request => {
		request.AddField("sort", sort => sort.AddField("_timestamp", "desc"));
		request.AddField("query", new JSONObject(query => query.AddField("match_all", JSONObject.emptyObject)));
		request.AddField("fields", fields => fields.Add("_timestamp"));
	}).ToString());
}
```


## Decoding

Decoding is much simpler on the input end, and again, what you do with the `JSONObject` will vary on a per-project basis.  One of the more complicated way to extract the data is with a recursive function, as drafted below.  Calling the constructor with a properly formatted JSON string will return the root object (or array) containing all of its children, in one neat reference!  The data is in a public `ArrayList` called `list`, with a matching key list (called `keys`!) if the root is an `Object`.  If that's confusing, take a glance over the following code and the `print()` method in the `JSONObject` class.  If there is an error in the JSON formatting (or if there's an error with my code!) the debug console will read "improper JSON formatting".

```C#
void Test() {
	var encodedString = "{\"field1\": 0.5,\"field2\": \"sampletext\",\"field3\": [1,2,3]}";
	var jsonObject = new JSONObject(encodedString);
	AccessData(jsonObject);
}

void AccessData(JSONObject jsonObject) {
	switch (jsonObject.type) {
		case JSONObject.Type.Object:
			for (var i = 0; i < jsonObject.list.Count; i++) {
				var key = jsonObject.keys[i];
				var value = jsonObject.list[i];
				Debug.Log(key);
				AccessData(value);
			}
			break;
		case JSONObject.Type.Array:
			foreach (JSONObject element in jsonObject.list) {
				AccessData(element);
			}
			break;
		case JSONObject.Type.String:
			Debug.Log(jsonObject.stringValue);
			break;
		case JSONObject.Type.Number:
			Debug.Log(jsonObject.floatValue);
			break;
		case JSONObject.Type.Bool:
			Debug.Log(jsonObject.boolValue);
			break;
		case JSONObject.Type.Null:
			Debug.Log("Null");
			break;
		case JSONObject.Type.Baked:
			Debug.Log(jsonObject.stringValue);
			break;
	}
}
```

Decoding also supports a delegate format which will automatically check if a field exists before processing the data, providing an optional parameter for an OnFieldNotFound response.  For example:

```C#
void Test(string jsonString) {
	var list = new JSONObject(jsonString);
	list.GetField("users", users => {
		foreach (var user in users.list) {
			var thisUser = user;
			users.GetField("sessions", sessions => {
				foreach (JSONObject gameSession in sessions.list) {
					Debug.Log(gameSession);
				}
			}, name => Debug.LogWarning(string.Format("No sessions for user {0}", thisUser["name"].stringValue)));
		}
		
	});
}
```

## `(O(n))` Random access

I've added a string and int [] index to the class, so you can now retrieve data as such (from above):

```C#
void Test() {
	var jsonObject = new JSONObject("{\"field\":[0,1,2]");
	var array = jsonObject["field"];
	Debug.Log(array[2].intValue); //Should output "2"
}
```

## Change Log

### v2.1.3
* Fix exception where input is just an empty array or object
* Pool and null out list and keys field when clearing JSONObject for consistency with a fresh object;
This was causing the MaxDepthWithExcessLevels test to fail randomly when checking results because some pooled objects had list but not keys
* Update JSONChecker with information about pools and a note about not validating standard JSON

### v2.1.2
* Fix issue parsing json strings with whitespace after colon characters

### v2.1.1
* Fix issue parsing nested arrays with multiple elements
* Refactor MaxDepth tests
* Fix issues with MaxDepth

### v2.1
* Add async parsing method
* Rewrite and optimize parser
* Fix parsing errors
* Refactor VectorTemplates to use extension methods
* Add more tests

### v2.0
* Add JSONObject to Defective.JSON namespace
* Update APIs to be more descriptive
* Fix parsing errors
* Add tests

### v1.4
Big update!

* Better GC performance.  Enough of that garbage!
	* Remaining culprits are internal garbage from `StringBuilder.Append`/`AppendFormat`, `String.Substring`, `List.Add`/`GrowIfNeeded`, `Single.ToString`
* Added asynchronous `Stringify` function for serializing large amounts of data at runtime without frame drops
* Added `Baked` type
* Added `MaxDepth` to parsing function
* Various cleanup refactors recommended by ReSharper

### v1.3.2
* Added support for `NaN`
* Added strict mode to fail on purpose for improper formatting.  Right now this just means that if the parse string doesn't start with `[` or `{`, it will print a warning and return a `null` `JSONObject`.
* Changed `infinity` and `NaN` implementation to use `float` and `double` instead of `Mathf`
* Handles empty objects/arrays better
* Added a flag to print and `ToString` to turn on/off pretty print.  The define on top is now an override to system-wide disable

---

### QFramework.Godot4.5+ Addons Qframework Doc 01. 概述 (`QFramework.Godot4.5+/addons/qframework/doc/01. 概述.md`)

##  背景

大家好，我是凉鞋，在筹备《回合制 HD2D JRPG》教程案例的过程中，笔者发现还是需要一个开发框架来完成这个复杂且庞大的项目。

于是在边筹备教程案例的过程中，边开始写 GDScript 版 QFramework 的基础功能。

而到今天为止（2025 年 12 月 19 日），GDScript 版 QFramework 算是可用状态了，目前的版本号为 v0.3。 

GDScript 版 QFramework 的名称为 qfgd，这样比较容易记住。

## QFramework 简介

QFramework 是笔者维护了 10 年的一套开源开发框架（从 2015 年 12 月开始），支持的引擎为 Unity。

最开始 QFramework 是一堆工具集，然后开始引入了单例架构，接着是使用 ManagerOfManagers 架构，到最后是决定版架构一直到现在。

而 qfgd 会继续延续使用决定版架构。

决定版架构是一套融合了 MVC、分层、CQRS、事件驱动、数据驱动、DDD 等特性的一套架构，从 2020 年公开到现在，广受好评。

一名初级开发者会随着决定版架构的学习和使用，会不断提升架构能力和程序设计能力。

当完全融会贯通决定版架构之后，可以在任何平台和技术上使用决定版架构的理念和经验。

决定版架构也支持纸上设计，可以在纸上或者一些画图或者建模工具上就可以完成逻辑的实现，然后再进行编码。

也非常适合先写原型，然后慢慢重构迭代，对于重构迭代，决定版结构也提供了很好的支持。

笔者使用 QFramework 制作了大量的教程和项目，通过这些工作，QFramework 也积攒了大量的优质理念和工具。



## 为什么要用 QFramework

使用 QFramework，可以提高开发效率，可以让代码组织更有调理，提高项目可维护性，可扩展性，QFramework 内置了大量的工具集。

如果不使用框架，项目也能做，但是自由度太高了，对于没有养成固定开发习惯&编码习惯的童鞋来说，需要花大量时间来纠结代码怎么写、在哪里写的问题。

而。

到了项目的中后期，维护和扩展是一个很头疼的问题，在项目的最开始如果不制定好约束和规范，中后期的开发进度会因为代码质量问题而受到阻碍。

一般情况下，在项目的前期要写原型，然后要搭建基本的系统框架，然后是量产内容，最后是打磨和优化。

而决定版架构在不同的阶段都能非常好地发挥作用。

## 注意事项

qfgd 在更新此教程的过程中，也会不断地进行更新。

qfgd 最低支持的 Godot 引擎版本为 4.5.1 （因为用了抽象类)。

笔者会在会员群内通知 qfgd 的新版本和 此教程的新内容。

此教程的更新时间不固定，但是争取每次更新都会有一个完整的功能点和内容供大家学习。

---

### QFramework.Godot4.5+ Addons Qframework Doc 02. 安装&更新 (`QFramework.Godot4.5+/addons/qframework/doc/02. 安装&更新.md`)

## 如何安装 qfgd

在本文的资料里会放一个下载文件，如图所示：



下载此文件。

然后解压缩，得到 qframework 文件夹，如图所示：



讲此文件夹放到一个 Godot 项目里的 res://addons/ 目录中，如图所示:



注意 Godot 的引擎版本需要使用 v4.5.1+。

之后打开引擎菜单的 项目->项目设置，如图所示:



打开项目设置后，再打开插件栏，如图所示:



然后勾选上图中红色的选框，来激活插件。激活之后如图所示:



关闭这个面板，激活成功之后，会在底部栏看到 QFramework，如图所示:



点击打开，则可以看到 QFramework 的编辑器面板，如图所示:



这样 qfgd 就安装好了。

可以通过快捷键 ctrl + e 来开关 QFramework 编辑器面板



## 如何更新 qfgd

当服务器有新版本时，会看到如图所示：



点击图中的更新即可一键升级到最新版。

更新之后，ctrl + e 打开 QFramework 编辑器面板，如图所示:



更新成功了。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 01. 基本使用与自动注销 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/01. 基本使用与自动注销.md`)

任何引擎、平台、SDK，都会提供至少一种事件机制。

Godot 引擎提供的官方事件机制是信号（signal）。

而 qfgd 则提供的事件机制是 EasyEvent。

笔者先介绍一下 EasyEvent 的基本使用。

## EasyEvent 基本使用

我们创建任意一个场景，在场景的根节点编写如下代码:

``` python
extends Node2D

# 定义事件
var event_a:EasyEvent = EasyEvent.new()

func _ready() -> void:
	
	# 注册事件
	event_a.register(func():
		print("事件 A 触发")
	).un_register_when_node_exiting_tree(self) # 自动注销

func _process(_delta: float) -> void:
	
	# 当按下空格时
	if Input.is_key_pressed(Key.KEY_SPACE):
		# 触发事件
		event_a.trigger()

```

运行当前场景，按下空格键后，得到如下结果：


运行成功了。

## 使用 EasyEvent 的好处：自动注销

我们可以在事件注册之后，通过 .un_register_when_node_exiting_tree(self) 来自动注销事件。

虽然大部分情况下，Godot 的信号会在发送者或者接收者销毁时自动取消关联，也就是自动取消，但是发生这种情况的前提是通过使用有名字的函数为前提，参考代码如下:
``` python
func _named_func():
	pass
	
some_signal_a.connect(_named_func) # self 或者 some_signal_a 所属对象销毁时会自动注销
```

如果使用的是匿名的函数，也就是 lambda 表达式或者是 callable，则会比较危险，参考代码如下:
``` python
some_singal_a.connect(func():pass) # 只有 some_signal_ 所属对象销毁时会自动注销，而如果当前脚本被销毁不会自动注销
```

所以在使用匿名函数和自定义信号的组合时，就会非常不可控。

很多情况下匿名函数非常好用。

这个时候使用 EasyEvent 则会在当前脚本销毁或者退出 tree 时，自动进行注销。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 02. 如何传参数 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/02. 如何传参数.md`)

EasyEvent 支持触发事件的时候传一个参数。

## EasyEvent 传参数

传参数的方式很简单，只需要在 trigger 的时候传参数即可，在注册事件的时候就可以接收到参数了。

代码示例如下：
``` python
extends Node2D

class_name EasyEventTriggerWithParam

# 定义事件
var event_a:EasyEvent = EasyEvent.new()

func _ready() -> void:
	
	# 注册事件
	# 注册时接收参数，可以带类型，可以不带类型
	# 带类型需要保证发送的时候类型一致
	# 不带类型则随意，但是不推荐这么用
	event_a.register(func(param:int): 
		print(param)
	).un_register_when_node_exiting_tree(self)
	
func _process(_delta: float) -> void:
	# 按下空格则触发事件
	if Input.is_key_pressed(KEY_SPACE):
		event_a.trigger(50)

```

代码很简单，挂到任意场景任意节点，运行之后，按下空格后，结果如下:

这样带参数的事件就发送成功了。

## 只能传一个或者不传参数

EasyEvent 只能传一个参数，或者不传参数。

如果一次事件触发要传多个参数怎么办？

答案是将事件参数定义为一个类。

这里我们再写一个示例，在任意场景和任意节点上，创建如下脚本:
``` python
extends Node2D

class_name EasyEventTriggerWithObj

# 定义事件 模拟当学生添加成功事件
var on_student_added:EasyEvent = EasyEvent.new()

# 定义学生对象，
class Student:
	var name:String
	var age:int
	
func _ready() -> void:
	# 注册事件
	on_student_added.register(func(student:Student):
		print("学生添加成功: 姓名 %s 年龄 %d" % [student.name,student.age])
	).un_register_when_node_exiting_tree(self)
	
func _process(_delta: float) -> void:
	
	# 当空格键按下时
	if Input.is_key_pressed(KEY_SPACE):
		
		# 定义要发送的数据
		var student = Student.new()
		student.name = "凉鞋"
		student.age = 33
		
		# 触发事件
		on_student_added.trigger(student)
```
运行之后，按下空格键，结果如下:


运行成功了，代码虽然比之前多一点，但是不难。

## 参数数量与信号对比

Godot 自带的信号可以定义无限个参数。
而 EasyEvent 最多只能传一个参数。

传无限个参数会更自由，最多只能传一个参数，就要求我们把多个参数封装成一个类，会麻烦一点。

但是把参数封装成一个类是一个编码的好习惯，因为约束意味着代码更有条理、更有组织。

所以 EasyEvent 参数机制完全够用了。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 03. 注册时调用一次 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/03. 注册时调用一次.md`)

我们在开发 ui 的时候，经常会遇到一种情况，就是在界面初始化的时候需要更新一次界面，然后当要展示的数据发生变更还需要更新一次界面，使用伪代码展示如下:

``` python

func _ready():
	
	# 最开始更新一次界面
	_update_view()
	
	# 当数据发生变更时更新一次界面
	Events.on_some_data_changed.register(func():
		_update_view()
	).un_register_when_exiting_tree(self)
	
func _update_view():
	# 这里是一些更新界面的代码
	...
```

代码虽然没什么问题，但是 EasyEvent 提供了更简便的写法。

也就是使用 register_with_a_call 函数。

我们来练习一下。

## register_with_a_call 基本使用

在任意场景任意节点，挂上如下脚本:
``` python
extends Node2D

class_name EasyEventRegisterWithACall

# 定义事件
var event_a:EasyEvent = EasyEvent.new()

func _ready() -> void:

	# 注册事件，同时调用一次注册的函数	
	event_a.register_with_a_call(func():
		on_event_a()	
	).un_regisiter_when_node_exiting_tree(self)

func on_event_a():
	print("on event a:received")
	
func _process(_delta: float) -> void:
	# 当空格按下
	if Input.is_key_pressed(KEY_SPACE):
		
		print("空格键按下")
		# 触发事件 A
		event_a.trigger()

```

运行之后，结果如下:


默认会调用一次 on_event_a()

当按下空格键后结果如下:


没有任何问题，代码也不难。

register_with_a_call 虽然在项目开发的过程中，使用频次不是很高。但是只要遇到了适合使用的场景，就可以减少很多冗余代码的编写工作，代码的可读性也会得到提升。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 04. 作为静态变量定义 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/04. 作为静态变量定义.md`)

Godot 的信号必须依附于 Node 节点，不能定义为静态变量。

如果想要定义全局事件，需要创建一个 Node 脚本或者场景放到自动加载部分。

这样其实问题不大，但是有的时候一些全局事件与事件发生的系统或模块放在一起会更容易管理，这个时候把全局事件放到自动加载的方式就不够用了。

而 EasyEvent 可以直接作为静态变量定义，这样既可以在一个代码文件里管理全局事件，也可以把一些全局事件放在一些系统或模块的脚本里管理，会灵活很多。

我们看下全局事件的基本使用。
  
## 基本使用

在任意场景的任意节点上，编写如下代码:

``` python
extends Node2D

class_name EasyEventAsStaticVar

# 定义事件
static var event_a:EasyEvent = EasyEvent.new()

func _ready() -> void:
	
	# 注册事件
	event_a.register(func():
		print("event a:received")
	).un_register_when_node_exiting_tree(self)
	
	# 如果是在别的脚本可以通过类名.事件的方式访问
	EasyEventAsStaticVar.event_a.register(func():
		pass	
	).un_register_when_node_exiting_tree(self)

func _process(_delta: float) -> void:
	
	# 当空格键按下
	if Input.is_key_pressed(KEY_SPACE):
		event_a.trigger()

```

代码很简单，运行之后，按下空格键，结果如下:


将 EasyEvent 定义为静态变量，其实就是定义全局事件。

这种使用方式非常普遍。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 05. 多个事件合并注册 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/05. 多个事件合并注册.md`)

在一些项目开发中，笔者遇到了一种需求，就是几个事件不管哪个触发都执行同样的一段代码。也就是多个事件合并注册的需求。

于是 EasyEvent 提供了 or_event API，用来实现多个事件的合并注册。

# 基本使用

在任意场景任意节点，编写如下代码：

``` python
extends Node2D

class_name EasyEventOrEvent

# 定义事件
var event_a:EasyEvent = EasyEvent.new()
var event_b:EasyEvent = EasyEvent.new()
var event_c:EasyEvent = EasyEvent.new()

func _ready() -> void:
	# 注册事件
	event_a.or_event(event_b).or_event(event_c).register(func():
		print("event a 或 event b:触发")
	).un_register_when_node_exiting_tree(self)


func _process(_delta: float) -> void:
	
	# 按键 1 按下
	if Input.is_key_pressed(KEY_1):
		print("按键 1 按下")
		# 触发事件 A
		event_a.trigger()
		
	# 按键 2 按下
	if Input.is_key_pressed(KEY_2):
		print("按键 2 按下")
		# 触发事件 B
		event_b.trigger()

```

运行之后，不管按下 1 还是 2 都得到同样的输出结果，如下:


代码很简单。

合并注册同样支持 register_with_a_call 这里就不演示了。

大家可能会问，如果合并注册的某个事件是有参数的怎么办？

答案是注册的时候都用不带参数的函数或者匿名函数注册。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 06. 手动注销 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/06. 手动注销.md`)

在非 Node 脚本注册事件的情况下，我们需要手动注销事件。

EasyEvent 提供了两种注销事件的方式。

我们来看下基本使用。

## 基本使用

在任意场景任意节点，编写如下代码：

``` python
extends Node2D

class_name EasyEventUnregisterbasic

# 定义事件
var event_a:EasyEvent = EasyEvent.new()
var event_b:EasyEvent = EasyEvent.new()

# 注销器对象
var event_b_unregister:UnRegister = null

func _ready() -> void:
	
	# 用函数注册事件
	event_a.register(_on_event_a)
	
	# 注册后会获得一个 unregister 对象
	event_b_unregister = event_b.register(func():
		print("on event b")	
	)

func _on_event_a():
	print("on event a")

func _process(_delta: float) -> void:
	
	# 空格按下
	if Input.is_key_pressed(KEY_SPACE):
		print("空格按下")
		# 触发事件 A 和 B
		event_a.trigger()
		event_b.trigger()
		
	# ESC 按下
	if Input.is_key_pressed(KEY_ESCAPE):
		print("注销事件")
		# 用函数注销事件
		event_a.un_register(_on_event_a)
		
		# 用 unregister 注销事件
		event_b_unregister.un_register()

```

运行代码，按下空格后结果如下:


按下 ESC 会注销事件，结果如下:


此时再按下空格，结果如下：


没有输出 on_event_a 和 on_event_b 了，说明事件已经注销成功。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 07. 需要注意：自动注销时机是 Exit Tree (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/07. 需要注意：自动注销时机是 exit tree.md`)

EasyEvent 的自动注销的时机是 exiting tree。

而节点只要换了父节点或者从父节点移除自己，就会触发 exiting tree。

而之前的例子中我们都在 ready 时机注册并关联了自动注销，这种时候只要当前脚本所在的节点触发 exit tree 时机，事件就会自动注销了，这是需要在使用自动注销时要注意的点。

笔者没有找到与 ready 时机对仗的生命周期函数，而 exit tree 是与 enter tree 对仗的。

如果遇到某个注册事件的脚本需要变换父节点，这个时候有几种注销事件的方案:

**在 enter tree 时注册事件，然后使用自动注销。但是 enter tree 是在 ready 之前会调用一次，如果要使用注册时调用一次的功能，同时还需要访问子节点，那么不太适合用 @onready 这个注解定义子节点为变量，而是使用$或者%直接获取子节点。**

参考代码如下:
``` python
extends Node

var event_a:EasyEvent = EasyEvent.new()

@onready var child_node:Node = $ChildNode # onready 是在 enter tree 之后

# 懒加载模式
var _child_node:Node = null
var child_node_lazy:Node:
	get:
		if _child_node == null:
			_child_node = $ChildNode
		return _child_node

func _enter_tree() -> void:
	event_a.register_with_a_call(func():
		# 这种可以用，
		$ChildNode.name = "123"
		# 或者用懒加载的方式
		child_node_lazy.name = "123"
		# 这种就不能用了，此时是 null
		child_node.name = "123"
		
	).un_register_when_node_exiting_tree(self)

```



**在 ready 注册事件，但是在确定销毁此节点的时机手动注销。**

参考代码如下:
``` python
extends Node

var event_a:EasyEvent = EasyEvent.new()
var event_b:EasyEvent = EasyEvent.new()

var event_a_un_register:UnRegister

func _ready() -> void:
	
	event_a_un_register = event_a.register(func():
		pass
	)
	
	event_b.register(_on_event_b)
	
func _on_event_b():
	pass

	
func _notification(what: int) -> void:
	
	# 确定此脚本要销毁了
	if what == NOTIFICATION_PREDELETE:
		
		# 通过 un_register 注销
		event_a_un_register.un_register()
		
		# 通过函数注销
		event_b.un_register(_on_event_b)
	

```

**最后一种就是，父节点会变换的脚本就不用自动注销，不会变换的就用自动注销**

笔者在开发项目时很少遇到过需要使用以上解决方案的场景，绝大多数无脑自动注销就可以了，但是有的项目会用对象池，而对象池有的时候会变换父节点，有的项目的节点会需要变换父节点，所以这里提前推测了下使用 EasyEvent 可能会遇到的问题。

在未来如果使用 EasyEvent 遇到了一些不可控的问题，可以考虑下排查下是不是 exiting tree 生命周期导致的事件被提前注销导致的问题。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 08. 需要注意：注册时调用一次只支持无参事件 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/08. 需要注意：注册时调用一次只支持无参事件.md`)

补充一个注意点，注册时调用一次也就是 register_with_a_call 只支持没有参数的事件。

如果是有参数的事件使用 register_with_a_call 会报错，而如果给注册的函数使用了 bind，那么在触发事件时，传什么参数都是 bind 对应的值。

当然，这种需求在笔者开发项目的过程中从来没有遇到过。

而且，所有的使用需求都是，无参数的事件在某些情况下需要使用 register_with_a_call，有参数的事件不需要在初始化的情况下在界面上显示，所以不需要使用 register_with_a_call。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 09. 通过继承的方式在节点销毁时自动注销 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/09. 通过继承的方式在节点销毁时自动注销.md`)

之前有聊过，自动注销的时机是 exit tree，为了改进这个问题，就在 qfgd v0.3.73 版本新增了 NodeWithDestroy 类，有了这个类，qfgd 就可以提供在节点销毁时自动注销的 API。

总共有两种方式实现在节点销毁时自动注销事件注册。

分别为

## 第一种 继承的方式 基本使用

新建一个场景，在场景的根节点下创建一个 Node 名字叫 EventReceiver 即事件接收者，如下:


在根节点，我们创建一个脚本并定义一个静态事件，在按下空格后触发这个事件，代码如下:
``` python
extends Node2D

class_name EasyEventRealUnRegister

# 定义一个事件
static var some_event:EasyEvent = EasyEvent.new()

func _process(_delta: float) -> void:
	if Input.is_key_pressed(KEY_SPACE):
		print("空格键按下")
		some_event.trigger()

```

在子节点，也就是 EventReceiver 编写监听这个事件的代码。

我们通过继承的方式实现真正的自动注销，需要我们的脚本继承 EventWithDestroyEvent。

代码如下:
``` python
# 继承
extends NodeWithDestroyEvent

class_name EasyEventReceiver

func _ready() -> void:
	# 注册
	EasyEventRealUnRegister.some_event.register(func():
		print("on some event")
	).un_register_when_node_destroyed(self)
	
```

接着，我们需要在父节点的脚本获得子节点，并在按下数字 1 键，销毁子节点，父脚本如下:

``` python
extends Node2D

class_name EasyEventRealUnRegister

# 定义一个事件
static var some_event:EasyEvent = EasyEvent.new() # ++

# 获取
@onready var event_receiver: EasyEventReceiver = $EventReceiver

func _process(_delta: float) -> void:
	if Input.is_key_pressed(KEY_SPACE):
		print("空格键按下")
		some_event.trigger()
		
	# 按下 1 键，删除掉子节点
	if Input.is_key_pressed(KEY_1): # ++
		if event_receiver != null:
			event_receiver.queue_free()
			event_receiver = null

```

运行当前场景，按下空格键，结果如下:

接着我们将 子节点删除掉，按下数字 1 键，然后再按下空格键 结果如下：

结果是正确的。

---

### QFramework.Godot4.5+ Addons Qframework Doc 03. EasyEvent 10. 通过子节点的方式在节点销毁时自动注销&优劣分析 (`QFramework.Godot4.5+/addons/qframework/doc/03. EasyEvent/10. 通过子节点的方式在节点销毁时自动注销&优劣分析.md`)

我们接着介绍第二种在销毁时自动注销的方式。

即子节点的方式在节点销毁时自动注销。

我们来看下基本使用。

## 基本使用

我们在任意场景的根节点下，创建一个 Node，名字叫 EventReceiver2 ，如下所示:



然后在根节点创建脚本，在脚本中定义一个静态事件，然后按空格触发事件，按 1 则销毁子节点，代码如下:

``` python
extends Node2D

class_name EasyEventUnRegisterByChildNode

# 定义
static var some_event:EasyEvent = EasyEvent.new()

# 获取子节点
@onready var event_receiver_2: Node = $EventReceiver2


func _process(_delta: float) -> void:
	
	# 按下空格键，触发事件
	if Input.is_key_pressed(KEY_SPACE):
		print("空格按下")
		some_event.trigger()
		
		
	# 按下数字 1 键，销毁子节点
	if Input.is_key_pressed(KEY_1):
		if event_receiver_2 != null:
			event_receiver_2.queue_free()
			event_receiver_2 = null

```

然后在子节点，编写脚本，注册这个静态事件即可，代码如下:

``` python
extends Node

class_name EasyEventReceiver2

func _ready() -> void:
	
	EasyEventUnRegisterByChildNode.some_event.register(func():
		print("on some event triggered")	
	).un_register_when_created_child_node_destroyed(self)

```

运行当前场景，当按下空格键后，结果如下:


按下数字 1 键，后再按下空格，结果如下:



事件自动注销成功了。


## 两种方式优劣
* 通过继承 NodeWithDestroyEvent 的方式
	* 好处是没有不可控因素，只要这个节点被销毁，肯定会自动注销
	* 坏处是需要继承 NodeWithDestroyEvent 节点，比如如果是此时的节点是 Sprite 类型，而恰好又需要用到 Sprite 相关的 API，则不太适合用这种方式。
* 通过创建一个子节点的方式
	* 好处是不用继承 NodeWithDestroyEvent 节点，可以访问当前节点类型的 API
	* 坏处是如果有一些遍历子节点的操作，新参与的节点也会参与遍历，比如有时候我们需要删除掉全部的子节点，那么很有可能会把子节点无删除，导致一些不可控的行为。

## 与 ExitingTree 相比的优势
* 好处是，更改父节点不会误触发自动注销事件
* 可控性更好

---

### QFramework.Godot4.5+ Addons Qframework Doc 04. BindableProperty 01. 简介&基本使用 (`QFramework.Godot4.5+/addons/qframework/doc/04. BindableProperty/01. 简介&基本使用.md`)

在游戏开发过程中，我们经常要定义一些数据变量，比如生命值、魔法值、金币等。

当这些数据发生改变时，我们需要将这些数据展示到游戏界面上。

为了简化这部分工作，qfgd 支持了 BindableProperty。

## 简介

BindableProperty，意思是可绑定的属性。

BindableProperty 包含了一个数据变量，和一个数据变更事件。

当数据修改时，会触发数据变更事件，而这个数据变更事件其实就是 EasyEvent。

我们来看一下基本使用。

## 基本使用

我们创建一个场景，让场景的子节点包含一个 Label，如下图所示:

在根节点创建脚本，代码如下:

``` python
extends Node2D

class_name BindablePropertyBasicUsage

# 定义金币
var coin:BindableProperty = BindableProperty.new(10)

# 金币 Label
@onready var coin_label: Label = $CoinLabel

func _ready() -> void:
	# 注册 coin 属性 当变更时会调用注册函数并把变更后的值传过来
	coin.register(func(c:int):
		print(c)
		coin_label.text = "金币:%d" % c
	).un_register_when_node_exiting_tree(self)

func _process(_delta: float) -> void:
	
	# 当按下空格时
	if Input.is_key_pressed(KEY_SPACE):
		# 金币值 + 1
		coin.value += 1

```

运行当前场景，当按下空格后结果如下:

显示结果如下:


代码中的 coin 这个 BindablePropery 的数据值 直接更新到 CoinLabel 上了。

---

### QFramework.Godot4.5+ Addons Qframework Doc 04. BindableProperty 02. 注册时调用一次 (`QFramework.Godot4.5+/addons/qframework/doc/04. BindableProperty/02. 注册时调用一次.md`)

与 EasyEvent 一样，BindableProperty 也支持注册时调用一次的功能。

## 基本使用
在任意场景，任意节点，编写如下代码:
``` python
extends Node2D

class_name BindablePropertyRegisterWithInitValue

# 定义事件
var coin:BindableProperty = BindableProperty.new(3)

func _ready() -> void:
	# 注册事件
	coin.register_with_init_value(func(c:int):
		print(c)	
	).un_register_when_node_exiting_tree(self)

```
运行之后，结果如下:

没有修改 coin 值，在场景运行之后直接输出了初始值 3，结果完全正确。

与 EasyEvent 不一样的是 API 名字是 register_with_init_value，在注册时，会通过注册函数的参数把初始值传过来。

---

### QFramework.Godot4.5+ Addons Qframework Doc 04. BindableProperty 03. 设置值但是不触发变更事件 (`QFramework.Godot4.5+/addons/qframework/doc/04. BindableProperty/03. 设置值但是不触发变更事件.md`)

在使用 BindableProperty 的过程中，偶尔会遇到这样的需求，就是需要设置 BindableProperty 的值，但是不需要触发变更事件。

BindableProperty 通过一个 API 支持了这个功能。

我们看一下基本使用。

## 基本使用

在任意场景任意节点编写如下代码：

```python
extends Node2D

class_name BindablePropertySetValueWithoutEvent

# 定义
var some_number_property:BindableProperty = BindableProperty.new(0)

func _ready() -> void:
	# 注册
	some_number_property.register(func(number:int):
		print("some_number_property 变更:%d" % number)
	).un_register_when_node_exiting_tree(self)

func _process(_delta: float) -> void:
	# 当空格键按下
	if Input.is_key_pressed(KEY_SPACE):
		var random_number = randi_range(1,10)
		print("随机置了一个数字:%d" % random_number)
		# 设置值不触发变更事件
		some_number_property.set_value_without_event(random_number)
		
	# 当 1 键按下
	if Input.is_key_pressed(KEY_1):
		var random_number = randi_range(1,10)
		print("随机置了一个数字:%d" % random_number)
		# 设置值将触发变更事件
		some_number_property.value = random_number

```

代码很简单，大概意思是，按下空格则通过 set_value_without_event 设置值，按下数字 1 键则通过默认的方式设置值。

运行后按下空格键，结果如下:

没有触发变更事件。

然后按下，数字 1 键，结果如下:

触发了变更事件。

---

### QFramework.Godot4.5+ Addons Qframework Doc 04. BindableProperty 04. 合并注册 (`QFramework.Godot4.5+/addons/qframework/doc/04. BindableProperty/04. 合并注册.md`)

BindableProperty 与 EasyEvent 一样，支持通过 or_event 将多个事件合并注册。

我们来看下基本使用。
## 基本使用

在任意场景节点编写脚本，代码如下:
``` python
extends Node2D

class_name BindablePropertyOrEvent

# 定义
var some_number_property:BindableProperty = BindableProperty.new(10)
var some_text_property:BindableProperty = BindableProperty.new("文本")
# 支持 EasyEvent
var event_a:EasyEvent = EasyEvent.new()

func _ready() -> void:
	# 合并注册
	(some_number_property
		.or_event(some_text_property)
		.or_event(event_a)
		.register_with_a_call(func(): # 也支持初始化调用一次
			print("or event triggered") \
		).un_register_when_node_exiting_tree(self)
	) # 用括号括一下 可以少写很多 \
	
func _process(_delta: float) -> void:
	# 当 1 键按下
	if Input.is_key_pressed(KEY_1):
		var random_number = randi_range(0,100)
		some_number_property.value = random_number
	# 当 2 键按下
	elif Input.is_key_pressed(KEY_2):
		var random_number = randi_range(0,100)
		some_text_property.value = "text:%d" % random_number
	# 当 3 键按下
	elif Input.is_key_pressed(KEY_3):
		event_a.trigger()
		

```

运行后，结果如下:

没做任何事情就触发了一次事件，这是因为我们用了 register_with_a_call，合并注册时支持用 register_with_a_call 来进行注册的。

但我们按下数字 1 ~ 3 中任意的按键，结果如下:

结果是正确的。

---

### QFramework.Godot4.5+ Addons Qframework Doc 04. BindableProperty 05. 手动注销 (`QFramework.Godot4.5+/addons/qframework/doc/04. BindableProperty/05. 手动注销.md`)

BindableProperty 与 EasyEvent 一样，也支持手动注销。

## 基本使用

在任意场景任意节点，编写如下脚本:
``` python
extends Node2D

class_name BindablePropertyUnRegisterBasic

# 定义
var some_number:BindableProperty = BindableProperty.new(10)
var some_text:BindableProperty = BindableProperty.new("abc")

var some_number_un_register:UnRegister

func _ready() -> void:
	# 注册后获得 unregister
	some_number_un_register = some_number.register(func(number:int):
		print("some number:%d" % number)
	)
	
	# 通过有名函数注册
	some_text.register(_on_some_text_changed)
	
func _on_some_text_changed(text:String):
	print("some text:%s" % text)

func _process(_delta: float) -> void:
	if Input.is_key_pressed(KEY_SPACE):
		print("空格键按下")
		some_number.value = randi_range(0,100)
		some_text.value = "%s" % ["def","qwe","aaa"].pick_random()
		
	if Input.is_key_pressed(KEY_1):
		some_number_un_register.un_register()
		some_text.un_register(_on_some_text_changed)

```

运行之后，按下空格键，结果如下:

按下数字 1，此时两个属性的数据变更事件已经注销，再按下空格键，结果如下:



注销成功了。

---

### QFramework.Godot4.5+ Addons Qframework Doc 04. BindableProperty 06. 节点销毁时注销 (`QFramework.Godot4.5+/addons/qframework/doc/04. BindableProperty/06. 节点销毁时注销.md`)

与 EasyEvent 一样，BindableProperty 也支持节点销毁时注销的功能。

因为前边的 EasyEvent 专题已经介绍过了，所以这里只给出一下基本用法的代码，不作验证了。

## 基本用法

``` python
# 继承
extends NodeWithDestroyEvent

class_name BindablePropertyUnRegisterByDestroy

var some_number:BindableProperty = BindableProperty.new(10)

func _ready() -> void:
	
	some_number.register(func(number:int):
		print("some number:%d" % number)
		
	# 通过创建一个子节点销毁，self 不需要继承 NodeWithDestroyEvent
	).un_register_when_created_child_node_destroyed(self) 
	
	some_number.register(func(number:int):
		print("some number:%d" % number)	
	# self 需要继承 NodeWithDestroyEvent
	).un_register_when_node_destroyed(self)

```

两种都支持，详情请看 EasyEvent 的 09 10 两篇图文。

---

### QFramework.Godot4.5+ Addons Qframework Doc 05. 事件通用特性 01. 当 Ui 隐藏时自动注销 (`QFramework.Godot4.5+/addons/qframework/doc/05. 事件通用特性/01. 当 ui 隐藏时自动注销.md`)

EasyEvent 和 BindableProperty 有很多一样的 API，为了节省篇幅，所以在这个专题统一进行介绍了。

在筹备 Godot 回合制案例的时候，遇到一种需求，就是当显示一个界面时候注册事件，然后当关闭一个界面时候需要注销事件，于是就诞生了 un_register_when_ui_hidden 这个 API。

我们来看下基本使用。


## 基本使用

创建一个 ui 场景，在根节点编写如下代码：

``` python
extends Control

# 定义
var some_event:EasyEvent = EasyEvent.new()
var some_number:BindableProperty = BindableProperty.new(10)

func _ready() -> void:
	some_event.register(func():	
		print("on some event")
	).un_register_when_ui_hidden(self) # self 只要继承 Control 就可以
	
	some_number.register(func(number:int):
		print("some number:%d" % number)	
	).un_register_when_ui_hidden(self)

func _process(_delta: float) -> void:
	
	# 按下空格键 触发事件
	if Input.is_key_pressed(KEY_SPACE):
		print("空格按下")
		some_event.trigger()	
		some_number.value = randi_range(0,100)
		
	# 按下数字 1 隐藏自己
	if Input.is_key_pressed(KEY_1):
		self.hide()
```

运行当前场景，按下空格键，结果如下:

事件正在触发，按下数字 1，之后再按下空格键，结果如下:


节点虽然隐藏了，但是 \_Process 函数还在继续执行，而此时按下空格键，则不触发事件了，因为事件随着节点的隐藏注销掉了。

这里虽然 API 后边是 ui_hidden，但是 ui 其实就是 Control 节点，任何继承了 Control 节点的子节点或者脚本都可以用这个 API。

---

### QFramework.Godot4.5+ Addons Qframework Doc 05. 事件通用特性 02. 当 Node2D｜3D 隐藏时自动注销事件 (`QFramework.Godot4.5+/addons/qframework/doc/05. 事件通用特性/02. 当 Node2D｜3D 隐藏时自动注销事件.md`)

事件除了当 ui 隐藏时自动注销，也支持 Node2D 和 Node3D 隐藏时自注销。

代码其实与 ui 隐藏自动注销差不多。

我们看下基本使用。

##  基本使用

创建一个 2D 场景，在根节点创建一个 3D 节点，如图所示：
)

在根节点创建脚本，代码如下:
``` python 
extends Node2D

var some_number:BindableProperty = BindableProperty.new(10)
var some_event:EasyEvent = EasyEvent.new()

@onready var node_3d: Node3D = $Node3D

func _ready() -> void:
	
	some_number.register(func(number:int):
		print("some number:%d" % number)
	).un_register_when_node_2d_hidden(self) # 自己需要是 Node2D
	
	some_event.register(func():
		print("some event")
	).un_register_when_node_3d_hidden(node_3d)


func _process(_delta: float) -> void:
	if Input.is_key_pressed(KEY_SPACE):
		print("空格键按下")
		some_number.value = randi_range(0,100)
		some_event.trigger()
	
	# 按下数字 1 则隐藏节点（会触发事件自动注销）
	if Input.is_key_pressed(KEY_1):
		self.hide()
		node_3d.hide()

```

运行之后，按下空格键，结果如下:


按下 1 键，再按下空格键，结果如下:


结果是正确的。

---

### QFramework.Godot4.5+ Addons Qframework Doc 06. EasyFSM 01. 简介&基本使用 (`QFramework.Godot4.5+/addons/qframework/doc/06. EasyFSM/01. 简介&基本使用.md`)

有限状态机（Finite State Machine，简称 FSM) 是在游戏开发中绕不开的设计模式。

qfgd 在 v0.1 版本就支持了 FSM，名字叫 EasyFSM。

与常见的 FSM 实现不同的是，EasyFSM 优先支持了链式 API。

我们来看下基本的用法。

## 基本使用

在任意场景任意节点编写如下脚本：

``` python
extends Node2D

# 使用枚举 定义 状态 Id
enum States
{
	Idle,
	Jump
}

# 定义状态机对象
var fsm:EasyFSM = EasyFSM.new()

func _ready() -> void:
	# 定义 idle 状态
	fsm.state(
		States.Idle
	).on_enter(func():
		print("idle state enter")
	).on_exit(func():
		print("idle state exit")
	)
	
	# 定义 jump 状态
	fsm.state(
		States.Jump
	).on_enter(func():
		print("jump state enter")	
	).on_exit(func():
		print("jump state exit")	
	)
	
	# 设置初始状态
	fsm.start_state(States.Idle)
	
func _process(_delta: float) -> void:
	
	if Input.is_key_pressed(KEY_1):
		print("数字 1 按下")
		fsm.change_state(States.Idle)
		
	if Input.is_key_pressed(KEY_2):
		print("数字 2 按下")
		fsm.change_state(States.Jump)

func _exit_tree() -> void:
	# 清空 fsm 对象
	fsm.clear()
	fsm = null

```

运行当前脚本，结果如下所示:

因为我们通过 start_state 将 States.Idle 设置为初始状态，所以运行游戏后会先进入 Idle 状态。

此时我们按下数字 1，结果如下所示:


状态没有变更，因为我们写的代码是，在按下数字 1 时，要切换到 Idle 状态，但是此时已经是 Idle 状态，所以就不会走进入 Idle 状态的流程。

此时我们按下数字 2 结果如下:


成功切换到了 jump 状态。

在切换到 jump 状态之前，先退出了 idle 状态。

---

### QFramework.Godot4.5+ Addons Qframework Doc 06. EasyFSM 02. Process 生命周期使用 (`QFramework.Godot4.5+/addons/qframework/doc/06. EasyFSM/02. process 生命周期使用.md`)

在很多时候，我们需要在特定状态里进行计时、处理用户输入等操作，所以 EasyFSM 的状态（State）除了支持 enter 和 exit 生命周期，还支持了 process 生命周期。

我们来看下基本使用。

## 基本使用

在任意场景任意节点，编写如下脚本：

``` python
extends Node2D

# 使用枚举定义状态 id
enum States
{
	Idle,
	Jump
}

# 定义 FSM
var fsm:EasyFSM = EasyFSM.new()

func _ready() -> void:
	
	# 定义 Idle 状态
	fsm.state(
		States.Idle
	).on_enter(func():
		print("idle state enter")	
	).on_process(func(_delta:float):
		# 数字 2 按下，跳转到 Jump 状态
		if Input.is_key_pressed(KEY_2):
			fsm.change_state(States.Jump)	
	).on_exit(func():
		print("idle state exit")	
	)
	
	
	# 定义 Jump 状态
	fsm.state(
		States.Jump
	).on_enter(func():
		print("jump state exit")	
	).on_process(func(_delta:float):
		# 数字 1 按下，跳转到 Idle 状态
		if Input.is_key_pressed(KEY_1):
			fsm.change_state(States.Idle)	
	).on_exit(func():
		print("jump state enter")	
	)
	
	# 设置初始状态 
	fsm.start_state(States.Idle)

func _process(delta: float) -> void:
	# 使用 process 生命周期，需要 Node 的 process 生命周期驱动
	fsm.process(delta)
	
func _exit_tree() -> void:
	fsm.clear()
	fsm = null

```

代码很简单，我们在每个状态的 on_process 里去监听特定的按键输入，而不是像上一个示例一样，统一在节点的 _process 里 去监听。

运行当前场景，结果如下:


当数字 2 按下，结果如下:


当数字 1 按下，结果如下:


结果没问题。

## 优势

在状态的 on_process 里处理一些逻辑与在节点的 \_process 里处理一些逻辑相比，前者的代码更清晰且容易管理，后者当状态一多就会得到一个巨大的 process 函数和大量的 if 语句。

---

### QFramework.Godot4.5+ Addons Qframework Doc 06. EasyFSM 03. 类状态支持 (`QFramework.Godot4.5+/addons/qframework/doc/06. EasyFSM/03. 类状态支持.md`)

之前介绍的 EasyFSM 定义状态的写法叫做链式状态，链式状态的好处就是可以在节点初始化的地方就定义好状态并编写逻辑，适合快速开发快速修改迭代。

但是也有缺点，就是当状态变多或者一个状态的逻辑变得复杂，那么定义状态的脚本的代码就会变得特比多，尤其是初始化的代码会有很多。

这个时候使用类状态是更好的选择。

类状态其实就是大部分状态机的常见实现，就是将一个状态定义为一个类。

我们来看下基本使用。

## 基本使用

在任意场景任意节点，编写如下脚本。

``` python
extends Node2D

class_name EasyFSMClassStateExample

# 定义状态 Id
enum States
{
	Idle,
	Jump
}

# 定义状态机
var fsm:EasyFSM = EasyFSM.new()

# 定义 Idle 状态类
class IdleState extends EasyFSMState:
	
	func enter():
		print("idle state enter")
		# 可以在状态类内部访问目标对象
		# 因为会在构造的时候传进来
		# 但是 target 类型是 Object，需要转成具体的类型
		print(target as EasyFSMClassStateExample)
		
	func exit():
		print("idle state exit")
		
	func process(_delta:float):
		# 当按下数字 2 按下则跳转到 Jump 状态
		if Input.is_key_pressed(KEY_2):
			# 可以在状态类内部访问 fsm
			# 因为会在构造的时候传进来
			fsm.change_state(States.Jump)
	
	# 类状态必须覆写 enter exit process physics_process 抽象函数
	func physics_process(_delta:float): pass
	
func _ready() -> void:
	# 类状态通过 add_state 的方式添加
	# 类状态的构造需要传入 状态 Id | 目标对象 | 状态机对象
	fsm.add_state(IdleState.new(States.Idle,self,fsm))
	
	# 定义 Jump 状态
	# 类状态和链式状态可以混合使用
	fsm.state(
		States.Jump
	).on_enter(func():
		print("jump state enter")	
	).on_exit(func():
		print("jump state exit")	
	).on_process(func(_delta:float):
		# 当 数字 1 按下则跳转到 Idle 状态
		if Input.is_key_pressed(KEY_1):
			fsm.change_state(States.Idle)
	)
	
	# 设置初始状态
	fsm.start_state(States.Idle)

func _process(delta: float) -> void:
	# 驱动状态机的 process
	fsm.process(delta)
	
func _exit_tree() -> void:
	fsm.clear()
	fsm = null
	

```

代码很简单，运行当前场景，结果如下:

按下数字 2 键，结果如下:

按下数字 1 键，结果如下:

结果完全没问题。

虽然这个示例的类状态是定义为一个脚本的内部类，但是是支持将类状态在一个独立的脚本里定义的。

## 链式状态 vs 类状态

链式状态适合在早期原型阶段或者比较简单的状态机逻辑，优点是快速开发，快速迭代，因为都是用的匿名函数，性能可能会稍差一些，但是可以几乎忽略不计。

类状态适合在中后期的生产阶段或者比较复杂的状态机逻辑，优点是帮助目标脚本分担逻辑，防止变得臃肿，性能也会快一些，但是可以几乎忽略不计。

链式状态可以满足 90% 的中小型独立游戏使用场景，等遇到不满足的情况下可以将逻辑搬移到类状态也是很容易的。

---

### QFramework.Godot4.5+ Addons Qframework Doc 06. EasyFSM 04. Physics Process 生命周期使用 (`QFramework.Godot4.5+/addons/qframework/doc/06. EasyFSM/04. physics_process 生命周期使用.md`)

如果状态机用在角色控制上，那么难免需要调用物理相关的 API，而很多物理相关的 API 最好是在 physics_process 生命周期中调用。

所以 EasyFSM 支持了 physics_process 生命周期。

我们来看下基本使用。

## 基本使用

在任意场景任意节点，编写如下脚本:

``` python
extends Node2D

# 定义状态 Id （只定义一个）
enum States
{
	Idle
}

# 定义状态机
var fsm:EasyFSM = EasyFSM.new()

func _ready() -> void:
	
	# 定义 Idle 状态
	fsm.state(
		States.Idle
	).on_physics_process(func(delta:float):
		print("physics_process delta:%f" % delta)	
	)
	
	# 设置初始状态
	fsm.start_state(States.Idle)

func _physics_process(_delta: float) -> void:
	# 每个状态的 physics_process 由脚本的 phycis_process 驱动
	fsm.physics_process(_delta)
	
func _exit_tree() -> void:
	fsm.clear()
	fsm = null

```

代码很简单，这里只定义了一个 Idle 状态，足够用于展示 physics_process 生命周期了。

运行当前场景，结果如下:


结果没问题。

---

### QFramework.Godot4.5+ Addons Qframework Doc 06. EasyFSM 05. 查询当前状态帧数和时间的 API (`QFramework.Godot4.5+/addons/qframework/doc/06. EasyFSM/05. 查询当前状态帧数和时间的 API.md`)

笔者喜欢做一些震屏效果或者角色震动效果时使用帧数来实现，而有的时候需要使用时间和 lerp 函数写一些缓动效果，或者控制材质参数。

当然这种喜好并不是笔者一个人有的，笔者在翻阅一些其他引擎的插件或者项目源码看到过很多次这种使用方式，所以就"借鉴"走了。

而 EasyFSM 提供了进入当前状态后持续了多少帧和时间。

我们来看下基本使用。
## 基本使用

在任意场景任意节点，编写如下脚本:

``` python
extends Node2D

# 定义状态 Id
enum States
{
	Idle,
	Jump,
}

# 定义状态机
var fsm:EasyFSM = EasyFSM.new()

func _ready() -> void:
	# 定义 Idle 状态
	fsm.state(
		States.Idle
	).on_process(func(_delta:float):
		print("idle state current frame count:%d" % fsm.current_frames)
		print("idle state current seconds:%f" % fsm.current_seconds)
		
		# 计时一秒则跳转到 Jump 状态
		if fsm.current_seconds > 1.0:
			fsm.change_state(States.Jump)
	)
	
	# 定义 Jump 状态
	fsm.state(
		States.Jump
	).on_process(func(_delta:float):
		print("jump state current frame count:%d" % fsm.current_frames)
		print("jump state current seconds:%f" % fsm.current_seconds)
		
		# 计时一秒则跳转到 Idle 状态
		if fsm.current_seconds > 1.0:
			fsm.change_state(States.Idle)
	)
	
	# 置始状态
	fsm.start_state(States.Idle)

func _process(delta: float) -> void:
	# 驱动 process
	fsm.process(delta)
	
func _exit_tree() -> void:
	fsm.clear()
	fsm = null

```

代码的大体意思是，先进入 Idle 状态，一秒后跳转到 Jump 状态，一秒后再跳转到 Idle 状态，如此往复。

运行当前场景，结果如下:

过一秒后，结果如下：


再过一秒后，结果如下:


如此往复。

结果完全正确。

有了这两个 API，状态机就可以做很多工作了，比如计时，比如替代一部分 tween 的功能，屏幕震动，或者流程管理等。

---

### QFramework.Godot4.5+ Addons Qframework Doc 06. EasyFSM 06. 查询当前状态的物理时间 API (`QFramework.Godot4.5+/addons/qframework/doc/06. EasyFSM/06. 查询当前状态的物理时间 API.md`)

有的时候，需要获得当前状态持续的时间，但是只用了 physics_process 生命周期，没有使用 process 生命周期，如果单独为了计时去支持 process 生命周期，一是有点麻烦，二是性能有点浪费。

所以 EasyFSM 支持了由 physics_process 统计的状态持续时间。

我们来看下基本使用。

## 基本使用

在任意场景任意节点，编写如下代码：

``` python
extends Node2D

# 定义状态 id
enum States
{
	Idle
}

# 定义状态机
var fsm:EasyFSM = EasyFSM.new()

func _ready() -> void:
	# 定义 Idle 状态
	fsm.state(
		States.Idle
	).on_physics_process(func(_delta:float):
		print("idle state physics seconds:%f" % fsm.current_physics_seconds)	
	)
	
	# 设置初始态
	fsm.start_state(States.Idle)
	
func _physics_process(delta: float) -> void:
	# 驱动 physics_process 生命周期
	fsm.physics_process(delta)
	
func _exit_tree() -> void:
	fsm.clear()
	fsm = null

```

运行当前场景，结果如下:

结果没问题。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 01. 简介 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/01. 简介.md`)

QFramework 架构是一套简单、强大、易上手的系统设计架构。

这套架构的特性如下：
* 基于 MVC
* 支持样板代码生成
* 分层
* （可选）CQRS 支持
* （可选）事件驱动
* （可选）数据驱动
* （可选）模块化
* （可选）领域驱动设计（DDD）支持
* 符合 SOLID 原则
* 极少的源码实现
* Godot 引擎集成友好
* 支持纸上设计
* 纸上设计支持使用标准 UML
* 熟练架构后，所积累的经验可以脱离框架甚至在其他语言&平台上使用（心中有架构）

## 使用 QFramework 设计的架构图参考


## 架构的各个概念交互示例

(这是笔者画的，水平相当了得:)

这两张图现在大家可能还看不太懂，没关系，我们过一遍快速入门就懂了。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 02. 基本的 MVC  准备 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/02. 基本的 MVC  准备.md`)

QFramework 架构是一套基于 MVC 的开发模式。

所以我们先从最熟知的 MVC 架构开始着手学习 QFramework 架构。

我们先做一个非常简单的计数器应用。

## 计数器应用准备

首先我们用 Godot UI 拼一个最简单的界面场景，如下所示:



场景结构如下:



在根节点我们编写脚本，代码如下：
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

# model
var count:int = 0

func _ready() -> void:
	# 监听输入
	btn_add.pressed.connect(func():
		# 交互逻辑
		count += 1
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		count -= 1
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % count

```

代码很简单，这是一个非常简易的 MVC 的实现。

运行当前场景，结果如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。

这个简易的计数器实现是为接下来介绍各种架构概念而做的准备。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 03. 代码分析&概念介绍 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/03. 代码分析&概念介绍.md`)

我们看下当前编写的计数器应用代码，如下:
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

# model
var count:int = 0

func _ready() -> void:
	# 监听输入
	btn_add.pressed.connect(func():
		# 交互逻辑
		count += 1
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		count -= 1
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % count
```

代码中写了一些注释，我们来简单分析一下。
首先是 MVC，即 Model、View、Controller

Model 的代码如下:

``` python
# model
var count:int = 0
```

非常简单，只有一个成员变量，但是在这里它其实并不算是一个 Model，他只是要在 View 中显示的一个数据而已，具体为什么不是 Model 我们在后边再说。

View 的代码如下:

``` python
# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel
```

View 的代码很简单，View 在 QFramework 的 MVC 定义里就是提供关键组件的引用，比如这三个组件是在 Controller 代码里要用到的。

Controller 的代码如下:
``` python
func _ready() -> void:
	# 监听输入
	btn_add.pressed.connect(func():
		# 交互逻辑
		count += 1
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		count -= 1
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % count
```

Controller 主要是监听用户输入，然后修改 Model 的状态（数据），修改后要将变化更新到界面上（通过 _update_view())

使用图来表示如下:

Controller 主要负责监听 View 的用户输入部分，比如按钮点击，或者一些操作，然后去修改 Model 的状态（数据)，状态修改之后，将数据变更更新到视图上。

图中就是，修改数据和更新视图两个职责。

这两种逻辑有具体的名字，监听用户输入，然后修改 Model 的状态这个流程叫做交互逻辑。

Model 状态发生变更后更新到 View 的逻辑叫做表现逻辑，如图所示:


好了，这一篇介绍了 5 个概念，分别是 Model、View、Controller 以及交互逻辑和表现逻辑。

Model 其实是需要展示的数据，更多时候 Model 的数据是需要共享的。
View 是视图，在 Godot 中是具体的一些节点引用。
Controller 是控制器，主要是负责交互逻辑和表现逻辑。
交互逻辑是从 View->Model 的控制流程。
表现逻辑是从 Model->View 的控制流程。

用图表示如下:

这就是 MVC 架构所需了解的全部概念，了解了这些概念，接下来引入 QFramework 架构的时候会更容易理解。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 03. 图 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/03. 图.md`)

---

excalidraw-plugin: parsed
tags: [excalidraw]

---
==⚠  Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'


# Excalidraw Data

## Text Elements
用户界面（View) ^CHaAvtfh

+ ^BgGtgher

- ^i9kIifL9

0 ^LGjfsgD1

count ^wvN4e0Qe

修改数据 ^UVwIZL2J

修改数据 ^4RXdAWEl

更新视图 ^Ycn73gWN

Controller ^EcT10NMi

model ^qdtsnyvN

交互逻辑 ^8SMTBHk8

交互逻辑 ^bT7ksOjO

交互逻辑 ^t9peZZs5

交互逻辑 ^Rl2tIz3r

表现逻辑 ^8ObajAMS

表现逻辑 ^1cHIVowY

View ^RpNDrzpA

Model ^dCigh5Xa

交互逻辑 ^1jlY8CGX

表现逻辑 ^dGq02AU3

Controller ^YFptOypF

%%
## Drawing
```
/* Detailed source-code truncated for AI context efficiency. */
```
%%

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 04. 图 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/04. 图.md`)

---

excalidraw-plugin: parsed
tags: [excalidraw]

---
==⚠  Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'


# Excalidraw Data

## Text Elements
CounterAppArchitecture ^7LoXV1rr

CounterAppController ^5UnYJzH4

+ ^giVV5kkd

- ^dIq2QIHU

0 ^MdgYQQjs

Count ^8YW7pZ10

%%
## Drawing
```
/* Detailed source-code truncated for AI context efficiency. */
```
%%

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 04. 引入架构&迁移 Count (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/04. 引入架构&迁移 count.md`)

我们看下当前的代码:

``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

# model
var count:int = 0

func _ready() -> void:
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		count += 1
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		count -= 1
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % count

```

目前的代码能运行，是完全没有问题的。

但是我们要用发展的眼光看待问题。

假如这是一个初创项目，那么接下来可能需要大量的业务逻辑。

其中很有可能让 count 在多个 Controller 中使用，甚至需要针对 count 这个数据写一些其他逻辑，比如增加 count 则增加 5 分，或者 count 需要存储等，总之 count 在未来可能会发展成一个需要共享的数据，而 count 目前只属于 CounterAppController，显然在未来是不够用的。

在实际项目开发中，只要秉持这够用即可的理念就可以应付大部分情况，不够用了就整理重构，而目前的情况是够用的，没必要做出任何改变。

不过为了演示项目架构的演化过程，就先假定当前的架构不够用了。

假定策划给了一个需求，这个需求分析下来，需要让 count 这个数据变成全局共享的。

如果不用 QFramework 解决方式很简单，直接创建一个自动加载（autoload）类或者场景，然后将 count 放在这个自动加载类中，或者将 count 改成静态（static）变量，总之至少有这两种方法。

接下来展示使用 QFramework 的解决方式，即引入架构（Architecture）。

## 引入架构

我们打开 QFramework 面板，或者按下 Ctrl + e 即可自动弹出 QFramework 面板，如图所示:

QFramework 面板在底部中心栏可以打开。

然后点击，创建 Architecture，打开创建架构面板，如图所示：


在名字栏输入 CounterApp，如图所示:

输入后，在预览区域可以看到 Autoload 名 和 生成路径。

Autoload 名的意思是，创建架构之后，会将架构自动添加到 Godot 引擎的 Autoload （自动加载）中。

点击名字栏右边的创建按钮，就会创建脚本，并打开脚本，如图所示:

在文件系统会自动创建一个脚本文件，如图所示:


打开 项目->项目设置->全局->自动加载，如图所示:


这样一个名为 CounterApp 的 Architecture 就创建好了。

## 迁移 Count

接着我们将 CounterAppController 中的 count 迁移到 CounterAppArchitecture 中，代码如下:

``` python
class_name CounterAppArchitecture extends Architecture

# model
var count:int = 0

func init():
	pass
```

然后将 CounterAppController 中引用 count 的代码，修改成通过 CounterAppArchitecture 访问，代码如下:
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

func _ready() -> void:
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		CounterApp.count += 1 # +-
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		CounterApp.count -= 1 # +-
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % CounterApp.count # +-

```

这样就迁移好了。

运行当前场景，结果如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。

## 用图表示

用图表示现在的结构，如下所示:


CounterApp 架构，包含静态类或自动加载的特性，可以单纯当做静态类使用。

而现在的计数器应用的 count 数据是共享的了，也就是说从当前的界面跳转到其他界面，再跳转回来这个 count 数据不会清空，而之前版本的计数器引用会清空。

当然，目前是假定的需求，所以才演化到这一步。

而迁移 count 的这个过程，真正的名字叫做重构，QFramework 架构是重构友好的架构。

最后，我们需要将 CounterAppArchitecture 脚本放到我们的示例目录下，如下所示:

接着删除掉 scripts 目录，结果如下:

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 05. 图 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/05. 图.md`)

---

excalidraw-plugin: parsed
tags: [excalidraw]

---
==⚠  Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'


# Excalidraw Data

## Text Elements
CounterAppController ^WP7N2oYf

CounterAppModel ^dhEmtjy1

表现层 ViewControllers ^GiLmqF0r

模型层 Models ^AWob6yoT

CounterAppArchitecture ^JJvyOItJ

%%
## Drawing
```
/* Detailed source-code truncated for AI context efficiency. */
```
%%

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 05. 引入 Model & 什么是需要共享的数据 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/05. 引入 Model & 什么是需要共享的数据.md`)

我们看下当前的全部代码:

counter_app_controller.gd
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

func _ready() -> void:
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		CounterApp.count += 1 # +-
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		CounterApp.count -= 1 # +-
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % CounterApp.count # +-

```

counter_app.gd
``` python
class_name CounterAppArchitecture extends Architecture

# model
var count:int = 0

func init():
	pass
```

我们将 count 迁移到了 CounterApp 架构中，这样有没有问题呢？

在小规模项目或者项目前期，可以把一些共享的数据定义在架构上，也就是把架构当做一个静态类或者全局自动加载去使用。

但是当项目规模变大，或者到项目中后期，需要共享的数据会变得很多，这个时候比较好的方式就是将这些变量放在 Model 中管理。

接下来假定我们的引用计数器的 count 因为规模变大原因需要迁移到 model 中，我们看下基本的过程。

## 引入 Model

创建一个 Model 很简单，只需要在 QFramework 面板上（按 ctrl + e 打开)，选择 点击创建 Model 按钮，得到如下界面:



我们先输入名字，叫 CounterApp，如图所示:


输入之后，直接可以看到类名和生成路径。

如果我们勾选了 独立文件夹，生成路径就会变成如下:

会多创建一个同名的目录。

不过我们不使用这个功能，取消勾选，独立文件夹，如下:


然后点击创建，结果如下:


会自动打开这个 coutner_app_model.gd 脚本，如下:


创建 Model 之后，需要注册到架构中，打开 CounterAppArchitecture 脚本，代码修改成如下:
``` python
class_name CounterAppArchitecture extends Architecture

# model
var count:int = 0

func init():
	register_model(CounterAppModel.new()) # ++
	pass

```

在 init 函数中注册了 CounterAppModel。

这里我们需要将，counter_app_model.gd 移动到我们的示例文件夹中，如下所示:


之后删除掉 scripts 目录，我们在这个示例用不到默认的约定。

好了，我们可以先看一下结果，运行当前场景，如下所示:


没有什么变化，我们查看远程部分，如图所示:


在远程的部分，我们可以清晰地看到 CounterAppModel  已经注册进了 CounterApp 的 Model 中。

与 Unity 版本的 QFramework 不同的是，这次 qfgd 拥抱了 Godot 的 Node，这样的好处是，非常方便调试，可以在游戏运行之后就能看到架构的注册情况，如果选择了 CounterAppModel，我们还能在检查器上看到具体的数据，如图所示:


我们取消运行，然后在 CounterAppModel 中创建一个 count 变量，脚本如下:
``` python
class_name CounterAppModel extends AbstractModel

const NAME:String = "CounterAppModel"

func get_model_name() -> String: return NAME

var count:int = 0 # ++

func init():
	pass

```

然后再运行当前场景，可以看到 CounterAppModel 的检查器如下所示:

Count 这个数据就可以直接看到了。

好了，接着把 CounterAppArchitecture 的 count 删掉，代码如下:
``` python
class_name CounterAppArchitecture extends Architecture

func init():
	register_model(CounterAppModel.new())
	pass

```

然后在 CounterAppController 中，引用 count 的部分，全部都通过获得 Model 完成，代码如下:
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

var _model:CounterAppModel = null

func _ready() -> void:
	# 通过架构获得 model
	_model = CounterApp.get_model(CounterAppModel.NAME) # +-
	
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		_model.count += 1 # +-
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		_model.count -= 1 # +-
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % _model.count # +-
	
func _exit_tree() -> void: #+=
	_model = null # ++

```

运行当前场景，结果如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。

这样引入 Model 就完成了。

## 什么是需要共享的数据?

之前说过，需要共享的数据放在 Model 中管理，那么什么是需要共享的数据？

常见的总共有三种:
* 需要存储的数据，是时间上共享的数据。即游戏应用关闭之后，下次打开数据是不变的。
* 需要在多个界面或者 Node 上使用的数据，是物理上共享的数据，即游戏不管是打开别的界面还是跳转到别的场景，数据在内存中是不会被释放的。
* 配置表也是共享的数据，即游戏开发阶段所配置的数据，共享给玩家游玩时的引用，提供数据的查询和展示。

那么非共享的数据放在哪里呢？

主要放在 Node 脚本中就行，比如要判断用户是否输入了，如果输入了则显示某个按钮，那么这里就需要一个 bool 变量的判断，这个 bool 变量如果放在 Model 里就不合适了，因为这个 bool 变量只是被当前的界面一次性使用了，除非这个 bool 变量需要存储或者在别的地方引用了，才适合放到 Model 里。

再比如一个常见的问题是，敌人的生命要不要放在 Model 里？

答案是不需要的，敌人的生命交给敌人脚本自己管理即可。

如果敌人的生命需要存储，那么存储和加载就需要将敌人的生命转换成对应的存储用的数据结构，然后将这个数据结构序列化并保存，而 Model 不需要持有这种数据结构。

如果敌人的最大生命需要查询，那么可以从配置表里查询，配置表可以由 Model 管理，也可以由之后要介绍的 System 管理。也可以将最大数据的配置定义在 敌人节点 或者 敌人场景上，把场景当做配置。


## 当前的架构图

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 06. 引入 Command (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/06. 引入 Command.md`)

我们回顾一下当前的代码，如下:

counter_app_model.gd
``` python
class_name CounterAppModel extends AbstractModel

const NAME:String = "CounterAppModel"

func get_model_name() -> String: return NAME

var count:int = 0 

func init():
	pass

```

counter_app.gd
``` python
class_name CounterAppArchitecture extends Architecture

func init():
	register_model(CounterAppModel.new())

```

counter_app_controller.gd
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

var _model:CounterAppModel = null

func _ready() -> void:
	# 通过架构获得 model
	_model = CounterApp.get_model(CounterAppModel.NAME) # +-
	
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		_model.count += 1 # +-
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		_model.count -= 1 # +-
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % _model.count # +-
	
func _exit_tree() -> void: #+=
	_model = null # ++

```

现在，数据共享的问题，通过引入 Model 解决了。

这里再次强调一下，需要共享的数据放 Model 里，不需要共享的，能不放就不放。

虽然引入了 Model，但是这套代码随着项目规模的发展还是有很多的问题的。

其中最严重也最常见的就是 Controller 会越来越臃肿。

我们简单分析一下为什么 Controller 会越来越臃肿，我们先看下监听用户输入部分的代码，如下:

``` python
# 监听输入
btn_add.pressed.connect(func(): 
	# 交互逻辑
	_model.count += 1 # +-
	# 表现逻辑
	_update_view()	
)

# 监听输入 
btn_sub.pressed.connect(func():
	# 交互逻辑
	_model.count -= 1 # +-
	# 表现逻辑
	_update_view()	
)
```

在处理用户输入的代码中，笔者写了注释，交互逻辑 和 表现逻辑。

交互逻辑 和 表现逻辑在之前有简单介绍过。

交互逻辑，就是从用户输入到数据变更的逻辑。

顺序是 View->Controller->Model。

表现逻辑，就是数据变更到在界面显示的逻辑。

顺序是 Model->Controller->View。

如下图所示:



虽然交互逻辑和表现逻辑理解起来简单，但是他们非常重要，因为 QFramework 接下来的概念都是围绕这两个概念展开的。

View、Model 以及 Controller 的交互逻辑和表现逻辑形成了一个闭环。构成了完整的 MVC 闭环。

而 Controller 本身之所以会逐渐臃肿，是因为，它负责了两种职责，即改变 Model 数据 的交互逻辑，以及 Model 数据变更之后更新到界面的 表现逻辑。

而在一个有一定规模的项目中，表现逻辑和交互逻辑非常多。一个 Controller 很容易就做到上千行代码。

而大部分的 MVC 方案，解决 Controller 臃肿问题是通过引入 Command 的方式，即引入命令模式，通过命令模式来分担 Controller 的交互逻辑的职责。

QFramework 也是使用了同样的方式解决 Controller 臃肿的问题。

我们来看下用法。

## 引入 Command

打开 QFramework(快捷键 ctrl + e) 面板，然后打开 创建 Command 页面，如下:



在名字栏输入 IncreaseCount ，结果如下:



点击创建，结果如下:



此脚本也被自动打开了:



我们再用同样的流程，创建 DecreaseCount 命令，按 ctrl + e 打开 QFramework 面板，然后打开 创建 Command 界面，如下:



然后在名字栏，输入 DecreaseCount ，点击创建，结果如下:



此时这个脚本被自动打开:



我们将 scripts/commands 移动到示例目录下，然后将示例的目录整理一下，结果如下:



models 是这次整理时创建的，这样的目录结构，其实是大部分使用 QFramework 架构的项目的目录结构，以后看到类似的目录结构的项目就知道这个项目使用了 QFramework。

好了，我们来编写脚本，讲 Controller 的交互逻辑迁移到这两个命令里，产生修改的脚本如下:

increase_count_command.gd
``` python
class_name IncreaseCountCommand extends AbstractCommand

func execute():
	var model = get_model(CounterAppModel.NAME) as CounterAppModel # ++
	model.count += 1 # ++
```

decrease_count_command.gd
``` python
class_name DecreaseCountCommand extends AbstractCommand

func execute():
	var model = get_model(CounterAppModel.NAME) as CounterAppModel # ++
	model.count -= 1 # ++
```

counter_app_controller.gd
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

var _model:CounterAppModel = null

func _ready() -> void:
	# 通过架构获得 model
	_model = CounterApp.get_model(CounterAppModel.NAME) 
	
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		CounterApp.send_command(IncreaseCountCommand.new()) # +-
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		CounterApp.send_command(DecreaseCountCommand.new()) # +-
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % _model.count
	
func _exit_tree() -> void:
	_model = null

```

代码很简单，我们用流程图表示如下:



运行当前场景，结果如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。

大家可能会问，一个简单的数据加减操作，至于创建一个 Command 对象来承担么？看不出来好处呀，反而代码更多了。

如果整个项目只有一个简单的数据加减操作，那使用 Command 有点多此一举，但是一般的项目的交互逻辑，是非常复杂的，代码量也非常多，这个时候使用 Command 才会发挥作用。

具体发挥什么作用，使用 Command 可以带来很多便利，比如：

* Command 可以复用，Command 也可以调用 Command
- Command 可以比较方便实现撤销功能，如果 App 或者 游戏需要的话
- 如果遵循一定规范，可以实现使用 Command 跑自动化测试
- Command 可以定制 Command 队列，也可以让 Command 按照特定的方式执行
- 一个 Command 也可以封装成一个 Http 或者 TCP 里的一次数据请求
- Command 可以实现 Command 中间件模式
- 等等

OK，通过引入 Command，帮助分担了 Controller 的交互逻辑。使得 Controller 成为一个薄薄的一层，在需要修改 Model 的时候，Controller 只要调用一句简单的 Command 即可。

Command 最明显的好处就是：

- 就算代码再乱，也只是在一个 Command 对象里乱，而不会影响其他的对象。
- 讲方法封装成命令对象，可以实现对命令对象的组织、排序、延时等操作。

更多好处会随着大家的实践慢慢体会到。

当前的 MVC 流程如下：

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 07. 引入 Event & CQRS 介绍 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/07. 引入 Event & CQRS 介绍.md`)

我们看下当前的代码:

counter_app_controller.gd
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

var _model:CounterAppModel = null

func _ready() -> void:
	# 通过架构获得 model
	_model = CounterApp.get_model(CounterAppModel.NAME)
	
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		CounterApp.send_command(IncreaseCountCommand.new())
		# 表现逻辑
		_update_view()	
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		CounterApp.send_command(DecreaseCountCommand.new())
		# 表现逻辑
		_update_view()	
	)

func _update_view():
	count_label.text = "%d" % _model.count
	
func _exit_tree() -> void:
	_model = null

```


counter_app.gd
``` python
class_name CounterAppArchitecture extends Architecture

func init():
	register_model(CounterAppModel.new())

```

counter_app_model.gd
``` python
class_name CounterAppModel extends AbstractModel

const NAME:String = "CounterAppModel"

func get_model_name() -> String: return NAME

var count:int = 0 

func init():
	pass

```

increase_count_command.gd
``` python
class_name IncreaseCountCommand extends AbstractCommand

func execute():
	var model = get_model(CounterAppModel.NAME) as CounterAppModel
	model.count += 1
```

decrease_count_command.gd
``` python
class_name DecreaseCountCommand extends AbstractCommand

func execute():
	var model = get_model(CounterAppModel.NAME) as CounterAppModel
	model.count -= 1
```

我们通过引入了 Command 来帮助 Controller 分担了一部分的交互逻辑。

但是表现逻辑的代码目前开起来并不是很智能。

表现逻辑的代码如下:

``` python
# 监听输入
btn_add.pressed.connect(func(): 
	# 交互逻辑
	CounterApp.send_command(IncreaseCountCommand.new())
	# 表现逻辑
	_update_view()	
)

# 监听输入 
btn_sub.pressed.connect(func():
	# 交互逻辑
	CounterApp.send_command(DecreaseCountCommand.new())
	# 表现逻辑
	_update_view()	
)
```

每次调用逻辑之后，表现逻辑部分都需要手动调用一次（UpdateView 方法）。

在一个项目中，表现逻辑的调用次数，至少会和交互逻辑的调用次数一样多。因为只要修改了数据，对应地就要把数据的变化在界面上表现出来。

而这部分调用表现逻辑的代码也会很多，所以我们引入一个事件机制来解决这个问题。

这个事件机制的使用其实是和 Command 一起使用的，这里有一个简单的小模式，如下图所示：



即通过 Command 修改数据，当数据发生修改后发送对应的数据变更事件。

这个是简化版本的 CQRS 原则，即 Command Query Responsibility Separiation，读写分离原则。

引入这项原则会很容易实现 事件驱动、数据驱动 架构。

在 QFramework 中，用法非常简单， 我们先在 CounterAppModel 定义一个 count_changed 事件，代码如下:

``` python
class_name CounterAppModel extends AbstractModel

const NAME:String = "CounterAppModel"

func get_model_name() -> String: return NAME

var count:int = 0 

var count_changed:EasyEvent = EasyEvent.new() # +

func init():
	pass
```

然后在 IncreaseCountCommand 和 DecreaseCountCommand 中，当 count 变更后，触发这个事件，代码如下:

``` python
class_name IncreaseCountCommand extends AbstractCommand

func execute():
	var model = get_model(CounterAppModel.NAME) as CounterAppModel
	model.count += 1
	model.count_changed.trigger() # ++

```

``` python
class_name DecreaseCountCommand extends AbstractCommand

func execute():
	var model = get_model(CounterAppModel.NAME) as CounterAppModel
	model.count -= 1
	model.count_changed.trigger() # ++

```

最后，我们在 CounterAppController 中注册这个事件，代码如下:
``` python
extends Control

# controller
class_name CounterAppController

# view
@onready var btn_add: Button = $BtnAdd
@onready var btn_sub: Button = $BtnSub
@onready var count_label: Label = $CountLabel

var _model:CounterAppModel = null

func _ready() -> void:
	# 通过架构获得 model
	_model = CounterApp.get_model(CounterAppModel.NAME)
	
	# 监听输入
	btn_add.pressed.connect(func(): 
		# 交互逻辑
		CounterApp.send_command(IncreaseCountCommand.new())
	)
	
	# 监听输入 
	btn_sub.pressed.connect(func():
		# 交互逻辑
		CounterApp.send_command(DecreaseCountCommand.new())
	)
	
	# 注册 count 变更事件
	(_model.count_changed # ++
		.register_with_a_call(_update_view) # ++
		.un_register_when_created_child_node_destroyed(self)) # ++

func _update_view():
	count_label.text = "%d" % _model.count
	
func _exit_tree() -> void:
	_model = null

```

代码很简单。

流程图如下：


运行当前场景，结果如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。

引入事件机制 和 CQRS 原则后，我们的表现逻辑的代码变少了很多。

由原来的两次主动调用
``` python
# 监听输入
btn_add.pressed.connect(func(): 
	# 交互逻辑
	CounterApp.send_command(IncreaseCountCommand.new())
	# 表现逻辑
	_update_view()	
)

# 监听输入 
btn_sub.pressed.connect(func():
	# 交互逻辑
	CounterApp.send_command(DecreaseCountCommand.new())
	# 表现逻辑
	_update_view()	
)
```

变成了一次监听事件，接收事件进行调用。

``` python
# 监听输入
btn_add.pressed.connect(func(): 
	# 交互逻辑
	CounterApp.send_command(IncreaseCountCommand.new())
)

# 监听输入 
btn_sub.pressed.connect(func():
	# 交互逻辑
	CounterApp.send_command(DecreaseCountCommand.new())
)

# 注册 count 变更事件
(_model.count_changed
	.register_with_a_call(_update_view)
	.un_register_when_created_child_node_destroyed(self))
```

这样减缓了很多表现逻辑。

OK，到此，我们算是用上了还算合格的 MVC 的实现，而 QFramework 所提供的概念中，最重要的概念已经接触到了，即 CQRS，通过 Command 去修改数据，数据发生修改后发送数据变更事件。

当前的示意图如下：



学到这里，对于 QFramework 架构的使用算是真正的入门了。

不过接下来还有一些概念，我们下一篇继续。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 08. 引入 Utility (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/08. 引入 Utility.md`)

在这一篇，我们来支持 CounterApp 的存储功能。

## 存储功能支持

其代码也很简单，只需要修改一部分 Model 的代码即可，如下:


运行场景，结果如下:


当我按下 + 号，结果如下:


停止运行，再运行当前场景，结果如下:


count 被存储后重新加载了，结果是正确的。

这样就支持了非常基本的数据存储功能。

当然还是有一些问题，如果时候未来我们需要存储的数据非常多的时候，Model 层就会充斥大量存储、加载相关的代码。

还有就是，我们以后如果不想用 ConfigFile 了，而是想使用 json 或者 xml 甚至 sqlite 的时候，就会造成大量的修改工作量，会牵一发而动全身。

于是 QFramework 提供了一个 Utility 层，专门用来解决上述两个问题的，使用方法非常简单。

## 引入 Utility

按 ctrl + e 打开 QFramework 面板，打开 创建 Utility 界面，如下:


我们在名字栏输入 Storage，如图所示:

点击右边的创建，结果如下:




我们将 utilities 目录移动到示例文件夹中，并删除掉 scripts 目录，结果如下:



我们将 model 中的存储相关的代码，迁移到 storage_utility.gd 中，代码如下:

这里设计了两个 API。

我们将 StorageUtility 注册到架构中，代码如下:

然后在 Model 中，使用 StorageUtility 来完成存储和加载的功能，代码如下:


代码很简单，运行场景，结果如下:


当我按下 + 号，结果如下:


停止运行，再运行当前场景，结果如下:


count 被存储后重新加载了，结果是正确的。

这样当我们，想要将 ConfigFile 方案替换成 json 的时候，只需要对 Storage 里的代码进行修改即可。

最后给出流程图，如下：

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 09. 引入 System (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/09. 引入 System.md`)

在这一篇，我们来引入最后一个基本概念 System。

首先我们来看下代码，如下：

counter_app_controller.gd

increase_count_command.gd

decrease_count_command.gd

counter_app_model.gd

storage_utility.gd

counter_app.gd

这里我们假设一个功能，即策划提出了一个成就达成的功能，即 count 到 10 的时候，触发一个点击达人成就，点击二十次则触发一个点击专家成就。

## 成就功能

逻辑听起来很简单，我们直接在 IncreaseCountCommand 里编写即可，如下:

代码很简单，我们运行测试一下。

运行之后，笔者点击了 20 次 + 号，结果如下:

这个功能很快就完成了。

但是这个时候策划说，希望再增加一个当点击 - 号到 -10 时，触发一个 点击菜鸟成就，然后策划还说，点击达人 和 点击专家 成就太容易达成了，需要分别改成 1000 次 和 2000 次。

而这次策划提出的需求，需要我们修改两处的代码，即 IncreaseCountCommand 里需要修改数值为 1000 和 2000，然后再 DecreaseCountCommand 增加一个判断逻辑。

一次提出的需求，结果造成了多处修改，这说明代码有问题。

首先像这种规则类的逻辑，比如分数统计或者成就统计等代码，不适合分散写在 Command 里，而适合统一写在一个对象里，而这种对象，在 QFramework 里有提供，就是 System 对象。

## 引入 System

按下 ctrl + e 打开 QFramework 面板，然后打开 创建 System 界面，如下:

输入名字 Achievement 点击创建，结果如下:




将 systems 目录迁移到 示例目录下，然后将 scripts 目录删掉，结果如下:


我们将 IncreaseCountCommand 中成就相关的代码迁移到 AchievementSystem 中，代码如下:





代码很简单。

要记得将 System 注册到架构中，代码如下:


运行当前场景，将当前的数字按 - 号归零，然后按 20 次 + 号，结果如下:


好了，笔者写的成就系统非常简陋，实际上成就系统可以写得非常完善，比如可以再成就系统里进行存储加载等操作，而此文的成就系统仅仅是展示目的。

到此，我们就接触到了 QFramework 架构所提供的核心概念。

## 回顾两张图

我们回顾一下第一篇的两张图，如下:





到此，大家应该能看懂这两张图了。

QFramework 总共分四个层级，即
- 表现层：IController
- 系统层：ISystem
- 数据层：IModel
- 工具层：IUtility

除了四个层级，还接触了为 Controller 的交互逻辑减负的 Command 和 为表现逻辑减负的 Event。

还有一个非常重要的 CQRS 原则的简易版本，Command->Model->State Changed Event。

到目前为止 QFramework 的基本用法我们过了一遍了。

从下一篇开始，我们开始介绍 QFramework 架构提供的剩余功能，这些功能是可选的。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 10. 使用 BindableProperty 优化事件 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/10. 使用 BindableProperty 优化事件.md`)

BindableProperty 是包含 数据 + 数据变更事件 的一个对象。

我们先看一下 Model 的代码:



这个 CounterAppModel 中定义了 count 数据和 count_changed 事件。

一个是 count 数据本身，以及对应的 count 变更事件，那么正好可以使用 BindableProperty 来优化。

我们看下优化后的代码，如下:


对应的 Command 以及 Controller 以及 System 都要修改，如下:








代码改动很多，重要的改动为：

* Model 中的 count 和 count_changed 改成了一个叫做 count 的 BindableProperty
* 删掉了 count_changed 改用监听 BindableProperty

可以说代码量少了一些，运行当前场景，结果如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。

运行没问题。

由于我们的 count 数据，是单个数据 + 事件变更的形式，所以用 BindableProperty 非常合适，可以少写很多代码。

一般情况下，像主角的金币、分数等数据非常适合用 BindableProperty 的方式实现。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 11. 使用接口（抽象类）设计模块（依赖倒置原则） (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/11. 使用接口（抽象类）设计模块（依赖倒置原则）.md`)

QFramework 本身支持依赖倒置原则，就是所有的模块访问和交互都可以通过接口来完成。

但是 GDScript 目前还不支持接口，其实接口在技术层面与全部都是抽象函数的抽象类是一样的，但是接口可以多继承，而抽象类只能单继承。

不过足够了。

我们先尝试让 CounterAppModel 支持依赖倒置原则。

首先我们创建一个脚本叫 i_counter_app_model.gd 与 counter_app_model.gd 放在同一目录下，如图所示:


我们先看一下 counter_app_model.gd 的代码，如下:


其中 count 是需要对外提供的一个成员变量，而 GDScript 不支持抽象属性的 getter 和 setter，所以我们需要在 i_counter_app_model 中定义一个抽象的获取 count 的函数，然后让继承这个抽象类的子类去实现。

在 i_counter_app_model.gd 编写代码如下:


注意 NAME 改成了 "ICounterAppModel"。

然后让 CounterAppModel 去继承这个类，代码如下:



此时 CounterAppModel 的 get_model_name 返回的是父类的，也就是 ICounterAppModel 的 NAME。

我们不用去修改架构中模块注册的代码，还是保持不变，如下:

但是在原来使用 CounterAppModel 的部分，都需要改成 ICounterAppModel，代码如下:









好了，可以发现所有的地方都换成了通过抽象类（接口）ICounterAppModel 去访问 count，而不是通过具体实现的 CounterAppModel 去访问 count 了。

这就做到了，依赖抽象而不依赖具体，这个就是依赖倒置原则的定义。

依赖倒置原则的好处就是可以非常方便地替换方案。

比如我们目前使用的存储是用的 Config 的方式，如下所示:


我之后想换成别的方案，比如 SQLite 或者 JSON + FileAccess ，一般情况下我们就直接改这个 StorageUtility ，但是换成别的方案后又要换回原来的方案，那么就得把代码又改回来或者用版本工具回滚过来，很不方便。

但是如果我们把 StorageUtility 定义成一个抽象类（接口），也就是 IStorageUtility，然后定义好统一的存储和保存的抽象函数，比如目前是 load_int 和 save_int，然后不同的实现方案分别用不同的 IStorageUtility 子类去实现，比如 SQLiteStorageUtility 或者 JSONStorageUtility 还有 ConfigFileStorageUtility。这样在整个游戏系统中，调用的部分是通过 IStorageUtility 去完成加载和存储工作，而具体的存储和加载细节方案则根据用哪个子类去注册来决定。

而替换一个存储方案或者扩展一个存储方案也会变得非常方便，不会影响其他调用存储和加载部分的代码。

这就是依赖倒置原则的好处。

我们将刚刚写的代码测试一下，如下:


当我按下 + 号，结果如下:


当我按两下 - 号，结果如下:


结果没问题。


如果是比较复杂且庞大的项目或者多人团队协作的项目，依赖倒置原则会发挥巨大的威力。

所有的模块注册，模块获取 等代码都是通过接口完成。

通过接口设计模块可以让我们更容易思考模块之间的交互和职责本身，而不是具体实现，在设计的时候可以减少很多的干扰。

当然面向接口的方式去做开发也有很多其他的好处，这当然是要靠大家随着使用时长慢慢去体会了。

甚至如果自己负责接口的设计，AI 也能产出更准确的代码。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 12. 引入 Query (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/12. 引入 Query.md`)

Query 是 CQRS 中的 Q，也就是 Command Query Responsibility Separation 中的 Query。

关于 Command 我们已经介绍了。

首先 Controller 中的表现逻辑更多是接收到数据变更事件之后，对 Model 或者 System 进行查询，而查询的时候，有的时候需要组合查询，比如多个 Model 一起查询，查询的数据可能还需要转换一下，这种查询的代码量比较多。尤其是像模拟经营或者非常重数据的项目，所以 QFramework 支持通过 Query 这样的一个概念，来解决这部分问题。

使用的方式也很简单，和 Command 用法一致，这里我们写一个小的 App， 叫做 QueryExampleApp 代码如下:

query_example_controller.gd


为了节省篇幅，直接将 Model 和 Query 的定义蟹道乐 Controller 内部。

然后使用之前同样的方式创建了一个 Architecture，如下:

query_example_app.gd


运行 controller 所在的场景，结果如下:


好了，这样 Query 的示例就写完了。

Query 是一个可选的概念，如果游戏中数据的查询逻辑并不是很重的话，直接在 Controller 的表现逻辑里写就可以了，但是查询数据比较重，或者项目规模非常大的话，最好是用 Query 来承担查询的逻辑。

Command 一般负责数据的 增 删 改，而 Query 负责数据的 查。

如果游戏需要从服务器同步数据，一般拉取服务器数据的请求可以写在 Query 中，而增删改服务器输的请求可以写在 Command 中。

好了，关于 Query 就介绍到这里。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 13. 架构规范 与 推荐用法 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/13. 架构规范 与 推荐用法.md`)

QFramework 架构提供了四个层级：
* 表现层：Controller
* 系统层：System
* 数据层：Model
* 工具层：Utility

除了四个层级，还提供了 Command、Query、EasyEvent、BindableProperty 等概念和工具。

这里有一套层级的规则，如下:
* 表现层：ViewController 层。负责接收输入和状态变化时的表现，一般情况下，Node 关联的脚本 均为表现层
	* 可以获取 System、Model、Utility（不推荐）
	* 可以发送 Command、Query
	* 可以监听 Event
* 系统层：System 层。帮助 Controller 承担一部分逻辑，在多个表现层共享的逻辑，比如计时系统、商城系统、成就系统等
	* 可以获取 System、Model、Utility
	* 可以监听 Event
	* 可以发送 Event
* 数据层：Model 层。负责数据的定义、数据的增删改查方法的提供
	* 可以获取 Utility
	* 可以发送 Event
* 工具层：Utility 层。负责提供基础设施，比如存储方法、序列化方法、网络连接方法、蓝牙方法、SDK、框架集成等。可以集成第三方库。或者封装 API。
* Command：命令，负责数据的增删改。
	* 可以获取 System、Model、Utility
	* 可以发送 Event、Command
* Query：查询、负责数据的查询
	* 可以获取 System、Model
	* 可以发送 Query


通用规则：
* Controller 更改 System、Model 的状态必须用 Command
* System、Model 状态发生变更后通知 Controller 必须用事件或 BIndablePropery
* Controller 可以获取 System、Model 一下来进行数据查询，也可以获取 Utility 不过不推荐，可以在前期用下。
* Command、Query 不能有状态
* 上层可以直接获取下层，下层不能获取上层对象
* 下层向上层通信用事件
* 上层向下层通信用方法调用（只是做查询，状态变更用 Command），Controller 的交互逻辑为特别情况，只能用 Command 通用规则是理想状态下的一套规则，但是落实的实际项目，很有可能需要对以上规则做一些修改。


如果是打算先了解或学习 QFramework 架构，那么我推荐就先按照 QFramework 默认的架构规范来做练习项目。

如果是打算马上用 QFramework 做项目，那么可以再保持原有开发习惯的基础上，一点点引入 QFramework 的概念，比如一开始用 BindableProperty 和 Architecture 来解决 Model 和数据更新的问题。

再慢慢开始用 Command 来解决交互逻辑臃肿的问题，以此类推，直到能完全掌握全部概念，最终能修改和定制 QFramework 源码。

---

### QFramework.Godot4.5+ Addons Qframework Doc 07. Architecture 14. 光速实现 EditorCounterApp 和 给主程看的开发模式 (`QFramework.Godot4.5+/addons/qframework/doc/07. Architecture/14. 光速实现 EditorCounterApp 和 给主程看的开发模式.md`)

首先，我们来做一个好玩的事情，就是在前边已经实现好的 CounterApp 的基础上，光速实现一个编辑器版本的 CounterApp。

首先我们在 addons 目录下创建搞一个 editor_counter_app 文件夹，同时创建一个  plugin.cfg  和 plugin.gd 两个文件。

plugin.cfg 代码如下:
``` ini
[plugin]

name="editor counter app"
description="counter app 编辑器版 "
author="liangxiegame"
version="v0.1.1"
script="plugin.gd"
```

plugin.gd 代码如下:
``` go
@tool
extends EditorPlugin

class_name EditorCounterPlugin

# 编辑器根节点
var root_area: VBoxContainer

# 架构
var architecture:CounterAppArchitecture;

func _enter_tree() -> void:	
	root_area = VBoxContainer.new()
	# 将架构添加到 编辑器的根节点
	architecture = CounterAppArchitecture.new()
	root_area.add_child(architecture)
	# 将编辑器添加到底部栏
	add_control_to_bottom_panel(root_area,"EditorCounterApp")

func _ready() -> void:
	# 添加按钮
	var add_button = Button.new()
	add_button.text = "+"
	add_button.pressed.connect(func():
		architecture.send_command(IncreaseCountCommand.new())	
	)
	root_area.add_child(add_button)
	
	# 计数显示
	var count_label = Label.new()
	count_label.text = "0"
	root_area.add_child(count_label)
	
	# 数值绑定
	var model = architecture.get_model(ICounterAppModel.NAME) as ICounterAppModel
	model.count.register_with_init_value(func(c:int):
		count_label.text = "%d" % c
	).un_register_when_created_child_node_destroyed(root_area)
	
	# 减少按钮
	var sub_button = Button.new()
	sub_button.text = "-"
	sub_button.pressed.connect(func():
		architecture.send_command(DecreaseCountCommand.new())	
	)
	root_area.add_child(sub_button)
		
func _exit_tree() -> void:
	# 从 dock 移除面板
	if root_area:
		remove_control_from_bottom_panel(root_area)
		root_area.free()
		root_area = null

```

然后到 项目->项目设置->插件中开启 EditorCounterApp 插件，如下:


开启后，在底部栏就有了 EditorCounterApp 的选项了，如下:


当我按下 + 号，结果如下:

当按两下 - 号，结果如下：


结果完全正确。

这样就非常快速地实现了 CounterApp 的 编辑器版本。

因为 QFramework 写的 App ，底层三层是可以复用的。

如图所示：

底层的三层 与 表现层 的通信方式有 Command、回调/事件、方法/Query。

我们可以把表现层类比成网页前端，而底层三层类比成服务器。

那么 Command、回调/事件、方法/Query 其实就是类似于 HTTP 或者 TCP 的接口或协议。

而接口或者协议只要做好约定，那么前端就不需要关心服务端的具体实现了，而服务端也不需要关心前端的具体实现。

这就做到了在分工时，将表现层和底层三层的工作分别给不同的人来负责。

而笔者曾经做过一个这样的 Unity 项目。

在项目中笔者负责将 底层三层实现好，然后和服务器把数据和接口调好，数据的显示部分笔者用的一个快速写界面的方案，比如 xmllayout(Unity 插件，xml 写 UI) 或者 delight（Unity 插件，xml 生成 UI 代码），这种方案写界面非常快，可以用来实现系统原型。

然后等数据和接口调好，系统原型实现好后，把界面、做场景流程、做表现的工作都分配给了初学者的同事们，同事们只要看实现的系统原型，就知道调用哪些 Command/Query、监听哪些事件、或调用哪些方法，这样就可以做好分工协作了。

用一张图表示如下：


当然这只是其中一种的项目开发模式。

随着时间，初学者同事们用熟了这套架构之后，渐渐地也能自己写底层三层了，于是笔者就慢慢把底层的工作量也分出去了，自己就没啥事干了。

好了，这就是一次笔者曾经使用的一种开发模式的分享，而具体自己的开发模式，需要根据实际情况来制定，最简单的方式就是先按照原有的习惯的开发模式，然后逐渐掌握这套架构，掌握了之后慢慢改进之前的开发模式。

另外对于快速用代码写界面的工具，qfgd 包含了一个 FluentUI 的封装库，这个会在后续介绍。

这篇就介绍到这里。

---

--- METRICS ---
- Files Extracted: 46
- Estimated Token Budget: ~20439 tokens
- Canonical Reference: https://codewiki.google/github.com/liangxiegame/QFramework
