AWTK上使用Signal/Slot解决多页面同步的问题

写界面软件,经常遇到这么一类场景:

主界面点击应用窗口进入某模块显示界面,某模块显示界面再通过按钮进入菜单界面,菜单界面有很多关于该模块显示界面的设置项,比如量程,增益,时间显示,亮度,对比度等等,大概十几个设置。

有些数值类的设置还有子预览菜单,在子预览菜单里面通过滑条去设置数值,回到菜单后,设置会显示子预览菜单设置的数值。

模块显示界面需要显示一些菜单的设置,比如量程,增益等等。

也就是大概这么一个页面关系,几个页面存在线性的导航关系,同时数据来源大量重复。

做这种页面逻辑的一个难点就是,设置某个菜单项,对应的修改数据怎么同步到上一层/几层窗口上去。

解决这个问题的经历经过了两次设计思路的迭代,也借此终于了解了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的作用显然不止于此,还有更多的作用等着我开发挖掘,就看后面的项目还会遇到什么样的挑战。