写界面软件,经常遇到这么一类场景:
主界面点击应用窗口进入某模块显示界面,某模块显示界面再通过按钮进入菜单界面,菜单界面有很多关于该模块显示界面的设置项,比如量程,增益,时间显示,亮度,对比度等等,大概十几个设置。
有些数值类的设置还有子预览菜单,在子预览菜单里面通过滑条去设置数值,回到菜单后,设置会显示子预览菜单设置的数值。
模块显示界面需要显示一些菜单的设置,比如量程,增益等等。
也就是大概这么一个页面关系,几个页面存在线性的导航关系,同时数据来源大量重复。
做这种页面逻辑的一个难点就是,设置某个菜单项,对应的修改数据怎么同步到上一层/几层窗口上去。
解决这个问题的经历经过了两次设计思路的迭代,也借此终于了解了Signal/Slot的原理和用法,算有收获,故此做记录。
旧思路
出于使用的框架特性,个人开发经验等原因,我当时解决此类问题的设计图示如下:
假设存在页面A->B->C的线性导航关系,每个页面根据MVC的设计思路,都做页面逻辑,业务逻辑,持久化(配置文件/数据库)三层逻辑的分层设计。
以一系列操作讲解大致的数据流:
1.比如从A进入页面B(图中步骤1),页面B初始化,从数据库加载设置数据(图中步骤2)
2.在页面B点击设置一些数据(设置页面的各种单选,数值滑条等等),设置的数据经由页面对应的业务逻辑方法,保存到数据库(图中步骤3)
3.返回页面A(图中步骤4),通过一些方法触发页面A的业务逻辑,从数据库再次载入从页面B修改好的数据,完成页面A更新(步骤5)。
这是简化过的设计框架,实际开发还有一些例外情况,比如几个页面有显示同一个数据的通用业务逻辑,不一定所有页面都要读写数据库之类,但是大体不离上图的思路。
如果有看过我的文章,其实这就是我在AWTK-MVVM的一些使用技巧总结: 跨页面同步数据的model,写model getter和setter将UI代码和model代码联系到一起的实践,只不过页面逻辑与业务逻辑的交互变成了MVVM框架的数据绑定和命令绑定(其实我那样写还更有问题,model和几个页面的业务逻辑耦合了),但是由于偏题,框架的东西就不说了。
这个开发思路面对这种场景够用了,但是缺点也很明显:
1.只能用于线性导航关系,对于分屏等同一个页面上并列的关系,需要另外设计数据结构完成多个屏幕业务逻辑的同步。
2.一个页面只能更新上一个页面,没法连带更新上N个页面,理论上,有N层导航关系,1个数据被N个页面用到,就要访问N次数据库,很不优雅,对IO效率敏感的机器也吃不消。
3.为了实现图中的步骤5,有几种实现方法:
1.在退出页面的exit回调(上图页面B)调用上一个页面(页面A)的函数来完成业务逻辑,最简单直接,但是这样页面和页面间逻辑就耦合了,如果还有其他页面也要调用页面A,且处理方法跟页面A不同,那就不行了。
ret_t on_pageB_exit(win, ctx){
db_data *data = get_db_data();
on_pageA_load(data);
return RET_OK;
}c
2.更一步,可以搞成页面A传return callback上下文给页面B在退出时调用的方式,来避免在页面B暴露具体的页面方法,但是仍旧无法解决缺点2。
ret_t on_pageA_load(){
db_data *data = get_db_data();
// set pageA data
}
ret_t on_pageA_navigate(){
ctx->exit_call = on_pageA_load;
navigator_to("pageB");
}
...
ret_t on_pageB_exit(win, ctx){
ctx = widget_get_prop_pointer(win, "ctx");
ctx->exit_call();
}
ret_t on_pageB_init(win, ctx){
widget_set_prop_pointer(win, "ctx", ctx);
}
其实上述页面页面连带更新的问题正好在Signal/Slot的射程范围内,然而当时,限于经历我并未意识到Signal/Slot的作用,只认为是类似按钮点击事件回调同一类的东西。
新思路:Signal+Slot
机缘巧合,接到一个要搬运其他项目模块到自己项目的任务,要搬运的代码逻辑实现大量使用QT,和自己项目的AWTK不兼容,给实现造成了不小的麻烦。
Signal/Slot是QT框架独特, 核心的特性之一,搬运的代码也大量用到了,遗憾的是,使用的AWTK框架并没有类似的特性,本身类似的emitter模块并不算很好用,也承接不了如lamada这样C++才能用的特性,我最后用了boost的signal2库去弥补这一不足。
不过多少还有点收获,干了这么久非QT的GUI框架,总算能接触到全球最经典最常用的GUI框架,去了解下用它开发的思维模式了。
代码
如果不想看代码可直接跳到标题:原理
还是以上面的页面A,B,C为例,新建一个全局性的SignalManager单例,在里面添加一个名为sigDataChange的signal函数,并在page_A,page_B,page_C页面文件里面都注册这个函数:
SignalManage.hpp
#ifndef SIGNAL_MANAGER_HPP
#define SIGNAL_MANAGER_HPP
#include <boost/signals2.hpp>
// 类型别名定义
template<typename T>
using signal = boost::signals2::signal<T>;
using connection = boost::signals2::connection;
class SignalManager {
public:
static SignalManager &instance(){
static SignalManager instance;
return instance;
}
signal<void(int)> sigDataChange;
private:
SignalManager();
~SignalManager();
SignalManager(const SignalManager &) = delete;
SignalManager &operator=(const SignalManager &) = delete;
SignalManager(SignalManager &&) = delete;
SignalManager &operator=(SignalManager &&) = delete;
};
#endif // SIGNAL_MANAGER_HPP
pageA,B,C的逻辑如下, 这些文件实际上是页面c文件的c++扩展,AWTK由于定位偏向纯C开发,页面文件为了兼容需要自己写不少CPP逻辑扩展,比较繁琐。
page_a_cpp.cpp
#include "awtk.h"
#include "../common/navigator.h"
#include "page_a_cpp.h"
#include "../common/SignalManager.hpp"
#include <boost/current_function.hpp>
#include <stdio.h>
class PageA {
public:
PageA() : m_win(NULL) {}
~PageA() {}
void PageInit(widget_t *win){ m_win = win; }
void PageDeinit(){}
void on_data_change(int data) {
printf("%s\r\n", BOOST_CURRENT_FUNCTION);
char text[128];
snprintf(text, sizeof(text), "data: %d", data);
widget_t* label = widget_lookup(m_win, "lbl_data", TRUE);
if (label != NULL) {
widget_set_text_utf8(label, text);
}
}
private:
widget_t *m_win;
};
PageA s_pageA;
static ret_t on_btn_page_b_click(void* ctx, event_t* e) {
return navigator_to("page_b");
}
/**
* 初始化窗口的子控件
*/
static ret_t visit_init_child(void* ctx, const void* iter) {
widget_t* win = WIDGET(ctx);
widget_t* widget = WIDGET(iter);
const char* name = widget->name;
// 初始化指定名称的控件(设置属性或注册事件),请保证控件名称在窗口上唯一
if (name != NULL && *name != '\0') {
if(tk_str_eq(name, "btn_page_b")){
widget_on(widget, EVT_CLICK, on_btn_page_b_click, win);
}
}
return RET_OK;
}
/**
* 初始化窗口
*/
ret_t page_a_cpp_init(widget_t* win, void* ctx) {
(void)ctx;
return_value_if_fail(win != NULL, RET_BAD_PARAMS);
widget_foreach(win, visit_init_child, win);
s_pageA.PageInit(win);
// 连接信号
SignalManager::instance().sigDataChange.connect([](int data){
s_pageA.on_data_change(data);
});
return RET_OK;
}
page_b_cpp.cpp
#include "awtk.h"
#include "../common/navigator.h"
#include "page_b_cpp.h"
#include "../common/SignalManager.hpp"
#include <boost/current_function.hpp>
#include <stdio.h>
#include <vector>
class PageB {
public:
PageB() : m_win(NULL) {}
~PageB() {}
void PageInit(widget_t *win){
m_win = win;
m_connections.push_back(SignalManager::instance().sigDataChange.connect([this](int data){
on_data_change(data);
})
);
}
void PageDeinit(){
for(auto connection : m_connections){
connection.disconnect();
}
m_connections.clear();
}
void on_data_change(int data) {
printf("%s\r\n", BOOST_CURRENT_FUNCTION);
char text[128];
snprintf(text, sizeof(text), "data: %d", data);
widget_t* label = widget_lookup(m_win, "lbl_data", TRUE);
if (label != NULL) {
widget_set_text_utf8(label, text);
}
}
private:
widget_t *m_win;
std::vector<connection> m_connections;
};
PageB s_pageB;
static ret_t on_btn_to_page_c_click(void* ctx, event_t* e) {
return navigator_to("page_c");
}
static ret_t on_btn_return_click(void* ctx, event_t* e) {
return window_close(WIDGET(ctx));
}
static ret_t on_window_close(void* ctx, event_t* e) {
s_pageB.PageDeinit();
return RET_OK;
}
/**
* 初始化窗口的子控件
*/
static ret_t visit_init_child(void* ctx, const void* iter) {
widget_t* win = WIDGET(ctx);
widget_t* widget = WIDGET(iter);
const char* name = widget->name;
// 初始化指定名称的控件(设置属性或注册事件),请保证控件名称在窗口上唯一
if (name != NULL && *name != '\0') {
if(tk_str_eq(name, "btn_to_page_c")){
widget_on(widget, EVT_CLICK, on_btn_to_page_c_click, win);
}
else if(tk_str_eq(name, "btn_return")){
widget_on(widget, EVT_CLICK, on_btn_return_click, win);
}
}
return RET_OK;
}
/**
* 初始化窗口
*/
ret_t page_b_cpp_init(widget_t* win, void* ctx) {
(void)ctx;
return_value_if_fail(win != NULL, RET_BAD_PARAMS);
widget_foreach(win, visit_init_child, win);
widget_on(win, EVT_WINDOW_CLOSE, on_window_close, win);
s_pageB.PageInit(win);
return RET_OK;
}
page_c_cpp.cpp
#include "awtk.h"
#include "../common/navigator.h"
#include "../common/SignalManager.hpp"
#include <boost/current_function.hpp>
#include <stdio.h>
#include "conf_io/app_conf.h"
#include "page_c_cpp.h"
static ret_t on_btn_return_click(void* ctx, event_t* e) {
return window_close(WIDGET(ctx));
}
static ret_t on_slider_value_changed(void* ctx, event_t* e) {
value_change_event_t *evt = value_change_event_cast(e);
app_conf_set_int("data", value_int(&evt->new_value));
SignalManager::instance().sigDataChange(value_int(&evt->new_value));
return RET_OK;
}
static ret_t on_slider_value_changing(void* ctx, event_t* e) {
value_change_event_t *evt = value_change_event_cast(e);
app_conf_set_int("data", value_int(&evt->new_value));
SignalManager::instance().sigDataChange(value_int(&evt->new_value));
return RET_OK;
}
/**
* 初始化窗口的子控件
*/
static ret_t visit_init_child(void* ctx, const void* iter) {
widget_t* win = WIDGET(ctx);
widget_t* widget = WIDGET(iter);
const char* name = widget->name;
// 初始化指定名称的控件(设置属性或注册事件),请保证控件名称在窗口上唯一
if (name != NULL && *name != '\0') {
if(tk_str_eq(name, "btn_return")){
widget_on(widget, EVT_CLICK, on_btn_return_click, win);
}
else if(tk_str_eq(name, "slider")){
widget_on(widget, EVT_VALUE_CHANGED, on_slider_value_changed, win);
widget_on(widget, EVT_VALUE_CHANGING, on_slider_value_changing, win);
}
}
return RET_OK;
}
/**
* 初始化窗口
*/
ret_t page_c_cpp_init(widget_t* win, void* ctx) {
(void)ctx;
return_value_if_fail(win != NULL, RET_BAD_PARAMS);
widget_foreach(win, visit_init_child, win);
widget_set_value_int(widget_lookup(win, "slider", TRUE), app_conf_get_int("data", 0));
return RET_OK;
}
编译启动,首先打开页面A,然后点击按钮进入页面B,再点击按钮进入页面C,页面会有一个滑条,滑动滑条,滑条的更新数据将通过signal回调去更新页面A和页面B的数据显示。
代码案例已在https://gitee.com/tracker647/awtk-practice/tree/master/signal_slot_test给出,可以验证效果。
原理
为什么更新了页面C之后,页面B和页面A都能同时更新?结合AI看了下Boost源码,大致明白,signal/slot是观察者模式的实现方式,其数据结构本质类似于一个桶链表,根据回调signal的不同来区分不同的"桶",每个"桶"存储注册的一系列函数(观察者),当"桶"被触发时,桶上所有的注册函数即被触发。
换到这个例子里,就是SignalManger的sigDataChange函数被两个页面page_a,page_b所观察,在page_c页面界面调节滑条时,数据变化将会通过sigDataChange传达给page_a, page_b注册了sigDataChange的函数。
这种实现有效解决了旧思路里2.不好同时更新多个页面的问题,这样在页面C可以只访问一次数据库,新数据的UI更新交给signal函数就行,而且调用方不需要关心signal函数背后的实际实现。
基于MVC的旧思路和signal/slot并不是相互替代的关系,而是补充加强,图示如下,可以看到MVC分层设计和signal/slot存在很有意思的关联:
如果说MVC的分层设计思路是"合纵",将前端UI,后端业务,数据库逻辑统合在一起,那signal/slot的引入就是"连横",通过signal/slot的灵活的数据传输机制实现各模块的"协同合作"!
结语
说起来有点惭愧,GUI开发干了这么久才开始了解和使用signal/slot,感觉自己的开发视野还是很闭塞,这多少有目前做的应用场景简单,尚且还未遇到很大的架构问题的原因,如果没有这次搬运QT代码的经历,我估计还是在使用很低效的开发方式。
AWTK虽然支持C++,但几乎所有API都是纯C实现的,当时不熟悉C++,也没有写独立原生逻辑的想法,很多页面代码都是依赖于框架自身的功能去完成,现在看来纯C写界面实在太不方便了,没有面向对象,STL,泛型,各种算法API,lamada,开发思路很受限制,做一些复杂的数据处理十分繁琐。虽说理论上也可以写一些轮子,struct+函数指针实现类面向对象的效果,但显然又多不少兼容工作,麻烦。
好在嵌入式Linux板本身是支持运行C++程序的,与其弄那么多弯弯绕绕还不如直接上C++,有了这份经历,以后警惕纯C,平台在单片机上写GUI的岗位。
讲了这么多,其实就是想说清楚自己目前对于signal/slot的了解,signal/slot的作用显然不止于此,还有更多的作用等着我开发挖掘,就看后面的项目还会遇到什么样的挑战。