はじめに
以前、4行のキャラクタLCDを搭載した組み込み機器向けに、オリジナルのGUIライブラリを設計したことがあります。
表示できるのは、たった4行です。それでも画面が増えるにつれて、座標へ文字列を直接書く実装では、フォーカス移動、キー入力、選択状態、再描画の処理が画面ごとに散らばっていきました。
ここでいう Widget(ウィジェット) とは、ラベル、ボタン、選択項目などを、表示内容、状態、入力時の振る舞いとひとまとまりにして扱うUI部品です。このライブラリでは、4行LCD上の表示単位や操作単位をWidgetとして表します。
設計の原型は、先輩エンジニアから受け継いだGTK風のWidget、イベント、継承という考え方です。ただし、GTKそのものを移植したわけではありません。動的な型システム、シグナル、レイアウトマネージャ、CSSなどは持ち込まず、4行LCDに必要な芯だけをC言語で再構成しました。
この記事では、当時の経験をもとに設計を再構成し、次の仕組みを実装例として示します。
- 共通の基底
GuiWidgetと、そこから派生するWidget階層 - オブジェクト側とクラス側の、二重の先頭メンバ埋め込み
- クラス関数表(
GuiWidgetClass系)による仮想関数風ディスパッチ - 画面を表す
GuiPageと、子Widgetの静的配列 - 基底へ載せるメンバと、派生型へ残すメンバの判断
公開用に書き起こした例です
本文とサンプルコードは、設計経験を説明するために新しく作成しました。製品で使用したソースコードや画面仕様を転載したものではありません。
この記事は6本シリーズの2本目です。前回示した全体像から、Widget階層、クラス関数表、C言語の継承風設計を取り出して確認します。イベント配送と静的な画面構築は次回に分けます。
先に結論
4行LCDでも、画面を「文字列をどこへ書くか」ではなく、Widgetの集合とイベントの流れとして扱うと、画面追加時の変更範囲を小さくできます。
一方、GTKの機能をそのまま再現しようとすると、小さな組み込み機器には過剰です。今回GTKから借りたのは、次の5点だけです。
- 共通の基底Widgetを持つ
- そこから派生型をつくる
- クラス関数表による仮想関数風ディスパッチを使う
- PageとWidgetにライフサイクルを持たせる
- Widget固有イベントで振る舞いを差し替える
GTKやGObjectとのAPI互換性はありません。移植でもありません。
シリーズ共通の型名
本シリーズの各記事とサンプルコードで、同じ名前を使います。
| 型・概念 | 名前 | 役割 |
|---|---|---|
| 基底Widget | GuiWidget |
すべての部品が先頭メンバとして埋め込む共通構造体 |
| 基底クラス関数表 | GuiWidgetClass |
show/hide/キーイベントを集めた、ROM上の共有テーブル |
| 画面 | GuiPage |
子Widgetの静的配列とselected_childを持つ単位 |
| 場所を持つWidget | GuiVisual |
座標とサイズ(GuiAllocation)を持つ派生型 |
| 表示専用Widget | GuiLabel |
選択対象にならない表示部品 |
| 選択対象Widget | GuiItem |
選択でき、方向リンクを持つ派生型 |
| 表示の抽象化 | GuiDisplayPort |
文字・アイコンをLCDへ出す境界(第3回で扱う) |
| 装置制御の抽象化 | DeviceControlPort |
UIから見た装置機能の境界(第5回で導入する) |
Widgetの階層
まず全体像です。GuiWidgetを頂点に、次の階層をつくります。
最初に、画面やWidgetごとにRAMへ置くインスタンス側です。
同じ継承形状を、constのクラス関数表側にも持たせます。
分岐の意味は次のとおりです。
-
GuiPageは画面そのもの。場所を持たず、子Widgetをまとめる -
GuiVisualは画面上に場所を持つWidget。座標とサイズはここから下だけが持つ -
GuiLabelとGuiItemの違いは、選択対象になるかどうか。Labelは表示するだけで、キー入力もフォーカスも受け取らない -
GuiNamedItemは「名前:値」の形で表示する編集系の共通部。GuiComboとGuiSpinが名前の表示処理を共有する
座標をGuiWidgetではなくGuiVisualへ置いたのが、素朴な設計との一番の違いです。GuiPageは画面全体を担当するので、x座標もwidthも持ちません。基底へ全部載せると、使わないメンバのRAMを全Widgetが払うことになります。
二重の先頭埋め込み
オブジェクト側
派生型は、先頭メンバに親型を実体で持ちます。
typedef struct {
uint8_t x;
uint8_t y;
uint8_t width;
uint8_t height;
} GuiAllocation;
typedef struct {
GuiWidget widget; /* 必ず先頭 */
GuiAllocation allocation;
} GuiVisual;
typedef struct {
GuiVisual visual; /* 必ず先頭 */
GuiItemLinks links;
} GuiItem;
struct GuiButton {
GuiItem item; /* 必ず先頭 */
const char *label;
void (*on_execute)(GuiButton *button, void *user_data);
void *user_data;
};
先頭にあるので、GuiButton *はそのままGuiItem *にもGuiVisual *にもGuiWidget *にもキャストできます。
クラス側
ここが、単なる構造体埋め込みとの分かれ目です。クラス関数表の側も、同じかたちで先頭埋め込みします。
typedef struct GuiWidgetClass {
void (*show)(GuiWidget *widget);
void (*hide)(GuiWidget *widget);
bool (*key_down)(GuiWidget *widget, GuiKey key);
bool (*key_press)(GuiWidget *widget, GuiKey key);
bool (*key_up)(GuiWidget *widget, GuiKey key);
} GuiWidgetClass;
typedef struct {
GuiWidgetClass parent_class; /* 必ず先頭 */
} GuiVisualClass;
typedef struct {
GuiVisualClass parent_class; /* 必ず先頭 */
void (*select)(GuiItem *item);
void (*deselect)(GuiItem *item);
void (*focus_in)(GuiItem *item);
void (*focus_out)(GuiItem *item);
} GuiItemClass;
typedef struct {
GuiItemClass parent_class; /* 必ず先頭 */
void (*execute)(GuiButton *button);
} GuiButtonClass;
これで何が嬉しいのか。GuiWidgetが持つのはconst GuiWidgetClass *klassの1本だけですが、実体はGuiButtonClassを指しています。基底のディスパッチャはklass->showを先頭から読めばよく、Button固有のexecuteが必要な場所だけ(const GuiButtonClass *)widget->klassへ戻します。
static bool button_key_down(GuiWidget *widget, GuiKey key)
{
GuiButton *button = (GuiButton *)widget;
const GuiButtonClass *klass = (const GuiButtonClass *)widget->klass;
if (key != GUI_KEY_ENTER) {
return false; /* 消費しない。Pageがフォーカス移動へ回す */
}
if (klass->execute != NULL) {
klass->execute(button);
}
return true;
}
オブジェクト側だけを埋め込んで、クラス側を1枚のGuiWidgetOpsに平らへ潰すこともできます。ただしそうすると、executeやfocus_inのような派生型にしか存在しない関数を、全Widgetの共通表へ載せることになります。階層を保ったまま関数表を伸ばせるのが、二重埋め込みの利点です。
前提はコンパイル時に固定する
先頭埋め込みが崩れた瞬間にキャストが壊れるので、C11の_Static_assertで押さえます。
_Static_assert(offsetof(GuiButton, item) == 0, "GuiButton head");
_Static_assert(offsetof(GuiButtonClass, parent_class) == 0, "ButtonClass head");
サンプルでは、オブジェクト側8本・クラス側8本の計16本を並べています。誰かがメンバの順番を入れ替えたら、実行する前にビルドが止まります。
GuiWidgetの構造
基底に置くのは、これだけです。
klassはclassの誤記ではありません。GTK/GObject系のCコードでは、クラス構造体を受け取る変数名としてklassが使われます。本稿でもこの慣例にならい、Widgetが共有するクラス関数表へのポインタをklassと呼びます。
struct GuiWidget {
const GuiWidgetClass *klass; /* ROM上の共有クラス関数表 */
GuiWidget *parent; /* 所有元へ戻る片方向参照。最上位はNULL */
uint8_t type; /* GuiWidgetType の値 */
uint8_t flags;
};
4つのメンバの役割は次のとおりです。
| メンバ | 役割 |
|---|---|
klass |
クラス関数表を指す |
parent |
所属するPageまたは親Widgetを指す |
type |
デバッグ時の型検査、ログ、キャスト前確認に使う |
flags |
表示、操作可能、フォーカス、編集中、再描画要求 |
parentは、汎用的な子リストを作るためのものではありません。この設計では、所有する子の一覧はGuiPage.childrenが管理します。parentはその逆向き、子から所有元へ戻るための片方向参照です。親から子をたどるときは配列を使い、子から親をたどるときだけparentを使います。両方向のリンクリストを全Widgetへ張るのとは、必要なRAMが違います。
typeは、Widgetの種類を表します。
typedef enum {
GUI_WIDGET_PAGE = 0,
GUI_WIDGET_LABEL,
GUI_WIDGET_BUTTON,
GUI_WIDGET_COMBO,
GUI_WIDGET_SPIN
} GuiWidgetType;
列挙型そのものの実サイズは処理系依存です。RAMを強く意識する場面では、格納領域をuint8_tに固定し、値の一覧だけをenumで定義する形が扱いやすくなります。サンプル実装もこの形にしてあり、typeとflagsがuint8_t2個で並ぶため、パディングを増やさずに済みます。
typeがあると、GuiWidget *から派生型へキャストする前に、それが本当にその型かを確認できます。サンプルでは次のヘルパを用意し、フォーカス設定のようにItem派生を前提とする境界でassertしています。リリースビルドではassertが消えるので、実行時コストは開発中だけです。
bool gui_widget_is_item(const GuiWidget *widget);
表示、操作可能、選択可能、選択中、編集中、再描画要求は、個別のboolではなくビットフラグへまとめます。
enum {
GUI_FLAG_VISIBLE = 1u << 0,
GUI_FLAG_SENSITIVE = 1u << 1,
GUI_FLAG_FOCUSABLE = 1u << 2,
GUI_FLAG_DIRTY = 1u << 3,
GUI_FLAG_FOCUSED = 1u << 4,
GUI_FLAG_EDITING = 1u << 5
};
関数ポインタをインスタンスへ持たせない
最初に思いつきやすいのは、show()やkey_down()をGuiWidgetへ直接5本持たせる形です。しかし32bit環境では、関数ポインタ5本だけでWidgetごとに20byteを使います。同じ種類のButtonは、全インスタンスが同じ関数を使うため、毎回保持する必要はありません。
クラス関数表をconstで1つ置き、Widget側にはそれを指すポインタを1本だけ持たせます。constなので、一般的なマイコン環境では読み出し専用領域へ配置できます。32bitポインタを前提にすれば、イベント関連のインスタンスRAMは20byteから4byteになります。
ただし構造体の実サイズはABI、アラインメント、ポインタ幅で変わります。最終的にはターゲット用コンパイラでsizeofとMapファイルを確認します。
GTKとの共通点と相違点
| 観点 | GTK/GObject | 今回の4行LCDライブラリ |
|---|---|---|
| 基底Widget | GtkWidget |
GuiWidget |
| 継承 | GType/GObjectの型システム | 先頭メンバへの構造体埋め込み |
| クラス構造体 |
GtkWidgetClassなどの階層 |
GuiWidgetClass系の階層(同じく先頭埋め込み) |
| 仮想関数 | クラス構造体の関数ポインタ |
const GuiWidgetClass系の関数ポインタ |
| イベント | イベントコントローラ、シグナル等 | クラス関数表への直接ディスパッチ |
| Widget階層 | 一般的な親子・兄弟構造 | Pageが持つ子Widgetの静的配列 |
| 移動順 | フォーカスチェイン |
GuiItemの方向別リンク |
| レイアウト | レイアウトマネージャ、サイズ要求 | 文字セル単位の固定座標 |
| 描画 | 描画システム、スタイル、テーマ |
GuiDisplayPortへ文字とアイコンを渡す |
| メモリ | 動的なオブジェクト生成と管理 | 静的確保、または固定長プール |
| 対象 | 汎用デスクトップGUI | 小型組み込み機器の4行LCD |
GTKからコードやAPIを持ち込んだのではなく、共通型へそろえ、Widget固有の処理をクラス関数表として差し替える設計を参考にしています。
小容量RAM向けに削ったもの
自動レイアウトを持たない
4行LCDでは、文字セル単位の固定座標で十分でした。最小サイズ、自然サイズ、伸縮、余白計算などは実装していません。
シグナル機構を持たない
購読者を複数登録できる汎用シグナルは便利ですが、コールバックリストと接続管理が必要になります。Buttonには実行コールバックを1本だけ持たせました。
参照カウントを持たない
Widgetの寿命は画面またはアプリケーションの寿命と同じです。静的確保なら、所有権を実行時に追跡する必要はありません。
文字列を複製しない
ラベルや選択肢はconst char *で参照します。ROM上の固定文字列を使う前提です。可変文字列を渡す場合は、呼び出し側がバッファ寿命を保証します。
汎用コンテナを増やさない
GuiPageの下は1段だけです。画面要件に存在しない柔軟性は実装しませんでした。
実装時にハマりやすい点
1. 先頭埋め込みを崩さない
オブジェクト側とクラス側の両方で、親型が必ず先頭にある必要があります。_Static_assertを全型分書いておけば、順番を入れ替えた瞬間にビルドが止まります。
2. 座標を基底へ上げない
「あとで便利そうだから」とGuiWidgetへ座標を戻すと、GuiPageも座標を持つことになります。使わないメンバのRAMを全インスタンスが払う形は、この規模では効いてきます。
3. 文字列の寿命を明確にする
Widgetが受け取ったconst char *を保持するなら、関数内のローカル配列を渡してはいけません。固定文字列、静的バッファ、画面所有バッファのどれかに限定します。
4. 再描画前に古い文字を消す
"115200"から"9600"へ変わると、末尾に古い文字が残ることがあります。サンプルではWidget幅まで空白で埋めた文字列をLCDへ渡します。
5. イベント処理とキーのデバウンスを分ける
WidgetがGPIOの生値を読む設計にすると、すべてのWidgetへチャタリング対策が混入します。キー入力層で確定イベントへ変換してから渡します。
6. コールバック中のリンク変更を決めておく
コールバック中にWidgetを非表示化したり、リンクを張り替えたりできる設計では、走査中ポインタが無効になる危険があります。小規模版では「イベント処理中はリンクを変更しない」と制約するのが安全です。
7. 割り込みから直接描画しない
キー割り込みではイベントをキューへ入れるだけにし、Widget更新とLCD転送はメインループまたはGUIタスクで行います。I2C/SPI転送やコールバックを割り込みコンテキストへ持ち込まないためです。
8. 実サイズはターゲットで測る
PC上の64bitビルドと、32bit MCUではポインタサイズが違います。sizeof、Mapファイル、スタック使用量をターゲットビルドで確認します。
新しいWidgetを追加する手順
たとえばGuiSpinのような数値入力Widgetを、ゼロから足すなら次の順です。
- どの型から派生させるかを決める(値と名前を持つなら
GuiNamedItem) - 先頭に親型を置いた構造体を定義する
- 先頭に親クラスを置いたクラス構造体を定義し、固有の関数を足す
-
static const GuiSpinClass GUI_SPIN_CLASS = { ... }を定義する -
_Static_assertをオブジェクト側とクラス側の両方へ足す - 初期化関数で座標、フラグ、
klassを設定する - 画面の子配列へ追加し、方向リンクをつなぐ
既存のイベントディスパッチャやPageへ、GuiSpin専用の分岐を追加する必要はありません。ここが継承風設計の効果です。
まとめ
4行LCD向けのGUIライブラリで大切だったのは、Widgetの種類を増やすことではありませんでした。
- 共通部分を
GuiWidgetへ集め、それ以外は派生型へ置く - オブジェクト側とクラス側の両方で、先頭埋め込みをそろえる
- Widget固有の動作をクラス関数表へ置く
- 画面は
GuiPageが子配列と選択中Widgetで管理する
GTKから借りたのは、見た目でもAPIでもなく、異なる画面部品を共通のWidgetとして扱い、クラス関数表によって振る舞いを差し替えるという考え方です。
組み込み向けの設計では、元のフレームワークをどこまで再現するかより、何を削れば製品要件にちょうどよくなるかを決める方が難しい。4行しかないLCDは、その判断がよく見える題材でした。
次回は、ここで作った型を静的に配置し、キーイベントをクラス関数表へ配送します。Widgetが処理しなかったキーだけをフォーカス移動へ回し、DIRTYな部品だけを描画する流れを扱います。
参考資料
- GtkWidget — GTK 4公式ドキュメント
- GObject Type System Concepts — GLib公式ドキュメント
- GObject Tutorial — Virtual Functions — GLib公式ドキュメント



