🔖

【M5coreS3】複数ページ開発の手法

2024/08/03に公開

前回の記事で書いたように、今のところM5stackの開発はplatformioでc++を書くのがベストではないかと思っています。

ただ、c++で複数ページをどうやって構成し開発していけば良いのか、これといったフレームワークが見つかりませんでした。

生成AIと色々相談しつつ、満足できる手法を編み出しましたので紹介します。
(言うて一週間くらいで作成した方法なので、今後しばらくは頻繁に更新するかもです)

複数ページの開発の概要

特殊なフレームワークを編み出そうとしているわけではなく、ReactやFlutterと同じようにファイル構成や状態管理をわかりやすく書けるようにしました。
(マイコン的にベストプラクティスかはわかりません。個人的にはメモリ管理とかポインタとかは困ってない限りどうでも良いと思っています)

  • Buttonなど共通で使う要素はコンポーネントにして使いまわせるようにする
  • 1ページ1ファイルで作成する
  • 各ページは状態を持ち、状態をぶちこんだUIを表示させる。(ReactやFlutterと同じ思想です)

フォルダ・ファイル構成

platformioはデフォルトでinclude、lib、src、testフォルダを用意してくれていますので、自己流など生み出さずできるだけこれらのフォルダを本来の使い方に沿って使います。

以下のようにsrcの中にmainとAppcontroller、そして各ページのファイル(Home、Delivery、Order)を含めています。

Typescriptやpythonと違ってc++では、あるファイルから別のファイルをインポートしようとする場合、インポートされる側(エクスポート側)は.cppのほかに.hを用意する必要があります。
※ちなみに.inoで作成すると、IDEがビルド時に自動的に.cppと.hを作成してくれますので.hを用意する手間が省けます。ただビルドに時間がかかるので、私はあえてcppとhの両方作成するようにしています。

.hはincludeに配置しています。src下にHomeフォルダを作ってHome/Home.cppとHome/Home.hを作ったほうが近くに合ってみやすいんじゃないかと思いましたが、そんなことはやらずincludeに入れるのが普通らしいです。

一方で自作コンポーネントはlibの下にButton/Button.cppとButton/Button.hという感じで作成するみたいです。

libにはこの他に日本語表示のためのefontを格納しています。

.
├── include
│   ├── AppController.h
│   ├── Delivery.h
│   ├── Home.h
│   ├── Order.h
│   └── README
├── lib
│   ├── Button
│   ├── README
│   └── efont
├── platformio.ini
├── src
│   ├── AppController.cpp
│   ├── Delivery.cpp
│   ├── Home.cpp
│   ├── Order.cpp
│   └── main.cpp
└── test
    └── README

各ファイルの構成

main.cpp

このファイルの役割はM5を立ち上げた時に各種設定をセットアップすることです。

ページ遷移の管理は次のAppControllerに任せています。

src/main
#include <M5CoreS3.h>
#include "AppController.h"
#include <efont.h>
#include <efontFontData.h>

AppController app;

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);

  M5.Display.setFont(&fonts::efontJA_16);  // または他のサイズを選択
}

void loop() {
  app.run();
}

AppController

このクラスの役割は各ページ(Home、Order、Delivery)の表示状態を管理することです。

どのページを表示しているかは数値(0,1,2)で管理しています。

include/AppController.h
#ifndef APP_CONTROLLER_H
#define APP_CONTROLLER_H

#include "Home.h"
#include "Order.h"
#include "Delivery.h"

class AppController {
public:
    AppController();
    void run();

private:
    Home home;
    Order order;
    Delivery delivery;
    int currentPage;
};

#endif
src/AppController.cpp
#include "AppController.h"

AppController::AppController() : currentPage(0) {}

void AppController::run() {
    while (true) {
        switch (currentPage) {
            case 0:
                currentPage = home.show();
                break;
            case 1:
                currentPage = order.show();
                break;
            case 2:
                currentPage = delivery.show();
                break;
        }
    }
}

Home(各ページ)

ここではHomeを例として挙げていますが、他のページも似た構成です。

このクラスは各ページの表示と状態を管理するクラスです。
(FlutterでいうStatefulWidgetと考えてもらうと良いです)

大事なのは、publicのHome()とshow()、privateのdrawPage()です。その他のプロパティは各ページで必要な変数及び関数に応じて適宜編集してください。

