0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【4行LCD GUI設計・第5回】UIイベントと専用ハードウェアを分離する ― 小型LCD機器の「なんちゃってMVC」

0
Last updated at Posted at 2026-08-04

はじめに

小型LCDを持つ組み込み機器では、画面のイベントハンドラからASICやFPGAのレジスタ操作を呼びたくなる場面がよくあります。

たとえば、Spinの値が変わったらレジスタへ書く、Buttonが押されたら設定を保存する、といった実装です。最初の1機種では短く書けますが、LCD、制御デバイス、通信方式が変わる機種展開では、画面コードまでハードウェア差分へ巻き込まれます。

私が実務で小型LCD向けUIを作ったときは、Widgetとイベントを共通化するだけでなく、LCD描画と装置機能の制御経路を分けました。

この記事では、その経験を一般化し、View、イベントハンドラ、機能サービス、ハードウェアアダプタへ分ける構成を示します。UI、ハードウェア制御、LCD描画の速度差と実機調整は、次回に独立して扱います。

この記事でいう「なんちゃってMVC」の範囲

特定のMVCフレームワークを移植したという意味ではありません。小さな組み込み環境で責務を分けた結果、MVCに似た境界になった、という程度の呼び方です。

本文とコードは、設計経験を説明するために新しく作成しました。実製品のレジスタ、通信コマンド、画面名、ソースコードは使用していません。

この記事は6本シリーズの5本目です。第2回・第3回でWidgetとイベントの土台を、第4回で物理キーを含めたUI設計を扱いました。型名はシリーズを通して共通で、第3回で置いたGuiDisplayPortが、ここで交換対象として本領を発揮します。

この記事でわかること

  • UIイベントからレジスタを直接操作する問題
  • 1画面の中を「小さなMVC」として整理する方法
  • ハードウェア境界を、Ports and Adaptersに近い形で置く方法
  • GuiComboClassGuiSpinClassのChange/Submit/Cancelを画面側へつなぐ書き方
  • LCDやASIC/FPGAが変わる機種展開への備え方
  • 実機なしでUIロジックを試験するための境界

先に結論

Widgetのイベントハンドラが扱うのは、レジスタ値ではなく、「通信速度を1段階上げる」「現在の編集を確定する」といった装置機能の言葉にします。

UIとハードウェアの間に小さなインターフェースを置けば、次の2つを別々に交換できます。

  • LCDが変わる: GuiDisplayPortを交換する
  • ASIC/FPGAや制御方法が変わる: DeviceControlPortを交換する

2つの整理を混ぜない

先に用語を整理します。この記事では、性質の違う2つの整理を同時に行います。混ぜて説明されがちなので、分けて扱います。

小さなMVC Ports and Adaptersに近い境界
対象 1つの画面の内側 UIライブラリとハードウェアの間
目的 表示・操作・値の責務を分ける 実装を差し替え可能にする
現れ方 GuiPage派生の構造体メンバ GuiDisplayPortDeviceControlPort
交換単位 交換しない(画面ごとに書く) 機種ごと、テストごとに交換する

MVCは画面の中の整理、Ports and Adaptersは外との境界の置き方で、同じ概念ではありません。以降、前半で画面の中を、後半で外との境界を扱います。

4行LCD向けGUI_⑨UIと専用ハードウェアの責務分離_icon.png

レジスタへ直結したイベントハンドラ

最初は、次のようなコードでも動きます。

static void on_level_changed(GuiSpin *spin, int16_t candidate, void *user_data)
{
    uint32_t reg = fpga_read(REG_SIGNAL_CONFIG);

    (void)spin;
    (void)user_data;
    reg &= ~LEVEL_MASK;
    reg |= encode_level(candidate);
    fpga_write(REG_SIGNAL_CONFIG, reg);
}

しかし、この関数は多くの事情を知りすぎています。

  • UI上の値
  • レジスタのアドレス
  • ビット配置
  • 値のエンコード方法
  • 読み出し変更書き戻しの手順
  • FPGAが直接接続されているという構成

次機種で制御先が別のデバイスや別プロセスになれば、画面コードを書き換えることになります。

2本の経路を分ける

小型LCD機器には、大きく2本の経路があります。

表示の経路

Widgetの状態を、文字、カーソル、外字へ変換し、LCDへ描画する経路です。

Widget → GuiWidgetClassのshow → GuiDisplayPort → LCD Adapter → LCD

機能制御の経路

UI操作の意味を、装置の状態変更へ変換する経路です。

