【说在前面的话】
资源相对紧张的MCU,无法负担起传统的嵌入式GUI(比如以体积“小巧”著称的LVGL): Flash <= 64K,或者 应用本身已经占用了大量Flash空间,留给GUI的空间非常有限 SRAM <= 16K 需要实现的GUI界面较为简单(这点在随后会详细介绍) 帧率要求较低(传说中的8帧不卡、9帧流畅、10帧电竞)
【基于面板的界面设计】
就本文要讨论的内容来说,我们可以简单的关注以下的一些要点:
智能设备的界面强调“简洁”、并希望“让用户的注意力一次只集中在一件简单的事物上”。
与Windows不同,智能设备的界面很少(或者极力避免)窗口重叠 界面的基本单位不是“窗体(Window)”,而是以整个屏幕为基本单位的“面板(Panel)” 每个面板的内容都尽可能简单、通过留白的方式强调那些需要用户注意的内容; 每个面板的功能都尽可能单一: 一般避免在同一个面板中挤进多个不太相关的功能; 相关的内容,如果能够放得下,且美观,则可以有主次的布置在同一个面板中以减少用户切换面板带来的不便; 如果相关的内容如果无法在同一个面板中展示,则一定会添加快捷方式方便用户快速进行面板的切换; 面板间的切换方式以大家熟悉的PPT页面切换方式类似 对滑动切换来说,要么不做,要做就要“丝滑”(差不多30FPS),否则会给用户带来“卡顿”的不适感 完全没有动画的切换往往会给用户“设备反应迅速”的错觉,对负担不起高帧率的嵌入式设备来说,反而是最好的选择
仔细回想一下,身边的智能设备,是不是都基本满足上述特点?——其实我们熟悉的手机和平板也是如此。
基于上述原则,我们甚至可以总结出一套简单有效的“嵌入式界面设计八股”:
用户界面分成三个部分:状态面板、导航面板和功能面板 状态面板:又叫待机面板,用于显示状态信息(比如温度、时间、产品Logo、产品当前状态等等)。 通常在待机界面上按下任意键(或者进行任意触摸)进入导航面板 一般用户超过一段时间没有与界面进行交互后会自动进入状态面板,所以状态面板有时候又叫待机面板 导航面板:也就是大家常说的菜单。 一般导航面板以图标、列表或者按钮的形式存在, 一般避免超出屏幕范围的内容,最好做到让用户对所有选项“尽收眼底” 导航面板可以通过子面板的形式实现多级菜单,从而简化开发 功能面板:实现具体功能的面板,一般由导航面板进入 每个面板的功能都尽可能单一,比如专门设置温度、专门设置时间等等 相关的导航面板之间可以通过类似左右箭头(或者底部导航快捷按钮)的机制进行快捷切换
【什么是场景(scene)】
【场景(scene)的数据结构和构成】
/*!
* \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
};
};
其数据结构并不复杂。
数据结构的主体是这两个指针:
fnScene:指向一个由用户提供的绘图函数:
绘制一个场景中所有的内容;或者
当场景中存在“不会变化且不会被覆盖的背景”和“少数”内容会发生变化的前景时,专门用于绘制前景——此时就需要通过ptDirtyRegion来指向描述前景变化区域的脏矩阵(Dirty Region List)。
fnBackground:不要使用(Deprecated)
ptDirtyRegion:指向一个可选的脏矩阵列表,通过这个脏矩阵列表,我们可以告诉场景播放器“只刷新某些我们指定的区域”
需要特别说明的是:
脏矩阵(ptDirtyRegion)只对 fnScene 有效;
当ptDirtyRegion 为 NULL时,fnScene也是绘制整个屏幕。
当ptDirtyRegion不为NULL时,第一次刷新场景(fnScene)时场景播放器会无视脏矩阵(也就是绘制整个屏幕),而在随后的场景刷新时,使用脏矩阵进行局部刷新。
当我们需要更新背景时,可以调用函数arm_2d_scene_player_update_scene_background() 来申请一次全屏幕刷新。
如果你对“背景”和“前景”的分工感到似懂非懂,不妨看下面这个例子:
在这个场景中:
作为背景的狗头实际上不会发生变化,因此只需要绘制一次即可
动态进度条由于其内容一直在变化,因此需要在 fnScene所指向的绘图函数中“配合脏矩阵”进行重复绘制。
用户可以预先生成多个场景,并通过函数arm_2d_scene_player_append_scenes压入队列中; 队列的头部就是当前生效的场景; 用户可以在任意时刻通过函数arm_2d_scene_player_switch_to_next_scene来安全的触发场景切换, 所谓的场景切换就是丢弃队列当前的头部场景——换成下一个; 当用户申请了场景切换后,在即将开始进行场景切换前,即将被丢弃的场景会调用fnBeforeSwitchingOut,用户甚至可以在这里决定下一个场景是谁(创建新场景、通过构造函数完成初始化并加入队列)。 除了每个场景有一个fnBeforeSwitchingOut事件处理以外,其实场景播放器自己也有一个公共的fnBeforeSwitchingOut,用户完全可以利用它来实现场景的调度,而不是由具体某个场景决定自己的后续。 场景切换后,被丢弃的场景会调用 fnDepose ,用户可以利用这个函数为对应场景“擦屁股” 比如,假设一个场景(arm_2d_scene_t)对象本身就是动态分配的(从 malloc中分配),那么就可以通过 fnDepose 方法来将内存释放掉(比如调用 free函数)。 场景播放器提供了 arm_2d_scene_player_flush_fifo 方法,它会清空整个队列。 被清空出去的场景都会被依次调用 fnDepose,因此不用担心内存泄露的问题。 场景切换是支持特效的,比如:淡入淡出、滑动和擦除等等
【用场景开发也太简单了8!】
这里的 arm_2d_scene_0.h 和 arm_2d_scene_0.c 分别对应我们新加入的场景的头文件和源代码。
其实,所谓的 Display Adapter 就是场景播放器(arm_2d_scene_player_t):
ARM_NOINIT
extern
arm_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 中。
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);
}
__on_scene0_frame_complete 在场景的每一帧绘制完成后都被调用一次; 函数 arm_2d_helper_is_time_out() 会在超过3000毫秒后返回 true,并申请场景切换 由于场景播放器的队列中已经没有下一个场景,因此以白屏告终。
我们在 __pfb_draw_scene0_handler 中绘制场景,且其中已经包含了一个用 Mask 打印 cmsis-logo 和左上角红色字符串"scene0"的例子代码:
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);
/* 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);
}
/* 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);
}
/* 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);
}
是不是非常贴心呢?
【通过代码模板创建新场景】
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 。通过文本替换功能
将所有的“<NAME>”替换为你新场景的名称,比如 MY_SCENE,注意要大写
将所有的“<name>”替换为你新场景的名称,比如 my_scene,注意要小写
注意:替换时,请一定要将“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 加入场景的头文件,并调用对应的初始化函数,例如:
...
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
*/
__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() 函数来释放资源:
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_SWITCH_MODE_FADE_WHITE
以黑色为过渡:ARM_2D_SCENE_SWITCH_MODE_FADE_BLACK
滑动效果:LEFT / RIGHT / UP / DOWN
擦除效果:LEFT / RIGHT / UP / DOWN
用户可以通过函数 arm_2d_scene_player_set_switching_mode() 来指定切换模式。
细节三:模板中使用了动态的方式来生成场景
它存在的目的主要是帮助我们完成移植时观察现象,并测算基本的带宽信息(测算LCD Latency)。进行实际应用开发时,往往并不希望将其作为用户看到的第一个场景——因此,我们可以通过对应Display Adapter的配置界面将其关闭:即,勾选 Disable the default scene(如下图所示)。
需要特别注意的是:
关闭 Display Adapter 的默认场景后,Display Adapter的场景队列实际上是空的
用户初始化并加入的第一个场景就成为了场景队列中的第一个场景,也就是当前直接显示在屏幕上的场景——因此无需调用场景切换函数 arm_2d_scene_player_switch_to_next_scene() 使其生效——这与未关闭默认场景时的情况是不同的。
如果你仍然调用了场景切换,且没有后续场景加入场景播放器,那么根据切换特效的不同,你看到的结果可能是白屏、黑屏或者是前一个场景的静止画面。
继续向Display Adapter加入场景后,会解除上述现象。
细节四:如何在多个场景中自由切换
场景播放器内部维护的是一个场景的FIFO,其逻辑就是:以用户入队的顺序来顺次播放场景。但实际应用中,场景与场景之间的关系是网状的,而不是一根筋的线性关系,这该如何处理呢?
由具体场景决定自己的后续:此时,只需要在对应场景的 __before_sceneN_switching_out() 事件处理程序中初始化“后继”场景即可,比如:
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);
}
由用户创建的中心调度逻辑来决定。每个Display Adapter(内置了一个场景播放器)都有一个公共的 before_scene_switching 事件,我们可以按照下面的方法来注册自己的事件处理程序:
/* 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++;
}
【说在后面的话】
Arm-2D 作为 Cortex-M 处理器的“显卡驱动”,不仅能为已有的GUI协议栈(比如LVGL)提供底层加速,还为资源受限的MCU实现GUI提供了一种“基于面板(Panel)开发”的解决方案。
在使用Arm-2D直接进行应用开发的过程中,场景是基本单位,也就是说我们所有的界面绘制工作都是在具体的场景中进行的。在本文中,我们已经学会了如何创建新的场景,并介绍了场景切换的基本方式。这就好比我们已经拥有了一个基本的舞台。
在下一篇文章中,我们将着重介绍使用 Arm-2D 进行简单GUI开发的一些基本步骤和对应的API函数。
如果你喜欢我的思维、觉得我的文章对你有所启发,
请务必 “点赞、收藏、转发” 三连,这对我很重要!谢谢!
欢迎订阅 裸机思维