include/Home.h
#ifndef HOME_H
#define HOME_H

#include <M5CoreS3.h>
#include <Button.h> 

class Home {
public:
    Home();
    int show();

private:
    struct OrderItem {
        String status;
        String date;
        String name;
    };
    static const int ORDER_HISTORY_SIZE = 4;
    OrderItem orderHistory[ORDER_HISTORY_SIZE];
    Button newOrderButton;
    Button deliveryButton;
    void drawPage();
    void setupButtons();
    int nextPage; 
};

#endif

Home()はコンストラクタで、この時に各種状態変数をセットアップしています。

show()の部分はdrawPage()を実行した後にwhileループを回しています。
このページが表示されている間はずっとこのwhileループが回っています。

drawPage()はボタンや枠などのUIを描きます。
ちなみにボタンを普通に作成しようとすると、絵を描く部分とタッチされた時に反応する部分を別々に書きますので、ボタンが増えてくるとバラバラになって見通しが悪くなります。
なので私は自作Buttonを作って、UIを宣言する時にタッチされた時に呼び出される関数も一緒に書くようにしています(自作Buttonクラスは押した時の色変化やチャタリング防止も盛り込んでいます)

whileループ部分には画面がタッチされた時の処理を書いています。
buttonインスタンスはタッチされると自動的にコンストラクタ作成時にセットしたpress時発火関数を実行する他、isPressedがtrueとなりますので、状況に合わせて処理内容を書いてください。

注目すべきは、タッチイベントを全て終えた後に、全てのボタンのupdate()メソッドを呼び出していることです。

        for (auto& button : keypadButtons) {
            button.update();
        }

これをしないとbuttonの状態がispressed==Trueのままとなってしまい、ボタンの色が変わりっぱなしになります。

src/Home.cpp
#include "Home.h"

Home::Home() : nextPage(0) {
    orderHistory[0] = {"注文待", "", "黒色ボールペン"};
    orderHistory[1] = {"注文済", "2024/7/28", "メモパッド"};
    orderHistory[2] = {"注文済", "2024/7/28", "単三電池 24本入り"};
    orderHistory[3] = {"注文済", "2024/7/14", "ホワイトボードマーカー"};
    setupButtons();
}

void Home::setupButtons() {
    newOrderButton = Button(
        "新規注文",
        20, 200, 120, 30,
        [this]() { nextPage = 1; },
        BLUE, WHITE, 1.4
    );

    deliveryButton = Button(
        "納品",
        180, 200, 120, 30,
        [this]() { nextPage = 2; },
        GREEN, WHITE, 1.4
    );
}

int Home::show() {
    drawPage();
    nextPage = 0;

    while (true) {
        M5.update();
        
        if (M5.Touch.getCount() > 0) {
            auto touch = M5.Touch.getDetail();
            newOrderButton.isPressed(touch.x, touch.y);
            deliveryButton.isPressed(touch.x, touch.y);
        }
        
        newOrderButton.update();
        deliveryButton.update();

        if (nextPage != 0) {
            return nextPage;  // ページ遷移
        }
    }
}

void Home::drawPage() {
    int y = 0;

    CoreS3.Display.fillScreen(BLACK);
    CoreS3.Display.setTextColor(WHITE);
    CoreS3.Display.setTextDatum(TL_DATUM);
    CoreS3.Display.setTextSize(2);
    CoreS3.Display.drawString("発注履歴",5, y);

    y +=35;

    CoreS3.Display.drawLine(0, y, CoreS3.Display.width(),y);

    y +=15;

    CoreS3.Display.setTextSize(1);
    CoreS3.Display.setTextDatum(ML_DATUM);
    int rowHeight = 20;
    for (int i = 0; i < ORDER_HISTORY_SIZE; i++) {
        String status = orderHistory[i].status;

        CoreS3.Display.fillRoundRect(10,y,70,rowHeight, rowHeight/2 ,status=="注文待"? BLUE:TFT_ORANGE);
        CoreS3.Display.drawString(status,               20,y+rowHeight/2);
        CoreS3.Display.drawString(orderHistory[i].name, 90,y+rowHeight/2);
        
        y += rowHeight + 10;
    }

    newOrderButton.draw();
    deliveryButton.draw();
}

Discussion