Widget → GuiComboClass/GuiSpinClassのchange・submit・cancel → 画面側ハンドラ → DeviceControlPort → Hardware Adapter → ASIC/FPGA

同じキー入力から始まっても、LCDへ何を描くかと、装置へ何を指示するかは別の責務です。2本の線は途中で交わりません。

画面の中を小さなMVCとして見る

1つの画面の内側を、次のように割り当てます。

役割 この設計での担当 実体
View 画面部品と描画 GuiLabelGuiButtonGuiComboGuiSpin
Controller相当 Widgetイベントを装置機能へ翻訳する on_changeon_submiton_cancelハンドラ
Model相当 確定値、編集中の値、編集開始時の値 GuiComboGuiSpinvalueeditingoriginal

厳密なMVCへ合わせる必要はありません。境界を置く目的は、変更理由の異なるコードを分けることです。

注目したいのは、Model相当をわざわざ別の構造体にしていない点です。第2回のGuiComboGuiSpinは、最初から3つの値を持っています。

/* 第2回で定義済み */
struct GuiSpin {
    GuiNamedItem named; /* 必ず先頭 */
    int16_t value;      /* 確定値 */
    int16_t editing;    /* 編集中の候補 */
    int16_t original;   /* 編集開始時のスナップショット */
    int16_t min;
    int16_t max;
    int16_t step;
    void (*on_change)(GuiSpin *spin, int16_t candidate, void *user_data);
    void (*on_submit)(GuiSpin *spin, int16_t value, void *user_data);
    void (*on_cancel)(GuiSpin *spin, int16_t original, void *user_data);
    void *user_data;
};

小さな画面では、Modelを別の層へ切り出すより、Widgetが自分の3値を持つ方が読みやすく、RAMも増えません。「なんちゃって」と呼んでいるのは、この割り切りのことです。

Change、Submit、Cancelを分ける

値を変更するWidgetでは、「画面上の値が変わった」と「設定を確定した」は同じではありません。

イベント UI上の意味 装置側の意味
Change 候補値を動かした 必要なら一時的にプレビュー
Submit Enterで確定した 確定値として反映、必要なら保存
Cancel 編集を取り消した 編集開始前の値へ復元

たとえば映像や音声に関する値は、操作中に結果を見聞きできる方が調整しやすいことがあります。その場合はChangeで一時反映します。

一方、ネットワーク設定や再起動を伴う値は、Changeでは画面内だけを更新し、Submitで初めて装置へ反映する方が安全です。

この3つは、第2回のクラス関数表に最初から並んでいます。

/* 第2回で定義済み */
typedef struct {
    GuiNamedItemClass parent_class; /* 必ず先頭 */
    void (*change)(GuiSpin *spin, int16_t candidate);
    void (*submit)(GuiSpin *spin);
    void (*cancel)(GuiSpin *spin);
} GuiSpinClass;

GuiComboClassも同じ形で、changeuint8_t candidateを受け取ります。

クラス側のchangesubmitcancelは、Widget共通の処理(editingの更新、GUI_FLAG_DIRTYの設定、originalの巻き戻し)を行い、最後に画面側のon_changeon_submiton_cancelを呼びます。この2段構えが、Viewと画面ハンドラの分かれ目です。

上下キーからハンドラまで

編集中のSpinで上キーを押したとき、呼び出しは次の順にたどります。

  1. gui_page_dispatch_key_down()が、選択中のWidgetへkey_downを渡す
  2. GuiSpinClassが継承したkey_downが、編集中なのでGUI_KEY_UPを消費する
  3. klass->change(spin, spin->editing + spin->step)を呼ぶ
  4. changeの実装がeditingを範囲内へ丸め、GUI_FLAG_DIRTYを立てる
  5. spin->on_change(spin, spin->editing, spin->user_data)を呼ぶ
  6. 画面側のハンドラがDeviceControlPortのプレビューを呼ぶ
  7. 制御が戻り、gui_page_repaint()がDIRTYなWidgetだけを描き直す

キーが消費されたので、フォーカスは動きません(第3回のディスパッチャの分岐です)。

UIから見えるハードウェア境界

ここからが後半、Ports and Adaptersに近い部分です。

公開用の例では、UIが使う装置機能を次のように定義します。GuiDisplayPortと同じ「関数表+context」の形にそろえます。

typedef struct {
    bool (*load_rate)(void *context, uint8_t *value);
    bool (*preview_rate)(void *context, uint8_t value);
    bool (*commit_rate)(void *context, uint8_t value);
    bool (*restore_rate)(void *context, uint8_t value);
} DeviceControlPortOps;

