基于 GD32F303ZET6 苹果派开发板
简介
GUI 的 App 应用设计是指模仿手机或电脑桌面,使用一个个图标来区分不同的应用,用户通过点击不同的图标,进入不同的应用,如下所示。然而,单片机中,安装程序是很难做到的,特别是对于没有内存管理单元的应用,所以本文中实现的 App 更类似于预置的 App,用户不能删除和更新。
注意:阅读本文时,推荐结合源码阅读,可以加深理解。

App 控制结构体设计
App 控制结构体的设计如下所示,里边包含了 App 的名字、图标等信息,同时也含有创建、注销和轮询相关的函数指针。App 控制结构体被设计成了单项链表,这样我们可以把所有的 App 串在一起,方便管理。
typedef struct guiDev
{
const char* name; //界面名字
const char* icon; //界面图标,不是背景图片,非 APP 的必须为 NULL
struct guiDev* next; //指向下一项
i32 userFlag; //预置的用户标志位
void (*open)(void); //创建、打开界面
void (*close)(void); //注销、关闭界面
void (*poll)(void); //界面轮询
u32 x0, y0, width, height; //起点、宽度、高度
}StructGUIDev;
App 的注册
本文提供的示例中,所有的 App 以注册的形式添加到系统中。系统中维护着一条链表,里边包含着所有已经注册过的 App,所以 App 的注册就是将 App 链表项添加到链表表尾。使用注册的方式有一个好处,如果项目中有某个 App 不用了,那么只需取消 App 的注册,即将注册 App 那一行代码注释掉,即可将该 App 从项目中剔除,方便快捷。
static StructGUIDev* s_pGUIDev = NULL;
void GUIRegister(StructGUIDev* guiDev)
{
StructGUIDev* node;
//查验该 GUI 界面是否已经注册过
if(NULL != s_pGUIDev)
{
node = s_pGUIDev;
while(NULL != node)
{
//查找到已经注册的记录
if(guiDev == node)
{
return;
}
//继续查验下一项
else
{
node = node->next;
}
}
}
//表头为空,表明尚未有任何 GUI 界面注册过,直接令该 GUI 为表头,即第一个 APP
if(NULL == s_pGUIDev)
{
s_pGUIDev = guiDev;
guiDev->next = NULL;
}
//表头非空
else
{
//查找链表表尾
node = s_pGUIDev;
while(NULL != node->next)
{
node = node->next;
}
//将 GUI 界面插入到表尾中
node->next = guiDev;
guiDev->next = NULL;
}
}
App 的轮询
除了 App 链表外,系统中还要有一个变量,在本文提供的示例为 s_pCurrentGUI,用来指示当前的 App 是哪个,如下所示。s_pCurrentGUI 也是一个链表项,直接指向某一 App。因为 App 列表项包含了 App 创建、删除和轮询的函数指针,所以我们可以很方便的轮询某一 App。
注意:GUITask 为整个 GUI 的轮询函数,用以驱动 GUI 运行,需要每隔一段时间调用一次。
static StructGUIDev* s_pLastGUI = NULL; //上一个GUI
static StructGUIDev* s_pCurrentGUI = NULL; //当前GUI
void GUITask(void)
{
//界面切换
if (s_pCurrentGUI != s_pLastGUI)
{
//删除上一个界面
if(NULL != s_pLastGUI)
{
if(NULL != s_pLastGUI->close)
{
s_pLastGUI->close();
}
}
//保存当前界面
s_pLastGUI = s_pCurrentGUI;
//创建新的界面
if(NULL != s_pCurrentGUI)
{
if(NULL != s_pCurrentGUI->open)
{
s_pCurrentGUI->open();
}
}
}
//界面轮询
if((NULL != s_pCurrentGUI) && (0 == GUITopIsSwitching()))
{
if(NULL != s_pCurrentGUI->poll)
{
s_pCurrentGUI->poll();
}
}
}
界面的切换
引入 s_pCurrentGUI 变量后,界面的切换十分简单,只需要将 s_pCurrentGUI 指向不同的 App,即可实现界面的切换。只是要注意,界面切换时不要自行任何刷新 GUI 的操作。当然,切换 App 之前,首先要校验该 App 有没有被注册过,确认无误后直接修改 s_pCurrentGUI 即可。
void GUISwitch(StructGUIDev* gui)
{
StructGUIDev* node;
unsigned char error;
//查验该 GUI 界面是否已经注册过
error = 1;
if(NULL != s_pGUIDev)
{
node = s_pGUIDev;
while(NULL != node)
{
//查找到已经注册的记录
if(gui == node)
{
error = 0;
break;
}
//继续查验下一项
else
{
node = node->next;
}
}
}
//界面已经注册过
if(0 == error)
{
printf("切换到:%s界面\r\n", gui->name);
s_pCurrentGUI = gui;
}
//界面未注册
else
{
printf("该界面未注册\r\n");
}
}
Home 页面设计
Home 页面,即主页面,用来摆放所有的 App。因为系统中所有的 App 组成了一条链表,所以我们只需要遍历这张链表,将图标和 App 名字依次显示出来即可。在本文中,App 图标使用了 ARBG8888 的 BMP 图片,当然读者也可以选择 PNG 图片,一般来说,图标是带透明度的。
为所有的图标分配显示位置的实现代码如下所示。在这里我们定义:App 图标的大小为 64×64,一行最多显示 8 个图标,读者可以根据自己的需要修改。
#define ICON_APP_H_NUM (8) //一行 App 的数量
#define ICON_IMAGE_WIDTH (64) //图标宽度
#define ICON_IMAGE_HEIGHT (64) //图标高度
#define ICON_TEXT_FONT (16) //图标字体
#define ICON_TEXT_SPACE (2) //字符串和图片的间隙
//App 总体高度
#define ICON_APP_HEIGHT (ICON_IMAGE_HEIGHT + ICON_TEXT_SPACE + ICON_TEXT_FONT)
//左右两个 App 之间的间隙
#define ICON_APP_SPAGE ((800 - ICON_IMAGE_WIDTH * ICON_APP_H_NUM) / (ICON_APP_H_NUM + 1))
static void InitAllAppStruct(void)
{
u32 i, x, y;
StructGUIDev* node;
//初始化App图标
x = ICON_APP_SPAGE;
y = 50;
i = 0;
node = GUITopGetHeader();
while(NULL != node)
{
//属于 APP 项目
if(NULL != node->icon)
{
//更新y轴
if((0 == (i % ICON_APP_H_NUM)) && (0 != i))
{
y = y + ICON_APP_HEIGHT + 15;
x = ICON_APP_SPAGE;
}
node->x0 = x;
node->y0 = y;
node->width = ICON_IMAGE_WIDTH;
node->height = ICON_APP_HEIGHT;
//更新横坐标
x = x + ICON_IMAGE_WIDTH + ICON_APP_SPAGE;
i++;
}
//下一项
node = node->next;
}
}
通过 InitAllAppStruct 函数为 App 图标分配显示位置后,直接在对应位置绘制出图片和字符串,即可完成图标的显示。单个 App 图标显示的代码如下所示。显示 BMP 图片时,因为图标一般比较小,所以我们可以选择直接将整张图片导入内存当中,显示后再释放动态内存。
对于内存较大的设备,例如蓝莓派开发板,使用 GD32F470 主控,外拓了 32MB 的 SDRAM。此时可以在初始化时将所有的 App 图标均加载到 SDRAM 中,这样刷新 Home 界面显示时,速度将会非常快,几乎看不到绘制的过程。
static void CreateAppIcon(StructGUIDev* app)
{
u32 stringNum; //字符串字符数
u32 stringLen; //字符串长度(像素点)
u32 beginX, beginY; //字符串起点坐标
u32 x0, y0, x1, y1;
u8* bmp;
//显示图片
x0 = app->x0;
y0 = app->y0;
x1 = app->x0 + ICON_IMAGE_WIDTH - 1;
y1 = app->y0 + ICON_IMAGE_HEIGHT - 1;
if(NULL != app->icon)
{
bmp = MallocAndCopy((void*)app->icon);
DrawBMP((void*)bmp, app->x0, app->y0);
MyFree(bmp);
}
else
{
LCDFill(x0, y0, x1, y1, BLUE);
}
//统计字符串总长度
stringNum = 0;
while(0 != app->name[stringNum])
{
stringNum++;
}
//计算字符串长度(像素点)
stringLen = stringNum * (ICON_TEXT_FONT / 2);
//计算起始纵坐标
beginY = app->y0 + ICON_IMAGE_HEIGHT + ICON_TEXT_SPACE;
//计算起始横坐标
beginX = app->x0 + (app->width - stringLen) / 2;
//显示App标题
g_iLCDPointColor = WHITE;
LCDShowString(beginX, beginY, 800, 480, ICON_TEXT_FONT, (void*)app->name);
}
如此一来,我们只需要遍历整条链表,刷新所有的图标显示即可。
void CreateGUIHome(void)
{
StructGUIDev* node;
//LCD 横屏显示
LCDDisplayDir(1);
//绘制背景
DisplayBackground();
//初始化图标参数
InitAllAppStruct();
//创建所有图标
node = GUITopGetHeader();
while(NULL != node)
{
//属于 APP 项目
if(NULL != node->icon)
{
CreateAppIcon(node);
}
//下一项
node = node->next;
}
}
App 图标的扫描如下所示。调用 ScanTouch 获取到触点坐标后,遍历整条 App 链表,查验有无 App 被按下。如果检测到某一 App 被按下,那么直接调用 GUISwitch 函数跳转到 对应的 App 即可。
static StructGUIDev* ScanAppIcon(void)
{
u16 x, y, x0, y0, x1, y1;
StructGUIDev* node;
//获取触屏坐标
if(1 == ScanTouch(&x, &y))
{
node = GUITopGetHeader();
while(NULL != node)
{
//属于 APP 项目
if(NULL != node->icon)
{
x0 = node->x0;
y0 = node->y0;
x1 = node->x0 + node->width - 1;
y1 = node->y0 + node->height - 1;
if((x >= x0) && (x <= x1) && (y >= y0) && (y <= y1))
{
return node;
}
}
//下一项
node = node->next;
}
}
//扫描失败
return NULL;
}
void GUIHomePoll(void)
{
StructGUIDev* app;
app = ScanAppIcon();
if(NULL != app)
{
GUISwitch(app);
}
}
App 的实现
一般,为了方便管理,一个 App 对应一个 .c 和 .h 文件对。源文件中,首先要添加包含所需的头文件,如下所示。
#include "GUIApp1.h"
#include "BMP.h"
#include "LCD.h"
#include "Common.h"
然后便是要定义该 App 的设备结构体,并附初值,如下所示。此处的结构体赋初值需要开启 C99 模式,否则编译器会报错。赋初值时,要指定 App 名字、图标路径,以及创建、删除和轮询的函数指针,这几个函数将会在后边定义。
static StructGUIDev s_structGuiDev =
{
.name = "应用 1",
.icon = "1:/apk-icon.bmp",
.next = NULL,
.userFlag = 0,
.open = InitGUIApp1,
.close = DeInitGUIApp1,
.poll = GUIApp1Poll,
.x0 = 0,
.y0 = 0,
.width = 0,
.height = 0,
};
紧接着是定义 App 的创建函数,如下所示。用户可以自行创建按键控件、创建波形控件、字符串显示等操作,一般的,首先会设置 LCD 的显示方向,然后刷新背景显示,最后才是控件的创建。如果整个项目中,LCD 的显示方向不变,那么也可以省略设置 LCD 显示方向这一步骤。
void InitGUIApp1(void)
{
//绘制背景
LCDClear(GBLUE);
//显示字符串
LCDShowString(304, 208, 800, 480, 64, "应用 1");
}
App 的删除函数用于自行释放动态内存、复位外设等操作,因为示例中只是显示了个字符串,没有执行申请动态内存等操作,所以 App 的删除函数可以为空。
void DeInitGUIApp1(void)
{
}
App 的轮询函数可以用于控件的扫描,例如按键扫描、行编辑控件扫描,又或者是为波形控件添加波形点显示。因为示例中没有创建任何的控件,所以轮询函数可以为空。
void GUIApp1Poll(void)
{
}
在 App 的源文件中,我们还要额外定义一个函数,方便其它 App 或模块访问源文件中的设备结构体,如下所示。
StructGUIDev* GUIApp1GetDev(void)
{
return &s_structGuiDev;
}
最后,在初始化时调用 GUIRegister 函数注册 App 即可,如下所示。
GUIRegister(GUIApp1GetDev());
应用示例
首先在主函数中,初始化 LCD 并设定好 LCD 的初始状态后,调用 InitGUI 函数初始化 GUI,然后在主循环中反复调用 GUITask 驱动 GUI 显示,如下所示。
void main(void)
{
//初始化 LCD 以及其它模块
...
//设置 LCD 初始状态
LCDDisplayDir(1);
LCDClear(WHITE);
//初始化 GUI
InitGUI();
//主循环
while(1)
{
//触屏扫描
TouchScanTask();
//GUI 轮询
GUITask();
//延时 10ms
DelayNms(10);
}
}
InitGUI 函数用于初始化整个 GUI,具体实现可如下所示。Home 界面也可以当成 App 来处理,只不过在定义 Home 界面的设备结构体时,需要将它的图标设为 NULL,这样就不会再 Home 界面上显示它的图标了。
void InitGUI(void)
{
//标记尚未有界面注册
s_pGUIDev = NULL;
//注册主界面
GUIRegister(GUIHomeGetDev());
//注册其它 App
GUIRegister(GUIApp1GetDev());
//切换到主界面
GUISwitch(GUIHomeGetDev());
}
因为主循环中一直有循环调用 GUITask 函数,GUITask 函数检测到用户切换到新的 App 后,首先会注销上一个 App,再创建新 App,最后就是反复调用 App 的轮询函数,驱动 App 显示。
实验结果
实验结果如下所示,进入应用界面后,按下 KEY1、KEY2 或 KEY3,应用将回到 Home 界面。
-App-应用设计-实验结果-20230304-1024x768.jpg)
源码
本章节中的源码请参考《单片机 GUI 设计(零)- 大纲》