前言
之前研究qemu的目的之一就是想用用qemu的stm32二次开发版本进行LCD显示实验。但是真的看了qemu stm32的源码后,发现并不支持LCD驱动的。所以我考虑是否由我自己来添加LCD驱动仿真,进行qemu二次开发。而步骤1就是我要先自己玩下基于stm32的LCD驱动应用编程。而我之前买oled屏幕后也买过一块stm32F407的开发板。oled能正常驱动,并且翻出了10年前买的2.4寸tft屏幕(80接口8bit的ili9325)也能正常驱动。
然后我发现就简单的显示内容没有动画效果,觉得不好玩,于是想起来2019年底下载过一个GUI开源软件GuiLite。然后2021年了我去看看它是否在不断的更新。所以我主要分析的是2019年底的版本,2021最新版也看了下,基础内容差距不大。
当然我也尝试了下将GuiLite移植到stm32F407开发板上,按Doc中help截图的操作步骤,还是很容易的。我现在看似在玩应用,其实我研究的还是底层库源码设计机制,所以我定义的一年视觉相关底层的的研究方向没有跑偏哈~
GuiLite源码研究
首先要明确目标,就是我分析GuiLite源码的目的是想了解GUI的设计原理。因为让我直接写个GUI引擎框架,我暂时不会。因为好奇,所以要去了解,毕竟他就5000行代码搞定的事情,我觉得很神奇。
我看了几个例子后,通过调试跟进源码,基本上已经了解了他的设计方法,让我自己设计的话我也有了方向。虽然源码还没有全看完,但是surface及widgets基本控件的原理都已经了解了,此次的目标已达成。另外的好处是,对这些控件的源码分析后,自己也可以比较灵活的去调用API做些小作品,蛮好玩的。我就喜欢这样小巧的代码,麻雀虽小五脏俱全。如下是我过程中分析源码的笔记,取其精华去其糟粕。我也发现了些bug,以及我觉得某些点,它还有继续完善的空间。
A.HelloStar工程
- 范围检查技巧
宽度和高度超范处理
x0 = (x0 < 0) ? 0 : x0;
y0 = (y0 < 0) ? 0 : y0;
x1 = (x1 > (m_width - 1)) ? (m_width - 1) : x1;
y1 = (y1 > (m_height - 1)) ? (m_height - 1) : y1;
- 随机数不超过范围的技巧,用取余数
m_x = m_start_x = rand() % UI_WIDTH;
m_y = m_start_y = rand() % UI_HEIGHT;
- 物体移动的技巧
先清除之前的绘制变成当前的背景,再重新绘制新的。至于只有一个图层为黑色底色的,就是直接画黑色,就是清除的意思,然后再重新绘制新的物体图像,看上去就是移动的效果。
B.HelloLayers工程
- 关于Layer的处理技巧
若有2层surface,则一开始需要申请Z_ORDER_LEVEL_1,然后就会进入如下的for循环,会为m_layers[i].fb分配内存空间。
若有3层surface,则一开始需要申请Z_ORDER_LEVEL_2。
void set_surface(Z_ORDER_LEVEL max_z_order, c_rect layer_rect)
{
m_max_zorder = max_z_order;
if (m_display && (m_display->m_surface_cnt > 1))
{
m_fb = calloc(m_width * m_height, m_color_bytes);
}
for (int i = Z_ORDER_LEVEL_0; i < m_max_zorder; i++)
{//Top layber fb always be 0
ASSERT(m_layers[i].fb = calloc(layer_rect.width() * layer_rect.height(), m_color_bytes));
m_layers[i].rect = layer_rect;
}
}
这个内存在fill_rect函数中进行赋值的,m_layers[z_order].fb。若surface只有1级的话,不会为fb赋值。首先要思考下为什么要为fb赋值,其实就是备份的意思。
if (z_order == m_top_zorder)
{
int x, y;
c_rect layer_rect = m_layers[z_order].rect;
unsigned int rgb_16 = GL_RGB_32_to_16(rgb);
for (y = y0; y <= y1; y++)
{
for (x = x0; x <= x1; x++)
{
if (layer_rect.pt_in_rect(x, y))
{
if (m_color_bytes == 4)
{
((unsigned int*)m_layers[z_order].fb)[(y - layer_rect.m_top) * layer_rect.width() + (x - layer_rect.m_left)] = rgb;
}
else
{
((unsigned short*)m_layers[z_order].fb)[(y - layer_rect.m_top) * layer_rect.width() + (x - layer_rect.m_left)] = rgb_16;
}
}
}
}
return fill_rect_on_fb(x0, y0, x1, y1, rgb);
}
在draw_pixel函数中也有对fb赋值。也就是说,所有绘制的地方,都会对fb进行更新值,记录最新的值。技巧就是判断if (z_order == m_max_zorder)则直接return绘制结果。若为单层,则此条件一定满足,就不会备份fb了,而对于多层则要在更新图像的时候备份fb。目的是为了将上一层级消除的时候对下一层级进行还原。
if (z_order == m_max_zorder)
{
return draw_pixel_on_fb(x, y, rgb);
}
if (z_order > (unsigned int)m_top_zorder)
{
m_top_zorder = (Z_ORDER_LEVEL)z_order;
}
if (m_layers[z_order].rect.pt_in_rect(x, y))
{
c_rect layer_rect = m_layers[z_order].rect;
if (m_color_bytes == 4)
{
((unsigned int*)(m_layers[z_order].fb))[(x - layer_rect.m_left) + (y - layer_rect.m_top) * layer_rect.width()] = rgb;
}
else
{
((unsigned short*)(m_layers[z_order].fb))[(x - layer_rect.m_left) + (y - layer_rect.m_top) * layer_rect.width()] = GL_RGB_32_to_16(rgb);
}
}
对于c_rect对象的还原方法是overlapped_rect,需要调用2句函数,一句是创建一个rect对象,目的是设置工作局域,另外一句是设置要还原的图层。
c_rect overlapped_rect(LAYER_1_X, LAYER_1_Y, LAYER_1_WIDTH, LAYER_1_HEIGHT);
s_surface->show_layer(overlapped_rect, Z_ORDER_LEVEL_0);
可以看到在show_layer中会取出m_layers[z_order].fb,重写到LCD上。其实就是还原。
int show_layer(c_rect& rect, unsigned int z_order)
{
ASSERT(z_order >= Z_ORDER_LEVEL_0 && z_order < Z_ORDER_LEVEL_MAX);
c_rect layer_rect = m_layers[z_order].rect;
ASSERT(rect.m_left >= layer_rect.m_left && rect.m_right <= layer_rect.m_right &&
rect.m_top >= layer_rect.m_top && rect.m_bottom <= layer_rect.m_bottom);
void* fb = m_layers[z_order].fb;
int width = layer_rect.width();
for (int y = rect.m_top; y <= rect.m_bottom; y++)
{
for (int x = rect.m_left; x <= rect.m_right; x++)
{
unsigned int rgb = (m_color_bytes == 4) ? ((unsigned int*)fb)[(x - layer_rect.m_left) + (y - layer_rect.m_top) * width] : GL_RGB_16_to_32(((unsigned short*)fb)[(x - layer_rect.m_left) + (y - layer_rect.m_top) * width]);
draw_pixel_on_fb(x, y, rgb);
}
}
return 0;
}
- 基于HelloLayers将Hellostar添加入UIcode.c,变成双图层,但是底层图层是动态的。修改后,遇到的问题是,star绘制的时候会擦除顶层的图片。
load_resource();
draw_on_layer_0();
while(1) {
stars[0].move();
thread_sleep(70);
cnt++;
if (cnt % 60 == 0)
{
draw_on_layer_1();
layer1 = 0;
}
if (cnt % 91 == 0)
{
clear_layer_1();
layer1 = 1;
}
if (cnt >= 32767)
{
cnt = 0;
}
见图
然后想到了办法临时解决下。方法就是star运动绘制后它不是会更新顶层区域的图像嘛,所有我的修改时,当顶层小窗口显示时,下一层star重绘后,我立即重绘下顶层图像。可以解决如上问题,但是感觉刷屏比较频繁,屏幕有闪烁感。这个问题要等我全部看完GuiLite看看还有哪些API可以用来更好的解决我遇到问题。
load_resource();
draw_on_layer_0();
while(1) {
stars[0].move();
if (layer1 == 0)
draw_on_layer_1();
thread_sleep(70);
cnt++;
if (cnt % 60 == 0)
{
layer1 = 0;
}
if (cnt % 91 == 0)
{
clear_layer_1();
layer1 = 1;
}
if (cnt >= 32767)
{
cnt = 0;
}
}
C.HelloWidgets学习窗口对象的原理
- 链表归递的技巧
UI窗口对象的处理相关函数中会看到链表归递,其实归递函数我平时都不太用的,常用的还是数组,依次扫描。
通过child->show_window()进行归递,退出条件为child==null,依次扫描的目的是为每个对象调用on_paint进行绘制。而双链表对象是通过add_child_2_tail函数添加到双链表末尾的。
void c_wnd::show_window()
{
if (ATTR_VISIBLE == (m_attr & ATTR_VISIBLE))
{
on_paint();
c_wnd *child = m_top_child;
if ( 0 != child )
{
while ( child )
{
child->show_window();
child = child->m_next_sibling;
}
}
}
}
c_wnd::connect函数中if (load_child_wnd(p_child_tree) >= 0)
的函数load_child_wnd中也是归递,通过调用p_cur->p_wnd->connect实现归递,它由于涉及了父函数的归递,而不是自己本身的归递。退出条件while(p_cur->p_wnd),也就是p_cur->p_wnd为null就退出。所以可以看到传入的窗口对象数组中最后一行都是null。
WND_TREE s_main_widgets[] =
{
{ &s_edit1, ID_EDIT_1, "ABC", 150, 10, 100, 50},
……
{ &s_my_dialog, ID_DIALOG, "Dialog", 200, 100, 280, 312, s_dialog_widgets},
{NULL, 0 , 0, 0, 0, 0, 0}
};
int c_wnd::load_child_wnd(WND_TREE *p_child_tree)
{
if (0 == p_child_tree)
{
return 0;
}
int sum = 0;
WND_TREE* p_cur = p_child_tree;
while(p_cur->p_wnd)
{
if (0 != p_cur->p_wnd->m_resource_id)
{//This wnd has been used! Do not share!
ASSERT(false);
return -1;
}
else
{
p_cur->p_wnd->connect(this, p_cur->resource_id, p_cur->str,
p_cur->x, p_cur->y, p_cur->width, p_cur->height,p_cur->p_child_tree);
}
p_cur++;
sum++;
}
return sum;
}
- 分析下2019年底GuiLite中的load_cmd_msg()函数,主要就是设置回调函数的,比较关键的变量就是GetMSgEntries,因为2021版本已经源码中已经不是这样设计了。
void c_cmd_target::load_cmd_msg()
{
const GL_MSG_ENTRY* p_entry = GetMSgEntries();
if (0 == p_entry)
{
return;
}
bool bExist = false;
while(MSG_TYPE_INVALID != p_entry->msgType)
{
if (MSG_TYPE_WND == p_entry->msgType)
{
p_entry++;
continue;
}
……
}
在代码中回调函数是这样定义的
GL_BEGIN_MESSAGE_MAP(c_my_ui)
ON_GL_BN_CLICKED(ID_BUTTON, c_my_ui::on_button_clicked)
ON_SPIN_CONFIRM(ID_SPIN_BOX, c_my_ui::on_spinbox_confirm)
ON_SPIN_CHANGE(ID_SPIN_BOX, c_my_ui::on_spinbox_change)
ON_LIST_CONFIRM(ID_LIST_BOX, c_my_ui::on_listbox_confirm)
GL_END_MESSAGE_MAP()
GL_BEGIN_MESSAGE_MAP需要传入对象,它调用的数组都是对象:: GetMSgEntries()
函数来获取某个对象的mMsgEntries[]数组内容。所以若要使用,则要在定义类的时候添加上GL_DECLARE_MESSAGE_MAP,进行初始化。
#define GL_BEGIN_MESSAGE_MAP(theClass) \
const GL_MSG_ENTRY* theClass::GetMSgEntries() const \
{ \
return theClass::mMsgEntries; \
} \
const GL_MSG_ENTRY theClass::mMsgEntries[] = \
{
这个数组对象的结构体类型如下
struct GL_MSG_ENTRY
{
unsigned int msgType;
unsigned int msgId;
c_cmd_target* pObject;
MSG_CALLBACK_TYPE callbackType;
MsgFuncVV func;
};
而这些callback函数的调用是在notify_parent中。
switch (entry->callbackType)
{
case MSG_CALLBACK_VV:
(m_parent->*msg_funcs.func)();
break;
case MSG_CALLBACK_VVL:
(m_parent->*msg_funcs.func_vvl)(param);
break;
case MSG_CALLBACK_VWV:
(m_parent->*msg_funcs.func_vwv)(m_resource_id);
break;
case MSG_CALLBACK_VWL:
(m_parent->*msg_funcs.func_vwl)(m_resource_id, param);
break;
default:
ASSERT(false);
break;
}
继续倒推分析代码,查看调用关系。比如c_button对象notify_parent被c_button::on_key调用
bool c_button::on_key(KEY_TYPE key)
{
if (key == KEY_ENTER)
{
notify_parent(GL_BN_CLICKED, 0);
return false;// Do not handle KEY_ENTER by other wnd.
}
return true;// Handle KEY_FOWARD/KEY_BACKWARD by parent wnd.
}
-
c_wnd::on_touch
分析
通过PtInRect来判断传入的x和y点击位置是否在某个控件内,这样就可以找到窗口对象的位置。
if (true == rect.PtInRect(x, y) || child->m_attr & ATTR_MODAL)
然后就设置目的对象target_wnd = child;
最后调用具体对象的on_touch函数target_wnd->on_touch(x, y, action);
这里guilite设计的不好的地方是没有添加break,找到target后还在进行for循环,我觉得比较浪费时间。另外,这样子的设计让我想到若有2个控件窗口位置重叠,然后鼠标点击的是重叠位置,那么找到的对象就是数组后面的对象了。直接实验了下,让2个控件窗口重叠,果然是存在这样的问题,哈哈,我分析源码找到一个bug。
然后说下点击中down要做的事情,它会绘制2次图像,先画focus的图像,因为set_child_focus中设置m_status = STATUS_FOCUSED,并且调用函数on_paint。然后再设置m_status = STATUS_PUSHED后调用on_paint。注意对button对象若是非TOUCH DOWN,就是TOUCH UP,会调用notify_parent,而一开始设置m_parent的目的,是按钮弹起的时候notify_parent会通过获取m_parent的回调函数数组找到需要执行的回调函数。源码看到这里我在想,为什么要用parent去找到对应的回调函数呢?而不是直接找回调函数,主要原因应该是对象本身成员中并没有定义回调函数成员。
bool c_button::on_touch(int x, int y, TOUCH_ACTION action)
{
if (action == TOUCH_DOWN)
{
m_parent->set_child_focus(this);
m_status = STATUS_PUSHED;
on_paint();
}
else
{
m_status = STATUS_FOCUSED;
on_paint();
notify_parent(GL_BN_CLICKED, 0);
}
return true;
}
而on_paint是比较底层的绘图函数,可以看到按不同的传入消息,进行不同的绘图处理,如下draw_xxx和fill_rect都是比较熟悉的函数了。
case STATUS_FOCUSED:
m_surface->fill_rect(rect, c_theme::get_color(COLOR_WND_FOCUS), m_z_order);
if (m_str)
{
c_word::draw_string_in_rect(m_surface, m_z_order, m_str, rect, m_font_type, m_font_color, c_theme::get_color(COLOR_WND_FOCUS), ALIGN_HCENTER | ALIGN_VCENTER);
}
break;
case STATUS_PUSHED:
m_surface->fill_rect(rect, c_theme::get_color(COLOR_WND_PUSHED), m_z_order);
m_surface->draw_rect(rect, c_theme::get_color(COLOR_WND_BORDER), 2, m_z_order);
if (m_str)
{
c_word::draw_string_in_rect(m_surface, m_z_order, m_str, rect, m_font_type, m_font_color, c_theme::get_color(COLOR_WND_PUSHED), ALIGN_HCENTER | ALIGN_VCENTER);
}
break;
如下记录了下vs中鼠标点击button后的回调函数。若换成单片机,需要使用touch屏,检查到按下后调用GuiLite的函数。
void CHelloWidgetsDlg::OnLButtonUp(UINT nFlags, CPoint point)
{
CPoint guilitePos = pointMFC2GuiLite(point);
sendTouch2HelloWidgets(guilitePos.x, guilitePos.y, false);
}
void CHelloWidgetsDlg::OnLButtonDown(UINT nFlags, CPoint point)
{
CPoint guilitePos = pointMFC2GuiLite(point);
sendTouch2HelloWidgets(guilitePos.x, guilitePos.y, true);
}
但是我看了下on_key,用2019年底的guilite只有3种数值。所以最后就是判断为KEY_ENTER后退出。
typedef enum
{
KEY_FORWARD,
KEY_BACKWARD,
KEY_ENTER
}KEY_TYPE;
- 关于s_main_widgets中最后一个成员对象,可以理解为存在2级弹出消息窗口,所以UI创建的时候用的是Z_ORDER_LEVEL_2。
WND_TREE s_main_widgets[] =
{
{ &s_edit1, ID_EDIT_1, "ABC", 150, 10, 100, 50},
……
{ &s_my_dialog, ID_DIALOG, "Dialog", 200, 100, 280, 312, s_dialog_widgets},
{NULL, 0 , 0, 0, 0, 0, 0}
};
另外一个特殊处理就是二级窗口,s_dialog_widgets代表二级窗口对象,若有二级则load_child_wnd(p_child_tree)不会直接返回0,再次调用其中的connect递归函数,进行二次迭代而已,并且二级会继续插入到双链表控件对象,二级最后也一定会设置NULL,最终二级也会调用load_cmd_msg方法来绑定回调函数。
if (load_child_wnd(p_child_tree) >= 0)
{
load_cmd_msg();
on_init_children();
}
这个二次递归的退出条件就是是否存在child tree。若为NULL则没有二次迭代。返回上一次都为while循环对一级对象进行递归处理。
if (0 == p_child_tree)
{
return 0;
}
然后我看了当前2021年最新版本load_cmd_msg绑定回调函数已经没有了,变成了在初始化的时候通过调用函数来绑定。
list_box->set_on_change((WND_CALLBACK)&c_my_ui::on_listbox_confirm);
就是为on_click成员函数赋值,将function挂入on_click成员。同理,最后回调函数的调用方式也不是notify_parent(GL_BN_CLICKED, 0);
而是直接为对象调用on_click函数进行操作了。
这样的初始化时刻写入,配合使用时候读取,我觉得看起来比较清楚。以前用id关联,判断还要做搜索匹配,匹配id成功后再调用回调函数。还是最新代码的设计看上去比较清爽些。这也是我之前分析的,他在对象中添加了回调函数成员,所以才可以这样设计,哈哈,看来我分析的过程中还真的看出了些问题,所以最新版中也有人看出了这样的问题,并且完成了修改。
if(on_click)
{
(m_parent->*(on_click))(m_id, 0);
}
- 窗口关闭函数
按下dialog按钮后,弹出二级窗口,点击二级窗口上的退出按钮就可以关闭窗口,而点击退出按钮后会调用btn的回调函数,回调函数中会调用c_dialog::close_dialog,此函数中会调用set_frame_layer_visible_rect
方法,此方法中比较关键的就是z_order-1
,然后从m_frame_layers中获取此窗口大小内之前的图像数据进行恢复。如下2句是最重要的,先从当前图层获取frame窗口大小,然后图层数据减1,就可以从下一图层获取此窗口大小的图像信息进行重绘还原,也就是取消了弹窗。
c_rect old_rect = m_frame_layers[z_order].visible_rect;
//Recover the lower layer
int src_zorder = (Z_ORDER_LEVEL)(z_order - 1);
int display_width = m_display->get_width();
int display_height = m_display->get_height();
for (int y = old_rect.m_top; y <= old_rect.m_bottom; y++)
{
for (int x = old_rect.m_left; x <= old_rect.m_right; x++)
{
if (!rect.PtInRect(x, y))
{
unsigned int rgb = ((unsigned short*)(m_frame_layers[src_zorder].fb))[x + y * m_width];
draw_pixel_on_fb(x, y, GL_RGB_16_to_32(rgb));
}
}
}