typedef struct {
    const DeviceControlPortOps *ops;
    void *context;
} DeviceControlPort;

UIは、対象がメモリマップドI/Oなのか、別プロセスへのメッセージなのかを知りません。

contextを渡す形にしておくと、グローバル変数へ固定せず、実機アダプタとテスト用モックを同じ関数形で扱えます。

イベントハンドラは翻訳に徹する

画面側のハンドラは、Widgetイベントを装置機能へ翻訳します。シグネチャは第2回のGuiComboGuiSpinのメンバどおりです。

typedef struct {
    GuiPage page;   /* 必ず先頭 */
    GuiLabel title;
    GuiCombo rate;
    GuiSpin  level;
    GuiButton apply;
    DeviceControlPort device;
    bool allow_live_preview;
    bool preview_applied;
} SettingsPage;

static void rate_on_change(GuiCombo *combo, uint8_t candidate, void *user_data)
{
    SettingsPage *sp = (SettingsPage *)user_data;

    (void)combo;
    if (!sp->allow_live_preview) {
        return;
    }
    if (sp->device.ops->preview_rate(sp->device.context, candidate)) {
        sp->preview_applied = true;
    }
}

static void rate_on_submit(GuiCombo *combo, uint8_t value, void *user_data)
{
    SettingsPage *sp = (SettingsPage *)user_data;

    (void)combo;
    (void)sp->device.ops->commit_rate(sp->device.context, value);
    sp->preview_applied = false;
}

static void rate_on_cancel(GuiCombo *combo, uint8_t original, void *user_data)
{
    SettingsPage *sp = (SettingsPage *)user_data;

    (void)combo;
    if (sp->preview_applied) {
        (void)sp->device.ops->restore_rate(sp->device.context, original);
        sp->preview_applied = false;
    }
}

ここにはレジスタのビット位置も、IPCのメッセージ番号もありません。user_dataに画面自身を渡しているので、グローバル変数も要りません。

Cancelで戻す範囲を決める

取消処理で戻す対象は、画面表示だけとは限りません。

  • Widgetの表示値(editingoriginalへ戻す。これはクラス側のcancelが行う)
  • 一時反映したハードウェア状態(画面側のon_cancelが行う)
  • 複数項目の組み合わせ

ハードウェアへプレビュー反映した後に画面値だけ戻すと、表示と実機が食い違います。

反対に、まだハードウェアへ反映していない値に対して復元I/Oを行うのも無駄です。preview_appliedのような状態を持ち、必要な場合だけ復元します。

Pageの表示前イベントで状態を読み込む

画面を開くたびに、前回表示時の値をそのまま使えるとは限りません。別の操作経路や装置側の状態変化があるためです。

第3回で用意したGuiPageClass.before_showからDeviceControlPortを呼び、最初の描画前に現在値を読み込みます。

static void settings_before_show(GuiPage *page)
{
    SettingsPage *sp = (SettingsPage *)page;
    uint8_t current;

    if (sp->device.ops->load_rate(sp->device.context, &current)) {
        sp->rate.value    = current;
        sp->rate.editing  = current;
        sp->rate.original = current;
    }
    sp->preview_applied = false;
}

valueeditingoriginalを同じ値へそろえてから描画することで、表示、編集候補、取消時の戻り先が同じ時点から始まります。

画面固有のGuiPageClassstatic constで定義し、before_showだけを差し替えます。クラス関数表をROMへ置くための初期化子は、公開用サンプルのGUI_PAGE_WIDGET_CLASS_INITIALIZERへまとめています。第2回で示した二重の先頭埋め込みにより、GuiPage *から画面固有のSettingsPage *へ戻せます。

ハードウェアアダプタへ閉じ込めるもの

次の情報は、UI側へ漏らさずアダプタ内部へ閉じ込めます。

  • レジスタアドレスとビット配置
  • エンディアン
  • 読み出し変更書き戻し
  • デバイスファイル
  • ioctlやIPCメッセージ
  • タイムアウトとリトライ
  • 機種別の値変換
  • 排他制御

UIへ返すエラーも、必要に応じて「読込失敗」「範囲外」「装置応答なし」程度の論理エラーへ変換します。

LCDが変わる場合

LCDの差はGuiDisplayPortの下へ閉じ込めます。第3回と同じ定義です。

/* 第3回で定義済み。シリーズ共通。 */
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;

キャラクタLCDから小型グラフィックLCDへ変わる場合、文字セル座標を完全に共通化できるとは限りません。それでもWidgetイベント、編集モデル、装置制御ポートをそのまま残せれば、変更範囲を表示側へ寄せられます。

