Quick Start / 快速移植

先把 PixelUI 的显示、时间、输入和刷新链路跑通。
Tickless 模式当前处于 Experimental 阶段,并未经测试。

1. 准备依赖

PixelUI 依赖 U8g2 和 ETL,主仓库通过 submodule 引入它们:

1
2
3
git clone --branch v0.3.1-beta --recursive https://github.com/Lawrence-Link/PixelUI.git
cd PixelUI
git submodule update --init --recursive

工程使用 C++20。PixelUI 核心关闭 exceptions/RTTI,并定义 ETL_NO_STL。移植到自己的构建系统时,需要保持这些编译条件一致。

请首先配置好 U8G2 与 ETL ( 确保编译环境可以链接到 ETL Library )

2. 测试 U8g2 单独工作

PixelUI 使用 U8g2 的全屏 framebuffer。接入 PixelUI 前,先确认底层显示正常:

1
2
3
4
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 12, "U8g2 works");
u8g2.sendBuffer();

3. 创建 PixelUI

1
2
3
4
#include "PixelUI.h"

U8G2 u8g2;
PixelUI ui(u8g2);

U8G2 理应比 PixelUI 活得更久。这在嵌入式系统里一般不是问题。PixelUI 内联了 App arena 和各个 manager,不是一个小句柄。建议放在全局/静态存储区,不要放进小容量 RTOS 任务栈。

begin() 在当前版本是保留的空初始化点;它不会替你初始化 U8g2 或启动 App。

4. 先理解 PixelUI 怎样运行

PixelUI 是一个由宿主驱动的 UI 库,它不会自己创建线程、定时器或无限循环。这样才能同时适应裸机、RTOS 和桌面模拟器,但也意味着平台需要主动把三类事情交给它:

  1. 时间经过了多久:动画、Popup 超时、Coroutine 延时和页面 fade 都要依靠内部精准的时间戳。时间戳通过心跳 (ticking, 也就是heartbeat) 在内部进行不断累加。
  2. 用户做了什么:各种按键或编码器事件通过 handleInput() 路由进入 Focus、Popup 和当前 App。
  3. 画面是否需要更新:状态改变后用 markDirty() 请求新帧,最后由 renderer() 在 U8g2 framebuffer 中绘制并发送。

为什么需要 heartbeat

PixelUI 不能假设每个平台都有相同的时钟 API。宿主需要把真实经过的毫秒数交给 heartbeat(elapsedMs),或在定时 ISR 中交给 tickFromISR(elapsedMs)。它们只输入时间;renderer() 会推进到期状态,并只在需要时提交 framebuffer。完整语义见事件驱动渲染

5. 接入主循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include "PixelUI.h"
#include "core/app/MyApp.h"

U8G2 display;
PixelUI ui(display);

constexpr uint32_t UI_TIMER_INTERVAL_MS = 1U;

/*
* STM32 HAL 定时器中断回调。
*
* 假设 TIM2 配置为每 1 ms 产生一次更新中断。
*/
extern "C" void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef* htim)
{
if (htim->Instance == TIM2) {
/*
* 只提供时间
*
* 不要在 ISR 中调用:
* ui.process()
* ui.renderer()
* ui.handleInput()
* Widget/Popup/Animation API
*/
ui.tickFromISR(UI_TIMER_INTERVAL_MS);
}
}

int main()
{
HAL_Init();
SystemClock_Config();

MX_GPIO_Init();
MX_SPI1_Init(); // 或 MX_I2C1_Init()
MX_TIM2_Init(); // 配置成每 1 ms 产生更新中断

display.begin(); // 先初始化屏幕
display.setPowerSave(0);

ui.begin();

ViewManager& views = *ui.getViewManagerPtr();

HAL_TIM_Base_Start_IT(&htim2); // 必须在 PixelUI、显示和 App 初始化完成后,才允许定时器中断开始调用 tickFromISR()。

const ViewManager::LaunchResult result = AppLauncher::launch(ui, views); // 启动 AppLauncher 页面

if (result != ViewManager::LaunchResult::Ok) {
Error_Handler();
}

while (true) {

InputEvent event;
while (readInputEvent(event)) {
ui.handleInput(event); // 消费输入事件
}

ui.renderer(); // 调用渲染
}
}

最简单的轮询接入

在裸机或简单单线程系统中,可以在同一个循环中派发输入、尝试绘制,并用类似 millis() 的 API 读取系统时间戳。

1
2
3
4
5
6
7
8
9
10
11
12
uint32_t previous = platform_millis();
for (;;) {
const uint32_t now = platform_millis();
ui.heartbeat(now - previous); // 经过了多少毫秒
previous = now;

InputEvent event; // 输入队列
while (read_input_event(event)) ui.handleInput(event);

ui.renderer();
platform_delay_ms(5);
}

now - previous 必须是真实经过的时间。即使目标是 60 FPS,也不要固定传 16;某次循环实际间隔 35 ms,就传 35 ms。

renderer() 会先调用 process(),再判断是否需要提交一帧。

6. RTOS 和定时器 ISR

在 RTOS / 硬件定时器ISR 中,定时器 ISR 和 UI 逻辑往往不在同一上下文。这时使用 tickFromISR() 代替普通 heartbeat():它只原子累加时间并可选唤醒 UI task。Widget、App、Popup、动画和导航 API 仍全部留在同一个 UI task:

1
2
3
4
5
6
7
8
9
10
11
12
void timer_isr(uint32_t elapsedMs) {
ui.tickFromISR(elapsedMs);
}

void ui_task() {
for (;;) {
wait_for_ui_notification();
dispatch_input_and_data_events();
const uint32_t nextDelay = ui.handler(16U);
program_next_ui_timer(nextDelay);
}
}

setTaskNotifyFromISR() 可安装平台的 ISR-safe 唤醒函数。不要混用 tickFromISR()heartbeat() 注入同一段时间,也不要从 ISR 直接调 handleInput()renderer()

这里 handler(16U)16U 是期望的帧周期,不是已经过去的时间。

Tickless 的 deadline 和外部事件唤醒规则见事件驱动渲染

7. 刷新与唤醒

什么时候刷新?

关于刷新

  • setRefreshCallback():一帧送入 U8g2 buffer 后通知宿主。
  • setRenderRequestCallback():clean 变 dirty 时合并唤醒 UI task;不能在回调中同步重入 renderer()
  • markDirty():业务数据改变后请求下一帧。

8. 下一步

没有启动 App 时空屏是正常的。接下来可以启动 AppLauncher,或构建第一个 App。更完整的调度说明见事件驱动渲染