单片机 GUI 设计(十四)- App 应用设计
单片机 GUI 设计(十四)- App 应用设计

单片机 GUI 设计(十四)- App 应用设计

基于 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 界面。

源码

本章节中的源码请参考《单片机 GUI 设计(零)- 大纲