【说在前面的话】
在前面的文章《【玩转Arm-2D】入门和移植从未如此简单》中,我们介绍了如何借助 cmsis-pack 快速的在 MDK 中部署 arm-2d。
在过去的一段时间内,想必很多人都完成了部署,看到了下面的画面吧?
如果还没有,推荐先跟着上一篇文章的手把手图文教程——完成基本的部署吧。本文将在此基础上继续为您介绍如何使用arm-2d来简化我们手撸GUI的过程。
为了避免让大家产生疑惑,这里我们需要再次明确一下我们所要面对的开发环境:
【基于面板的界面设计】
让我把话挑明了吧——流畅的滑动只是添料,甚至是可以完全丢弃的——真正核心的是一套与传统Windows图形界面设计完全不同的理念。关于这套设计理念,有一套叫做“人本界面”的设计方法论作为支撑,感兴趣的小伙伴可以在豆瓣上搜索同名的图书。
就本文要讨论的内容来说,我们可以简单的关注以下的一些要点:
基于这一原则,又派生出了如下的特点:
仔细回想一下,身边的智能设备,是不是都基本满足上述特点?——其实我们熟悉的手机和平板也是如此。
基于上述原则,我们甚至可以总结出一套简单有效的“嵌入式界面设计八股”:
【什么是场景(scene)】
“场景(scene)”是 arm-2d为“手撸GUI”的用户引入的一个概念,通过配套的“场景播放器(scene player)”,极大的简化了基于面板的界面开发。
一般来说,一个简单的面板用一个场景就可以搞定;而稍微复杂点的面板则可以通过多个场景(以及基于状态机的场景切换)来搞定——总的原则就是,无论多复杂的面板,都可以拆分成一个个简单的场景来分而治之。
也许你已经注意到了:原本面板本身就已经很简单了,那么所谓“复杂的面板”根据状态机拆分成多个场景后是不是更加简单了?——是的,每个场景的功能都是极其单一和简单的——极大的简化了每个场景的实现难度。
举个例子:有个面板的功能是设置温度,当超过某一特定值后,需要弹出一个窗口提醒用户当前设置值有某些注意事项。这样的面板在设计时就可以拆分成两个场景:1)一个正常的数值设置场景,实现一个类似滑条的功能让用户设置温度;2)一个专门的场景来提示用户注意事项——通过这样的安排,每个场景都可以非常单一。
再比如:某个面板的用来设置多个相关的选项,并且当用户开启某个开关后,会出现一些隐藏选项(或者原本不可设置的选项变成可选)。此时,就可以根据这个开关的状态,引入两个场景:一个对应开关关闭时的面板,一个对应开关开启时的面板——总之,面板拆的越细致,每个场景的设计就越简单。
【场景(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 can use this event to initialize/generate the new(next) scene
*/
void (*fnBeforeSwitchOut)(arm_2d_scene_t *ptThis); //!< before-scene-switch-out 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
};
};
其数据结构并不复杂。
数据结构的主体是这两个指针:
fnBackground:不要使用(Deprecated)
需要特别说明的是:
如果你对“背景”和“前景”的分工感到似懂非懂,不妨看下面这个例子:
在这个场景中:
为了方便应用开发,arm_2d_scene_t 提供了一系列事件处理程序接口(回调函数),它们与背景、场景的绘制关系如下:
可以看到,这里的事件处理顺序并不复杂,大家可以根据实际的应用需求各取所需。
【场景播放器(scene player)的本质是什么】
场景播放器的本质是一个针对场景(scene)的队列(FIFO):
【用场景开发也太简单了8!】
假设你已经根据《【玩转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 分别对应我们新加入的场景的头文件和源代码。
打开 main.c,加入对场景的头文件引用:
#include "arm_2d_scenes.h"
其实,所谓的 Display Adapter 就是场景播放器(arm_2d_scene_player_t):
ARM_NOINIT
extern
arm_2d_scene_player_t DISP0_ADAPTER;
在初始化完 Display Adapter 后,我们调用场景的初始化函数arm_2d_scene0_init()——将它们加入指定的场景播放器队列中:
#include "arm_2d_scenes.h"
...
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() 来切换到我们新加入的场景中:
#include "arm_2d_scenes.h"
...
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();
}
}
为了方便观察效果,不妨设置一个场景切换效果:
#include "arm_2d_scenes.h"
...
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 又消失在白屏中了呢?要解答这一疑问不妨打开 arm_2d_scene_0.c 一探究竟。注意到时间处理函数 __on_scene0_frame_complete:
static void __on_scene0_frame_complete(arm_2d_scene_t *ptScene)
{
ARM_2D_UNUSED(ptScene);
/* switch to next scene after 3s */
if (arm_2d_helper_is_time_out(3000)) {
arm_2d_scene_player_switch_to_next_scene(ptScene->ptPlayer);
}
}
结合初始化代码:
void arm_2d_scene0_init(arm_2d_scene_player_t *ptDispAdapter)
{
...
arm_2d_scene_t *ptScene = (arm_2d_scene_t *)malloc(sizeof(arm_2d_scene_t));
assert(NULL != ptScene);
*ptScene = (arm_2d_scene_t){
.fnBackground = NULL,
.fnScene = &__pfb_draw_scene0_handler,
.ptDirtyRegion = (arm_2d_region_list_item_t *)s_tDirtyRegions,
/* Please uncommon the callbacks if you need them
*/
//.fnOnBGStart = &__on_scene0_background_start,
//.fnOnBGComplete = &__on_scene0_background_complete,
//.fnOnFrameStart = &__on_scene0_frame_start,
.fnOnFrameCPL = &__on_scene0_frame_complete,
.fnDepose = &__on_scene0_depose,
};
arm_2d_scene_player_append_scenes( ptDispAdapter, ptScene, 1);
}
根据前面事件调用关系流程图,我们容易发现:
破案了!!!
此外,观察 arm_2d_scene_0.c 可以发现,该模板已经为我们打好了所有的基础,并添加了所有基础的代码:
static
IMPL_PFB_ON_DRAW(__pfb_draw_scene0_handler)
{
ARM_2D_UNUSED(pTarget);
ARM_2D_UNUSED(ptTile);
ARM_2D_UNUSED(bIsNewFrame);
/*-----------------------draw the foreground begin-----------------------*/
/* following code is just a demo, you can remove them */
arm_2d_fill_colour(ptTile, NULL, GLCD_COLOR_WHITE);
#if 0
/* draw the cmsis logo in the centre of the screen */
arm_2d_align_centre(ptTile->tRegion, c_tileCMSISLogo.tRegion.tSize) {
arm_2d_tile_copy_with_src_mask( &c_tileCMSISLogo,
&c_tileCMSISLogoMask,
ptTile,
&__centre_region,
ARM_2D_CP_MODE_COPY);
}
#else
/* draw the cmsis logo using mask in the centre of the screen */
arm_2d_align_centre(ptTile->tRegion, c_tileCMSISLogo.tRegion.tSize) {
arm_2d_fill_colour_with_mask_and_opacity(
ptTile,
&__centre_region,
&c_tileCMSISLogoMask,
(__arm_2d_color_t){GLCD_COLOR_BLACK},
64);
}
#endif
/* draw text at the top-left corner */
arm_lcd_text_set_target_framebuffer((arm_2d_tile_t *)ptTile);
arm_lcd_text_set_colour(GLCD_COLOR_RED, GLCD_COLOR_WHITE);
arm_lcd_text_location(0,0);
arm_lcd_puts("Scene 0");
/*-----------------------draw the foreground end -----------------------*/
arm_2d_op_wait_async(NULL);
return arm_fsm_rt_cpl;
}
void arm_2d_scene0_init(arm_2d_scene_player_t *ptDispAdapter,
user_scene_0_t *ptThis)
{
assert(NULL != ptDispAdapter);
/*! define dirty regions */
IMPL_ARM_2D_REGION_LIST(s_tDirtyRegions, static)
/* a dirty region to be specified at runtime*/
ADD_REGION_TO_LIST(s_tDirtyRegions,
0 /* initialize at runtime later */
),
/* add the last region:
* it is the top left corner for text display
*/
ADD_LAST_REGION_TO_LIST(s_tDirtyRegions,
.tLocation = {
.iX = 0,
.iY = 0,
},
.tSize = {
.iWidth = 320,
.iHeight = 8,
},
),
END_IMPL_ARM_2D_REGION_LIST()
/* get the screen region */
arm_2d_region_t tScreen
= arm_2d_helper_pfb_get_display_area(
&ptDispAdapter->use_as__arm_2d_helper_pfb_t);
/* initialise dirty region 0 at runtime
* this demo shows that we create a region in the centre of a screen(320*240)
* for a image stored in the tile c_tileCMSISLogoMask
*/
arm_2d_align_centre(tScreen, c_tileCMSISLogoMask.tRegion.tSize) {
s_tDirtyRegions[0].tRegion = __centre_region;
}
if (NULL == ptThis) {
ptThis = (user_scene_0_t *)malloc(sizeof(user_scene_0_t));
assert(NULL != ptThis);
if (NULL == ptThis) {
return NULL;
}
} else {
bUserAllocated = true;
}
memset(ptThis, 0, sizeof(user_scene_0_t));
*ptThis = (user_scene_0_t){
.use_as__arm_2d_scene_t = {
/* Please uncommon the callbacks if you need them
*/
.fnScene = &__pfb_draw_scene0_handler,
.ptDirtyRegion = (arm_2d_region_list_item_t *)s_tDirtyRegions,
//.fnOnBGStart = &__on_scene0_background_start,
//.fnOnBGComplete = &__on_scene0_background_complete,
.fnOnFrameStart = &__on_scene0_frame_start,
//.fnBeforeSwitchOut = &__before_scene0_switching_out,
.fnOnFrameCPL = &__on_scene0_frame_complete,
.fnDepose = &__on_scene0_depose,
},
.bUserAllocated = bUserAllocated,
};
arm_2d_scene_player_append_scenes( ptDispAdapter, ptScene, 1);
}
是不是非常贴心呢?
【通过代码模板创建新场景】
除了上面介绍的通过RTE来添加新场景的方式,cmsis-pack还未我们在MDK中提供了另外一种选择——通过代码模板来添加。具体步骤为:
1、在工程管理器中选中你想添加代码模板的Group,单击右键,弹出菜单:
2、选择Add New Item to Group。
3、在窗口中找到User Code Template。展开Acceleration,选中Arm-2D:Core的User Scene Template。这里,我们可以在Location中设置代码模板存放的位置。
4、在编辑器中打开新加入的 arm_2d_scene_template.c 和 arm_2d_scene_template.h 。通过文本替换功能
注意:替换时,请一定要将“Match whole word” 选项去掉,并勾选“Match case”
5、建议根据场景的名称修改arm_2d_scene_template.c 和arm_2d_scene_template.h 的文件名:比如我们的场景叫my scene,因此对应的文件名称为 arm_2d_scene_my_scene.c 和 arm_2d_scene_my_scene.h 。
6、将修改名称后的.c和.h加入工程中参与编译。
7、需要使用新场景时,别忘记通过 #include 加入场景的头文件,并调用对应的初始化函数,例如:
#include "arm_2d_scene_my_scene.h"
...
arm_2d_scene_my_scene_init(&DISP0_ADAPTER);
...
【一些值得注意的细节】
细节一:模板中使用了动态的方式来生成场景
虽然不是必须的,但场景的模板中使用了动态的方式来生成场景:
/*!
* \brief initalize scene0 and add it to a user specified scene player
* \param[in] __DISP_ADAPTER_PTR the target display adatper (i.e. scene player)
* \param[in] ... this is an optional parameter. When it is NULL, a new
* user_scene_0_t will be allocated from HEAP and freed on
* the deposing event. When it is non-NULL, the life-cycle is managed
* by user.
* \return user_scene_0_t* the user_scene_0_t instance
*/
#define arm_2d_scene0_init(__DISP_ADAPTER_PTR, ...) \
__arm_2d_scene0_init((__DISP_ADAPTER_PTR), (NULL, ##__VA_ARGS__))
user_scene_0_t *__arm_2d_scene0_init(arm_2d_scene_player_t *ptDispAdapter,
user_scene_0_t *ptThis)
{
assert(NULL != ptDispAdapter);
...
arm_2d_scene_t *ptScene = (arm_2d_scene_t *)malloc(sizeof(arm_2d_scene_t));
assert(NULL != ptScene);
*ptScene = (arm_2d_scene_t){
...
.fnDepose = &__on_scene0_depose,
};
arm_2d_scene_player_append_scenes( ptDispAdapter, ptScene, 1);
}
并在场景的 __on_scene0_depose 函数中(也就是场景废弃事件处理程序 fnDepose里)进行了释放:
static void __on_scene0_depose(arm_2d_scene_t *ptScene)
{
ptScene->ptPlayer = NULL;
free(ptScene);
}
当我们初始化一个场景时,我们通常直接调用它的构造函数:
arm_2d_scene0_init(&DISP0_ADAPTER);
此时,该场景就是使用堆来分配对象的。但其实我们可以通过别的方式事先为场景创建变量,并将其指针传递给构造函数:
static user_scene_0_t my_scene0;
arm_2d_scene0_init(&DISP0_ADAPTER, &my_scene0);
此时,该场景将不再“自动使用”堆来创建对象,改为直接初始化我们所提供的场景变量:
user_scene_0_t *__arm_2d_scene0_init(
arm_2d_scene_player_t *ptDispAdapter,
user_scene_0_t *ptThis)
{
...
if (NULL == ptThis) {
ptThis = (user_scene_0_t *)malloc(sizeof(user_scene_0_t));
assert(NULL != ptThis);
if (NULL == ptThis) {
return NULL;
}
} else {
bUserAllocated = true;
}
memset(ptThis, 0, sizeof(user_scene_0_t));
...
}
容易发现,当用户传递的第二个参数 ptThis 不为NULL时,变量 bUserAllocated 将被设置为 true——对应的,在场景释放时,模板中的 __on_scene0_depose() 也会通过检测 bUserAllocated发现不需要调用 free() 函数来释放资源:
#undef this
#define this (*ptThis)
static void __on_scene0_depose(arm_2d_scene_t *ptScene)
{
user_scene_0_t *ptThis = (user_scene_0_t *)ptScene;
ARM_2D_UNUSED(ptThis);
...
if (!this.bUserAllocated) {
free(ptScene);
}
}
细节二:场景切换的多种模式
现阶段,场景播放器为用户提供多种切换特效:
typedef enum {
/* valid switching visual effects begin */
ARM_2D_SCENE_SWITCH_MODE_NONE = 0, //!< no switching visual effect
ARM_2D_SCENE_SWITCH_MODE_USER = 1, //!< user defined switching visual effect
ARM_2D_SCENE_SWITCH_MODE_FADE_WHITE = 2, //!< fade in fade out (white)
ARM_2D_SCENE_SWITCH_MODE_FADE_BLACK = 3, //!< fade in fade out (black)
ARM_2D_SCENE_SWITCH_MODE_SLIDE_LEFT = 4, //!< slide left
ARM_2D_SCENE_SWITCH_MODE_SLIDE_RIGHT, //!< slide right
ARM_2D_SCENE_SWITCH_MODE_SLIDE_UP, //!< slide up
ARM_2D_SCENE_SWITCH_MODE_SLIDE_DOWN, //!< slide down
ARM_2D_SCENE_SWITCH_MODE_ERASE_LEFT = 8, //!< erase to the right
ARM_2D_SCENE_SWITCH_MODE_ERASE_RIGHT, //!< erase to the left
ARM_2D_SCENE_SWITCH_MODE_ERASE_UP, //!< erase to the top
ARM_2D_SCENE_SWITCH_MODE_ERASE_DOWN, //!< erase to the bottom
...
};
这其中就包含了大家常见的:
用户可以通过函数 arm_2d_scene_player_set_switching_mode() 来指定切换模式。
此外,用户还可以通过函数arm_2d_scene_player_set_switching_period() 来指定“整个切换过程需要多长时间内完成”——这意味着,如果具体的硬件带宽不足(刷新率不够),场景播放器会以跳帧的方式来满足时间要求。
细节三:模板中使用了动态的方式来生成场景
每个Display Adapter都携带了一个默认的场景,也就是我们移植完毕后所看到的“转圈圈”界面:
它存在的目的主要是帮助我们完成移植时观察现象,并测算基本的带宽信息(测算LCD Latency)。进行实际应用开发时,往往并不希望将其作为用户看到的第一个场景——因此,我们可以通过对应Display Adapter的配置界面将其关闭:即,勾选 Disable the default scene(如下图所示)。
需要特别注意的是:
细节四:如何在多个场景中自由切换
场景播放器内部维护的是一个场景的FIFO,其逻辑就是:以用户入队的顺序来顺次播放场景。但实际应用中,场景与场景之间的关系是网状的,而不是一根筋的线性关系,这该如何处理呢?
其实,我们不该被场景播放器的队列迷惑——它只是方便我们事先缓冲一些场景而已:比如将“数据载入的Loading界面”和“下一个工作界面”都加入缓冲等等。
我们需要做的是根据需要往队列里加入少量目标场景即可。这里有两种解决思路:
static void __before_scene0_switching_out(arm_2d_scene_t *ptScene)
{
user_scene_0_t *ptThis = (user_scene_0_t *)ptScene;
ARM_2D_UNUSED(ptThis);
/* 加入一个loading页面(当然这个scene是用户事先设计好的) */
arm_2d_scene_loading_page(
this.use_as__arm_2d_scene_t.ptPlayer);
/* 在loading页面后紧随的实际工作场景 */
arm_2d_scene_example_scene(
this.use_as__arm_2d_scene_t.ptPlayer);
}
/* load scene one by one */
void before_scene_switching_handler(void *pTarget,
arm_2d_scene_player_t *ptPlayer,
arm_2d_scene_t *ptScene)
{
...
}
int main (void)
{
arm_irq_safe {
arm_2d_init();
}
disp_adapter0_init();
arm_2d_scene_player_register_before_switching_event_handler(
&DISP0_ADAPTER,
before_scene_switching_handler);
...
while (1) {
disp_adapter0_task();
}
}
随后,每当场景播放器(按照用户的申请)即将进行场景切换的前夕,我们注册的 before_scene_switching 事件处理程序都会被调用,此时,我们就可以根据自己编写的中心调度策略来决定谁是下一个场景。
比如,这里我们就编写了一个非常简单的场景顺次切换中心调度,仅供娱乐:
void scene0_loader(void)
{
arm_2d_scene0_init(&DISP0_ADAPTER);
}
void scene1_loader(void)
{
arm_2d_scene1_init(&DISP0_ADAPTER);
}
void scene2_loader(void)
{
arm_2d_scene2_init(&DISP0_ADAPTER);
}
void scene3_loader(void)
{
arm_2d_scene3_init(&DISP0_ADAPTER);
}
void scene4_loader(void)
{
arm_2d_scene4_init(&DISP0_ADAPTER);
}
typedef void scene_loader_t(void);
static scene_loader_t * const c_SceneLoaders[] = {
scene0_loader,
scene1_loader,
scene3_loader,
scene4_loader,
scene2_loader,
};
/* load scene one by one */
void before_scene_switching_handler(void *pTarget,
arm_2d_scene_player_t *ptPlayer,
arm_2d_scene_t *ptScene)
{
static uint_fast8_t s_chIndex = 0;
if (s_chIndex >= dimof(c_SceneLoaders)) {
s_chIndex = 0;
}
/* call loader */
c_SceneLoaders[s_chIndex]();
s_chIndex++;
}
实际应用中,中心调度器的逻辑还是要看大家自己“八仙过海各显神通”了。
【说在后面的话】
我们在文章的开头简单介绍了现代嵌入式GUI设计以“面板”为基本单位的设计模式,并以此引入了场景(scene)这个概念。
相信借助 arm-2d 场景播放器(scene player),尤其是在“滑动场景切换特效”的帮助下,在资源受限的环境中,“手撸GUI”的难度将大大降低。
Arm-2D 作为 Cortex-M 处理器的“显卡驱动”,不仅能为已有的GUI协议栈(比如LVGL)提供底层加速,还为资源受限的MCU实现GUI提供了一种“基于面板(Panel)开发”的解决方案。
在使用Arm-2D直接进行应用开发的过程中,场景是基本单位,也就是说我们所有的界面绘制工作都是在具体的场景中进行的。在本文中,我们已经学会了如何创建新的场景,并介绍了场景切换的基本方式。这就好比我们已经拥有了一个基本的舞台。
在下一篇文章中,我们将着重介绍使用 Arm-2D 进行简单GUI开发的一些基本步骤和对应的API函数。