はじめに
前回は、GuiWidgetを基底にしたオブジェクト階層と、同じ形を持つクラス関数表をC言語で表現しました。
型を作っただけでは、画面は動きません。物理キーを選択中のWidgetへ届け、状態が変わった部品を描き直し、画面を開くたびに必要な初期化を行う流れが必要です。
この記事では、4行LCD向けGUIを次の条件で組み立てます。
- Widgetと画面は静的に確保する
- キー入力はイベントとしてWidgetへ渡す
- Widgetが処理しなかったキーだけをフォーカス移動へ使う
- LCDコントローラをWidgetから直接操作しない
- 状態が変わったWidgetだけを再描画する
公開用に書き起こした例です
本文とサンプルコードは、設計経験を説明するために新しく作成しました。実製品のソースコードや画面仕様を転載したものではありません。
先に結論
mallocを使わないことと、イベント駆動設計は両立します。
画面とWidgetの寿命があらかじめ決まる機器なら、実体と子配列を静的に置き、イベントだけをクラス関数表へ配送できます。これにより、実行中の確保失敗を避けながら、Widgetごとの振る舞いを差し替えられます。
Pageが画面の入口になる
画面はGuiPageで表します。
typedef struct GuiPage {
GuiWidget widget; /* 必ず先頭 */
GuiWidget **children;
uint8_t child_count;
GuiWidget *selected_child;
const GuiDisplayPort *display;
} GuiPage;
childrenは描画対象を列挙する静的配列、selected_childは現在キーを受け取るWidgetです。
配列の順番は描画順です。フォーカスの移動先はGuiItemの方向リンクが決めます。二つを分けることで、画面上の重なり順と操作順を無理に一致させる必要がなくなります。
PageとWidgetのライフサイクル
画面固有の処理はGuiPageClassへ置きます。
typedef struct {
GuiWidgetClass parent_class; /* 必ず先頭 */
void (*before_show)(GuiPage *page);
void (*after_hide)(GuiPage *page);
void (*time_up)(GuiPage *page);
void (*repaint)(GuiPage *page);
} GuiPageClass;
| イベント | タイミング | 主な用途 |
|---|---|---|
before_show |
子Widgetを描く前 | 装置の現在値を読み込み、画面へ反映する |
after_hide |
画面を閉じた後 | 一時状態の破棄、タイマ停止 |
time_up |
設定時間へ到達 | 周期更新、タイムアウト |
repaint |
差分描画の前 | 画面全体に関わる表示値の計算 |
before_showを設けておくと、前回画面を閉じたときの値を盲目的に再利用せず、現在の装置状態から表示を始められます。第5回では、この入口をDeviceControlPortへつなぎます。
イベントは、まず選択中のWidgetへ渡す
キー入力を受けたら、最初にselected_childのkey_downを呼びます。
bool gui_page_dispatch_key_down(GuiPage *page, GuiKey key)
{
GuiItem *next;
uint8_t guard;
if (page->selected_child == NULL) {
return false;
}
if (gui_widget_key_down(page->selected_child, key)) {
gui_page_repaint(page);
return true;
}
next = gui_item_link((GuiItem *)page->selected_child, key);
for (guard = 0; next != NULL && guard < 16u; guard++) {
if (widget_selectable(&next->visual.widget)) {
gui_page_set_focus(page, next);
gui_page_repaint(page);
return true;
}
next = gui_item_link(next, key);
}
return false;
}
戻り値のboolは、「そのWidgetがキーを消費したか」を表します。
たとえば編集中のGuiSpinが上キーを受け取った場合、値を増やしてtrueを返します。Pageはフォーカスを移動しません。通常状態のWidgetが同じキーを処理しなければ、Pageが方向リンクをたどります。
この順番にすると、「同じ上キーが、通常時はフォーカス移動、編集中は値変更」というモード依存の操作を、Page側の巨大な条件分岐にせず表現できます。
KeyDown、KeyPress、KeyUpを分ける
クラス関数表では、キーイベントを三つに分けます。
| イベント | 意味 | 使用例 |
|---|---|---|
key_down |
押した瞬間 | 編集開始、Button実行、最初の1段階変更 |
key_press |
押し続けている間 | リピート、長押し加速 |
key_up |
離した瞬間 | 最終値の確定、長押し状態の解除 |
GPIOの生値やチャタリング除去をWidgetへ入れるわけではありません。キー走査層で物理入力を確定イベントへ変換してから渡します。
この三つを分けたことが、第6回で扱う長押し加速と「離したときに最終値を適用する」処理につながります。
WidgetはLCDコントローラを知らない
Widgetから見える描画先はGuiDisplayPortだけです。
typedef struct {
void (*clear)(void *context);
void (*draw_text)(void *context,
uint8_t x, uint8_t y,
const char *text);
void (*draw_icon)(void *context,
uint8_t x, uint8_t y,
GuiIcon icon);
void (*flush)(void *context);
} GuiDisplayPortOps;
typedef struct {
const GuiDisplayPortOps *ops;
void *context;
} GuiDisplayPort;
contextには、実機ならLCDコントローラの状態、PCなら20文字×4行の文字バッファを渡せます。Widget側は、接続がパラレルかI2Cか、どのLCDコントローラかを知りません。
DIRTYなWidgetだけを描き直す
値やフォーカス状態が変わったWidgetへGUI_FLAG_DIRTYを立てます。
void gui_page_repaint(GuiPage *page)
{
uint8_t i;
for (i = 0; i < page->child_count; i++) {
GuiWidget *child = page->children[i];
if ((child->flags & GUI_FLAG_DIRTY) == 0) {
continue;
}
if ((child->flags & GUI_FLAG_VISIBLE) != 0) {
gui_widget_show(child);
}
child->flags &= (uint8_t)~GUI_FLAG_DIRTY;
}
}
4行LCDでも、毎回全画面を書き直す必要はありません。転送量を減らすだけでなく、旧表示と新表示が混ざって見える時間を減らす効果もあります。
ただし、"115200"から"9600"へ変えたときは、末尾の古い文字が残らないようWidget幅まで空白で埋めて描画します。
mallocなしで画面を構築する
Widgetの実体と子配列を静的に確保します。
static GuiLabel title;
static GuiCombo rate;
static GuiButton apply;
static GuiSpin level;
static GuiWidget *settings_children[] = {
(GuiWidget *)&title,
(GuiWidget *)&rate,
(GuiWidget *)&apply,
(GuiWidget *)&level
};
初期化時に座標、表示文字列、クラス関数表を設定し、gui_page_init()へ配列を渡します。
gui_label_init(&title, 0, 0, 20, "Device Settings");
gui_combo_init(&rate, 0, 1, 10, "Rate", RATE_ITEMS, 3, 1);
gui_button_init(&apply, 10, 1, 10, "Apply", on_apply, &settings);
gui_spin_init(&level, 0, 2, 10, "Lvl", 0, 9, 1, 5);
gui_page_init(&settings.page, &SETTINGS_PAGE_CLASS,
settings_children, 4, &display);
gui_page_set_focus(&settings.page, &rate.named.item);
動的確保を使わないため、必要な画面数とWidget数からRAMを見積もれます。Widgetの寿命も画面またはアプリケーションと同じなので、所有権管理を単純にできます。
これは公開用Cサンプルの方針です。当時の実装すべてが動的確保なしだった、という意味ではありません。
実行時に画面を増減させる必要があるなら、固定長プールを使う方法もあります。重要なのは「組み込みだから必ずmalloc禁止」と決めることではなく、寿命と最大数が固定できるかで判断することです。
PC上のモックへ描画する
公開用サンプルでは、GuiDisplayPortを文字バッファへ差し替えます。
+--------------------+
|Device Settings |
|>Rate:115k [Apply] |
| Lvl:5 Port:P1 |
| |
+--------------------+
>は選択中、*は編集中、#は操作不可を表します。キーイベント列を投入すれば、実機LCDがなくてもフォーカス移動、編集、確定、取消を試験できます。
実装時に決めておくこと
割り込みから直接描画しない
キー割り込みではイベントを通知するだけにし、Widget更新とLCD転送はメインループまたはGUIタスクで行います。I2C/SPI転送や画面コールバックを割り込みコンテキストへ持ち込まないためです。
イベント中にリンクを書き換えない
小規模版では、「イベント配送中はWidgetの方向リンクや子配列を変更しない」と制約した方が安全です。必要なら、イベント処理後に変更要求を反映します。
実サイズはターゲットで測る
PC上の64bitビルドと32bit MCUではポインタサイズが違います。sizeof、Mapファイル、スタック使用量はターゲット用コンパイラで確認します。
まとめ
-
GuiPageが子Widgetと選択中Widgetを管理する - キーは選択中Widgetへ先に渡し、未処理ならフォーカス移動へ使う
-
KeyDown/KeyPress/KeyUpを分ける - Widgetは
GuiDisplayPortだけを通して描画する -
DIRTYなWidgetだけを再描画する - 画面数と寿命が固定できるなら、静的構築で十分にイベント駆動GUIを作れる
次回は、このイベント配送へ物理ボタンの配置を重ねます。一列のnext/prevではなく、上下左右を持つフォーカスグラフとして画面を設計します。
関連リンク(4行LCDから考える、組み込み機器のUI設計)
- 【4行LCD GUI設計・第1回】4行LCDでもUI設計は必要だった
- 【4行LCD GUI設計・第2回】C言語でWidgetと継承風設計を作る
- 【4行LCD GUI設計・第3回】mallocなしでイベント駆動GUIを組み立てる(この記事)
- 【4行LCD GUI設計・第4回】物理ボタンから4行LCDのUIを設計する
- 【4行LCD GUI設計・第5回】UIイベントと専用ハードウェアを分離する
- 【4行LCD GUI設計・第6回】リアルタイムではない装置を、リアルタイムに感じさせる ― 4行LCDの操作感を実機で詰める


