自作エディターを作る! イベントドリブンにする

今回はなにを思ったかイベント創出の仕組みを作っていきます。(なぜ……) コンソールアプリケーションで使えるイベント駆動のプラットフォームがよくわからなかったので自分で作りました。

先に誤っておきますが、この方法がスタンダードなものなのかはわかりません。

前回の記事はこちらです。

r-mutax.hateblo.jp

なぜイベント駆動にしたか?

前回の改良で、mtxtprotではコンソールサイズに適した行数だけファイルを表示するようにしました。

ですが、前回の改良では以下の問題がありました。

  1. キー入力のたびにフィッティングして描画し直していたので負荷が高い。 2.キー入力をするまで表示内容が更新されない。

1,2の問題はともに、画面サイズの変更をキックとして、表示内容を更新させることで対応可能です。これはまさしく、mtxtprotにキー入力以外のイベントが必要であることにほかなりません。

そこで、別スレッドにてイベント発生を監視して、CEditControlクラスでイベントメッセージを受け取って処理することにしました。

イベント駆動のプラットフォーム開発

設計もどき

キー入力や画面変更などのイベントが発生したらメッセージキューにためて、メッセージを取得する側はメッセージキューからメッセージを一つずつ取ってきて処理する。 メッセージを使用する側からはイベント創出側の実装は極力意識させずに、メッセージを取得するGetMessage()みたいなAPIでイベントを取得する。

イベント創出はクラス化し、クラスメソッドでイベント監視を行う。クラス外からイベントを登録されることを見越して、イベント登録関数SendMessage()を用意する。メッセージキューは、外部のアプリケーションがイベント登録を依頼してきたときにやりやすいように、グローバル領域に用意しておく。メッセージキューはnamespaceで隠す。

f:id:sanseido:20201114022214p:plain

しかしこうして図にしてみると、設計としてイマイチな気がします。msg_poolはグローバルな領域にするのでなく、CMessageEngineのメンバにした方が変なところから触られる可能性もなくていいですね。

メッセージ用APIの機能

API 詳細

  • void PrepareMessageLoop() - イベント駆動プラットフォームを使う前に1回だけ呼ぶ。CMessageEngineインスタンス化や、イベント創出用Threadを作る。
  • MESSAGE_ID GetMessage(MESSAGE& msg) - メッセージを取得する。戻り値はenum型。MESSAGE型は独自定義の構造体。
  • void SendMessage(MESSAGE& msg) - メッセージキューへのメッセージの登録。APIの使用側からイベント登録したいときのために、APIとしても用意している。

イベント種類

今の所、現状はほしいイベントしか用意していないです。

  • MM_QUIT - 終了イベント。Ctrl + Cが押されたらイベント登録して終了する。
  • MM_KEYPRESS - キープレスイベント。マルチバイトキーを遅れるように、KEY構造体を定義してそこに読み取ったキーを全部入れている。仮想キーコードに置き換える予定。
  • MM_CHANGE_WINSIZE - 画面サイズの変更イベント。描画内容を更新する。イベントの実装は悩み中。

ただこれ以上なにかイベント種別を追加するかと言われると、正直わかりません。たぶんしないんじゃないかな…。コンソールアプリケーションなので、キー入力くらいしかできることないですしね。追加してマウスクリックくらいかなあ…。

実装

まずはじめに、今回のリビジョンはこちらです。

github.com

CMessageEngine クラス

ブログにはっつけようとして、nanosleepがコメントアウトしてあったことに気づいて直しました。PrepareMessageLoop()を呼び出すと、CMessageEngineインスタンス化してGetMessage() SendMessage()が使えるようになります。

GetMessage()は一度コールするとイベントを取るまで帰らないようにしています。SendMessage()ではmessage_poolへのアクセスで排他するためにstd::lock_guard<std::mutex>していますが、GetMessage()はループの途中で排他を外したいので、std::mutex.lock()しています。

ブログ書いてて気づいたけど、GetMessage()でusleep()する前にstd::mutex.unlock()しとかないとだめじゃない? やらかしてるわ。

class CMessageEngine{
private:
    std::thread m_thread_key_monitoring;
    std::thread m_thread_display_monitoring;

