【例说Arm-2D界面设计】“手撸GUI”的利器——场景播放器
扫描二维码
随时随地手机看文章
【说在前面的话】
在前面的文章《【喂到嘴边了的模块】准备徒手撸GUI?用Arm-2D三分钟就够了》中,我们介绍了如何借助 cmsis-pack 快速的在 MDK 中部署 arm-2d。
在过去的一段时间内,想必很多人都完成了部署,看到了下面的画面吧?
为了避免让大家产生疑惑,这里我们需要再次明确一下我们所要面对的开发环境:
- 资源相对紧张的MCU,无法负担起传统的嵌入式GUI(比如以体积“小巧”著称的LVGL):
- Flash <= 64K,或者
- 应用本身已经占用了大量Flash空间,留给GUI的空间非常有限
- SRAM <= 16K
- 需要实现的GUI界面较为简单(这点在随后会详细介绍)
-
帧率要求较低(传说中的8帧不卡、9帧流畅、10帧电竞)
【基于面板的界面设计】
从用户的角度来说,如果一个嵌入式产品带了彩屏,很自然的就会希望它能提供像智能手机(或平板设备)一样的操作体验——但从开发者的角度来说,用户的这一期望往往会被错误的理解为:用户希望嵌入式产品上的图形界面能像手机那样支持“这样或那样”的滑动、滚动效果——如果能做到当然最好,但其实这并不是这些“类智能手机界面”设计的核心。
让我把话挑明了吧——流畅的滑动只是添料,甚至是可以完全丢弃的——真正核心的是一套与传统Windows图形界面设计完全不同的理念。关于这套设计理念,有一套叫做“人本界面”的设计方法论作为支撑,感兴趣的小伙伴可以在豆瓣上搜索同名的图书。
就本文要讨论的内容来说,我们可以简单的关注以下的一些要点:
- 智能设备的界面强调“简洁”、并希望“让用户的注意力一次只集中在一件简单的事物上”。
- 与Windows不同,智能设备的界面很少(或者极力避免)窗口重叠
- 界面的基本单位不是“窗体(Window)”,而是以整个屏幕为基本单位的“面板(Panel)”
- 每个面板的内容都尽可能简单、通过留白的方式强调那些需要用户注意的内容;
- 每个面板的功能都尽可能单一:
- 一般避免在同一个面板中挤进多个不太相关的功能;
- 相关的内容,如果能够放得下,且美观,则可以有主次的布置在同一个面板中以减少用户切换面板带来的不便;
- 如果相关的内容如果无法在同一个面板中展示,则一定会添加快捷方式方便用户快速进行面板的切换;
- 面板间的切换方式以大家熟悉的PPT页面切换方式类似
- 对滑动切换来说,要么不做,要做就要“丝滑”(差不多30FPS),否则会给用户带来“卡顿”的不适感
-
完全没有动画的切换往往会给用户“设备反应迅速”的错觉,对负担不起高帧率的嵌入式设备来说,反而是最好的选择
仔细回想一下,身边的智能设备,是不是都基本满足上述特点?——其实我们熟悉的手机和平板也是如此。
基于上述原则,我们甚至可以总结出一套简单有效的“嵌入式界面设计八股”:
- 用户界面分成三个部分:状态面板、导航面板和功能面板
- 状态面板:又叫待机面板,用于显示状态信息(比如温度、时间、产品Logo、产品当前状态等等)。
- 通常在待机界面上按下任意键(或者进行任意触摸)进入导航面板
- 一般用户超过一段时间没有与界面进行交互后会自动进入状态面板,所以状态面板有时候又叫待机面板
- 导航面板:也就是大家常说的菜单。
- 一般导航面板以图标、列表或者按钮的形式存在,
- 一般避免超出屏幕范围的内容,最好做到让用户对所有选项“尽收眼底”
- 导航面板可以通过子面板的形式实现多级菜单,从而简化开发
- 功能面板:实现具体功能的面板,一般由导航面板进入
- 每个面板的功能都尽可能单一,比如专门设置温度、专门设置时间等等
-
相关的导航面板之间可以通过类似左右箭头(或者底部导航快捷按钮)的机制进行快捷切换
【什么是场景(scene)】
“场景(scene)”是 arm-2d为“手撸GUI”的用户引入的一个概念,通过配套的“场景播放器(scene player)”,极大的简化了基于面板的界面开发。
一般来说,一个简单的面板用一个场景就可以搞定;而稍微复杂点的面板则可以通过多个场景(以及基于状态机的场景切换)来搞定——总的原则就是,无论多复杂的面板,都可以拆分成一个个简单的场景来分而治之。
也许你已经注意到了:原本面板本身就已经很简单了,那么所谓“复杂的面板”根据状态机拆分成多个场景后是不是更加简单了?——是的,每个场景的功能都是极其单一和简单的——极大的简化了每个场景的实现难度。
【场景(scene)的数据结构和构成】
场景在 arm-2d 中以类 arm_2d_scene_t 来描述:
/*! * \brief a class for describing scenes which are the combination of a * background and a foreground with a dirty-region-list support * */typedef struct arm_2d_scene_t arm_2d_scene_t;struct arm_2d_scene_t { arm_2d_scene_t *ptNext; //!< next scene arm_2d_scene_player_t *ptPlayer; //!< points to the host scene player arm_2d_region_list_item_t *ptDirtyRegion; //!< dirty region list for the foreground arm_2d_helper_draw_handler_t *fnBackground; //!< the function pointer for the background arm_2d_helper_draw_handler_t *fnScene; //!< the function pointer for the foreground void (*fnOnBGStart)(arm_2d_scene_t *ptThis); //!< on-start-drawing-background event handler void (*fnOnBGComplete)(arm_2d_scene_t *ptThis); //!< on-complete-drawing-background event handler void (*fnOnFrameStart)(arm_2d_scene_t *ptThis); //!< on-frame-start event handler void (*fnOnFrameCPL)(arm_2d_scene_t *ptThis); //!< on-frame-complete event handler /*! * \note We use fnDepose to free the resources */ void (*fnDepose)(arm_2d_scene_t *ptThis); //!< on-scene-depose event handler struct { uint8_t bOnSwitchingIgnoreBG : 1; //!< ignore background during switching period uint8_t bOnSwitchingIgnoreScene : 1; //!< ignore forground during switching period };};
其数据结构并不复杂。
数据结构的主体是这两个指针:
-
fnScene:指向一个由用户提供的绘图函数:
-
绘制一个场景中所有的内容;或者
-
当场景中存在“不会变化且不会被覆盖的背景”和“少数”内容会发生变化的前景时,专门用于绘制前景——此时就需要通过ptDirtyRegion来指向描述前景变化区域的脏矩阵(Dirty Region List)。
-
fnBackground:指向一个由用户提供的绘图函数,专门绘制一个场景中那些“只需要绘制一次”且“未来不会被前景覆盖或者变化”的内容,最典型的就是绘制场景中的背景图片;
需要特别说明的是:
-
fnBackground 只会在绘制每个场景的第一帧时调用;
-
随后的每一帧就只会调用 fnScene;
-
fnBackground 会绘制整个屏幕;
-
脏矩阵(ptDirtyRegion)只对 fnScene 有效;
-
当ptDirtyRegion 为 NULL时,fnScene也是绘制整个屏幕。
-
这意味着,当 ptDirtyRegion为NULL时,fnBackground 绘制的内容会 100% 被覆盖掉——也就是说完全没用。这意味着:
-
当且仅当我们指定了有效的脏矩阵时,fnBackground 才是实际有意义的。
如果你对“背景”和“前景”的分工感到似懂非懂,不妨看下面这个例子:
在这个场景中:
-
作为背景的狗头实际上不会发生变化,因此我们只需在 fnBackground 所指向的绘图函数中绘制即可;
-
动态进度条由于其内容一直在变化,因此需要在 fnScene所指向的绘图函数中“配合脏矩阵”进行重复绘制。
可以看到,这里的事件处理顺序并不复杂,大家可以根据实际的应用需求各取所需。
【场景播放器(scene player)的本质是什么】
场景播放器的本质是一个针对场景(scene)的队列(FIFO):
- 用户可以预先生成多个场景,并通过函数arm_2d_scene_player_append_scenes压入队列中;
- 队列的头部就是当前生效的场景;
- 用户可以在任意时刻通过函数arm_2d_scene_player_switch_to_next_scene来安全的触发场景切换,
- 所谓的场景切换就是丢弃队列当前的头部场景——换成下一个;
- 场景切换后,被丢弃的场景会调用 fnDepose ,用户可以利用这个函数为对应场景“擦屁股”
- 比如,假设一个场景(arm_2d_scene_t)对象本身就是动态分配的(从 malloc中分配),那么就可以通过 fnDepose 方法来将内存释放掉(比如调用 free函数)。
- 场景播放器提供了 arm_2d_scene_player_flush_fifo 方法,它会清空整个队列。
- 被清空出去的场景都会被依次调用 fnDepose,因此不用担心内存泄露的问题。
- 场景切换是支持特效的,比如:淡入淡出、滑动和擦除等等
【用场景开发也太简单了8!】
前面洋洋洒洒的做了这么多理论铺垫,也许会让你对 scene 的使用产生了“非常复杂”的错觉或者担忧,但实际情况却相反:借助cmsis-pack和RTE的帮助,创建 scene 几乎只要点几下鼠标就可以搞定,而且立即就可以使用。
假设你已经根据《【喂到嘴边了的模块】准备徒手撸GUI?用Arm-2D三分钟就够了》的描述,完成了 arm-2d 的部署,并且成功的加入了一个 Display Adapter,此时我们应该能看到这样的效果:
此时,打开 RTE,展开Acceleration后在Arm-2D Helper中找到 Scene:
如果你的界面中找不到 Scene,说明你的 arm-2d cmsis-pack 版本较老,可以关注公众号【裸机思维】后,发送关键字 arm-2d 后获取最新版本的网盘链接。
在Scene的右边,我们可以通过“增加数值”的方式向工程中添加指定数量的场景。单击确定后,对应数量的场景模板会加入到工程管理器中:
这里的 arm_2d_scene_0.h 和 arm_2d_scene_0.c 分别对应我们新加入的场景的头文件和源代码。
其实,所谓的 Display Adapter 就是场景播放器(arm_2d_scene_player_t):
ARM_NOINITexternarm_2d_scene_player_t DISP0_ADAPTER;
在初始化完 Display Adapter 后,我们调用场景的初始化函数arm_2d_scene0_init()——将它们加入指定的场景播放器队列中:
...int main (void) { arm_irq_safe { arm_2d_init(); } disp_adapter0_init(); arm_2d_scene0_init(&DISP0_ADAPTER); while(1) { disp_adapter0_task(); } }
调用函数 arm_2d_scene_player_switch_to_next_scene() 来切换到我们新加入的场景中:
...int main (void) { arm_irq_safe { arm_2d_init(); } disp_adapter0_init(); arm_2d_scene0_init(&DISP0_ADAPTER); arm_2d_scene_player_switch_to_next_scene(&DISP0_ADAPTER); while(1) { disp_adapter0_task(); } }
为了方便观察效果,不妨设置一个场景切换效果:
-
...int main (void) { arm_irq_safe { arm_2d_init(); } disp_adapter0_init(); /* 初始化场景 scene0,并将其加入到场景播放器 DISP0_ADAPTER 中 */ arm_2d_scene0_init(&DISP0_ADAPTER); /* 设置切换特效为 淡入淡出(白色) */ arm_2d_scene_player_set_switching_mode( &DISP0_ADAPTER, ARM_2D_SCENE_SWITCH_MODE_FADE_WHITE); /* 设置切换持续时间为 3000ms */ arm_2d_scene_player_set_switching_period( &DISP0_ADAPTER, 3000); /* 申请切换到新加入的场景中 */ arm_2d_scene_player_switch_to_next_scene(&DISP0_ADAPTER); while(1) { disp_adapter0_task(); } }
编译后运行,可以看到类似如下的效果:
可以看到,场景播放器从默认的“转圈圈”界面以“渐明渐暗”的形式切换到了我们的新场景 scene0 中。
细心的小伙伴可能很快就注意到了一个奇怪的地方:为啥很快 scene0 又消失在白屏中了呢?要解答这一疑问不妨打开