ASICやFPGAが変わる場合

機種AはメモリマップドI/O、機種Bは別CPUへのメッセージで制御する、とします。

両方が同じDeviceControlPortOpsを実装すれば、画面側は共通です。

4行LCD向けGUI_⑩機種展開とアダプタ差し替え_icon.png

完全に無変更で機種展開できるとは限りません。装置機能そのものが変われば、画面とインターフェースも変わります。

狙いは差分をゼロにすることではなく、差分がどの層に属するかを明確にすることです。

モックでUIを先に試験する

2つのポートを差し替えられると、PC上で次の確認ができます。

  • GuiDisplayPortを20文字×4行のテキストバッファへ描画する
  • DeviceControlPortをメモリ上のモックへ差し替える
  • キーイベント列を順番に投入する
  • Changeでプレビューが呼ばれたか確認する
  • Submitで確定値が残るか確認する
  • Cancelで元の値とハードウェア状態へ戻るか確認する

実機I/Oが必要な試験は残りますが、フォーカス移動や編集状態のバグを、実機完成前に見つけやすくなります。

シリーズの公開用サンプルは、まさにこの構成でPC上のgccだけを使って動きます。画面出力はテキストで、>がカーソル、*が編集中、#が操作不可を表します。

+--------------------+
|Device Settings     |
|>Rate:115k [Apply]  |
| Lvl:5     Port:P1  |
|                    |
+--------------------+

分けすぎないための基準

小さな機器へWebアプリと同じ数の層を持ち込む必要はありません。

次のどれかに当てはまる境界だけを置きます。

  • 機種によって実装が変わる
  • 実機なしで試験したい
  • 変更と確定を分ける必要がある
  • 画面とハードウェアで担当者や変更理由が異なる
  • 1つの機能を複数の操作経路から使う

1画面、1機種、使い捨ての評価コードなら、直接呼び出しの方が読みやすい場合もあります。

メリット

  • LCD変更と制御デバイス変更を別々に扱える
  • UIコードへレジスタ定義が広がらない
  • Change/Submit/Cancelの意味が揃う
  • 実機なしで画面ロジックを試験しやすい
  • 別の操作経路から同じ装置機能を再利用できる
  • 機種差分をアダプタへ集めやすい

デメリット

  • 小規模な機器では関数ポインタと型が増える
  • どこまでを装置機能として抽象化するか判断が必要になる
  • 非同期処理では完了通知とエラー表示の設計が増える
  • アダプタが単なる巨大ラッパーになると効果が薄い
  • ハードウェア差が大きすぎる場合、共通インターフェースが不自然になる

まとめ

小型LCD向けUIライブラリの役割は、画面部品を再利用することだけではありません。

  • 画面の中は、View(Widget)、Controller相当(ハンドラ)、Model相当(valueeditingoriginal)の小さなMVCとして見る
  • 外との境界は、GuiDisplayPortDeviceControlPortという交換可能なポートとして置く
  • UIイベントを装置機能の言葉へ翻訳する
  • レジスタやIPCをHardware Adapterへ閉じ込める
  • Change、Submit、Cancelで一時変更と確定を分ける
  • before_showで装置の現在状態を読み込む

特定ハードウェアと密になりやすい組み込み機器でも、UIと制御の間へ小さな境界を置くことで、機種展開時の変更を局所化できます。

私はこれを厳密なMVCとは呼びません。ただ、Viewと装置制御を直結させず、イベントハンドラを翻訳役にした設計は、小型機器でも十分に効果がありました。

次回は、UI、専用ハードウェア、LCDの速度差をどう操作感として整えたかを扱います。長押し加速、キーを離したときの確定、LCD画面切り替えの間合いを、実機で調整した経験です。

参考資料

関連リンク(4行LCDから考える、組み込み機器のUI設計)

  1. 【4行LCD GUI設計・第1回】4行LCDでもUI設計は必要だった
  2. 【4行LCD GUI設計・第2回】C言語でWidgetと継承風設計を作る
  3. 【4行LCD GUI設計・第3回】mallocなしでイベント駆動GUIを組み立てる
  4. 【4行LCD GUI設計・第4回】物理ボタンから4行LCDのUIを設計する
  5. 【4行LCD GUI設計・第5回】UIイベントと専用ハードウェアを分離する(この記事)
  6. 【4行LCD GUI設計・第6回】リアルタイムではない装置を、リアルタイムに感じさせる ― 4行LCDの操作感を実機で詰める
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?