    bool m_bRun;
    void key_monitoring();
    void display_monitoring();
    std::mutex mtx;

public:
    CMessageEngine();
    ~CMessageEngine();
};

namespace message_nsp
{
    std::queue<MESSAGE> msg_pool;
    CMessageEngine* msg_engine;
} // namespace msg

MESSAGE_ID GetMessage(MESSAGE& msg){

    enum MESSAGE_ID ret;
    while(1){
        mtx.lock();
        if(message_nsp::msg_pool.size() != 0){
            msg = message_nsp::msg_pool.front();
            message_nsp::msg_pool.pop();
            ret = msg.id;
            mtx.unlock();
            break;
        } else {
            // 30ミリ秒sleepする
    // 追記 多分ここでmtx.unlock()しないとだめ
            usleep(30000);
        }

        mtx.unlock();
    }
    return ret;
}

void SendMessage(MESSAGE& msg){
    std::lock_guard<std::mutex>    ul(mtx);
    message_nsp::msg_pool.push(msg);
}

void PrepareMessageLoop(){
    message_nsp::msg_engine = new CMessageEngine();
}

キーイベント監視処理

(2020/11/23 追記)この章に記載の内容は古く、こちらの記事で更新しています。 具体的には、別スレッドでgetchar()タイムアウトを監視する方法だと、別スレッドのgetchar()が終わらなくてthread.join()ができないためです。

元記事の内容は一応以下に残しておきます(せっかく書いたので…)。

実際はこちらの記事↓を参考にしていただけると…。

r-mutax.hateblo.jp

CMessageEngineのコンストラクタで、key_monitoring()のスレッドを立てて、別スレッドでキー入力を監視しています。 このgetkey()関数は、タイムアウト付きのgetchar()を内部で呼んでいて、タイムアウトしたらキー入力の切れ目と判定しています。タイムアウト時間は10msecです。

なんでこんな事をしているかというと、矢印キーはマルチバイト文字なので、複数文字を読み取って一つのキーとして判定しているからです。そのためにKEY構造体にはcharを10個まで入れれるように領域とっているけど、この判定はgetkey()内部に閉じて仮想キーコードを返すようにしたほうが良さそう。

void CMessageEngine::key_monitoring(){

    while(1){
        KEY c;
        getkey(c);
        if(c.keynum > 0){
            MESSAGE msg;
            msg.id = MM_KEYPRESS;
            msg.info = c;
            SendMessage(msg);
        }
    }
}

すぐに変えるつもりだけど、getkey()の中身載せておきますね。

ちょっと説明すると、getkey()の中身ではgetchar_t()のスレッドを立てていて、std::future<T>.wait_until()の機能を使って10msecでタイムアウトしています。 タイムアウトしなかった場合は、キー入力があったとしてKEY構造体に追加し、タイムアウトした場合は、マルチバイト文字のキー入力が終わったと判定して呼び出し元に帰ります。 getchar_t()getchar()のラッパー関数で、std::promise<T>を引数で受け取れるようにするために使ってます。

void getchar_t(std::promise<char> p){
    char c = getchar();
    p.set_value(c);
}

int getkey(KEY& keycode){

    bool endflg = false;

    do{
        std::promise<char> p;
        std::future<char> f = p.get_future();

        std::thread t(getchar_t, std::move(p));

        namespace chrono = std::chrono;
        chrono::steady_clock::time_point tp = chrono::steady_clock::now();
        std::future_status result = f.wait_until(tp + chrono::milliseconds(10));

        if (result != std::future_status::timeout) {
            char key = f.get();
            keycode.keys[keycode.keynum] = key;
            keycode.keynum++;
            if(keycode.keynum > 9){
                // キーコードの数が10を超えたら、たまり過ぎなので帰る。
                break;
            }            
        }  else {
            endflg = true;
        }

        t.join();
    } while(!endflg);

    return 1;
}

次回予告

毎回この終わりの章の名前つけるのどうしようか悩むんですよね。

次回はキー入力を受けてカーソル移動をする処理を組み込んでいこうと思います。あと前回までに作ったcommand_loop()から脱却して、message_loop()を回してイベントドリブンっぽくやろうかな。いやまあある程度はやってあるんですけどね。