見出し画像

SQLite3のデータベース更新通知をリアルタイムで受信する方法【Unity編】【C#】

こちらは、公式アドベントカレンダー2024_A IT技術関連トピック Day.21 の記事です。
公式アドベントカレンダー2024_B 仕事術・キャリア・体験記も毎日記事を公開していますので、ぜひあわせてご覧下さい。

★Day20のアドベントカレンダー記事
軽い気持ちで1on1を支援するツールを作ったら、奥が深くていっぱい考えた話(森川知雄)


はじめに


こんにちは、開発エンジニア兼テスト自動化アーキテクトの髙橋です。

今回はUnityでニッチだけど確実に悩んでいる方がいらっしゃるであろう、ある問題について、一つの解決策を試してみましたので共有したいと思います。

それは一体何かというと、データベース更新時に画面上にどのように変更データを反映するのか、という問題です。
「え?そんなこと」と思われる方がいらっしゃるかもしれませんが、これが実は意外と難しかったりします。

本記事では、Unity(C#)でSQLite3をDBとして使用している方向けに、データベース更新時の画面表示における考慮すべきポイントを整理し、それに対する解決策を実際に試していこうと思います。

本記事で想定している読者


  • Unityのデータ管理でSQLite3を使用している方

  • データベースの値に紐づいた画面表示をリアルタイムに行う必要がある方

    • でもそのために「更新されたかをチェックするポーリング処理」あるいは「更新された事のプッシュ通知」を一から実装するのは工数や品質、性能面で懸念が…と思っているそこのあなた

※Unityを使用していない方でも、SQLite3のDB更新にトリガーした処理を行いたい方も見て損はないかもしれません。

データベース更新と画面表示の間にある苦悩


例えば、以下のようなよくあるシミュレーションゲーム風の画面を作っているときに、上部ペインの値(例えばゴールドや鉄、木材…)についてDBから取得したものをそのまま表示させたい!と思うことがありますよね。
しかし、性能面など諸々考慮すると、単純なように見えて実は難しい実装だったりします。

画面上にDBから取得した値を表示させる場合、データが変更されたタイミングでリアルタイムに表示する必要があります。 ではどうすればいいでしょうか?

案1:画面リフレッシュのタイミングでDBクエリ

まず最初に考えるのが、画面リフレッシュのタイミングでDBにクエリを行う方法です。

実装としては上記のようなシーケンスになるかと思います。

ここで問題となるのが、画面リフレッシュの頻度です。
通常、画面の更新は遅くとも1秒に30回(30fps)ほどになります。ただでさえ性能面でネックになりやすいDBクエリを、1秒に30回も行うというのは少し不安ですよね。
FixedUpdateなどを使えばポーリングレートは調整できますが、そうするとリアルタイム性を犠牲にします。
それに画面の更新処理は他にもキー入力待ちやキャラの位置計算などに使われたりします。「DBアクセスが遅延すると引きずられてUXが止まる」という仕組みはリスクでしかありません。

案2:バッファを噛ませる

では、DBへの直接クエリではなく、以下のようにバッファを介せばよいのではないでしょうか。

DBのクエリを常駐スレッドなどに任せ、画面はそのスレッドがバッファに格納した値を画面リフレッシュ毎に確認します。これであれば画面から直接DBにアクセスした際のボトルネック問題を解決しており、問題ないように見えます。

しかしこれではDBをデータ管理に使っている意味が薄れてしまいます。
ビッグデータをORMやSQLを使って少ない手数で上手く抽出できるのがDBによるデータ管理の強みなのに、この方式ではデータを一旦プログラム上の変数に格納しなければならず、条件付きでデータを取り出したい場合は一々専用の抽出ロジックを組まなければなりません。

ではバッファ更新処理でその条件付きクエリをすればいいのではないかと思うかもしれませんが、そこで問題となるのが画面数です。ゲームには多数の画面があり、多数のパラメータ表示があるので、それぞれの表示用データ取得クエリを一つのスレッドに集約するのは、画面固有の処理がその画面に閉じていない事になりメンテナンス性が低下します。

案3:自前でのDB更新通知

それなら画面リフレッシュタイミングでのデータ取得ではなく、DB更新処理でDBを更新したことを画面に対して通知し、画面側はそれを受け取ったら単発でデータ取得クエリを投げればいいのではないでしょうか?例えば以下のように。

これであれば画面リフレッシュ毎にクエリを行う必要もなく、データ取得もORMの力を生かして欲しいものが簡単に取れそうですし、画面固有の処理も画面内に閉じており問題なさそうですね。

ですがちょっと待ってください。DBを更新する処理すべてにDB更新通知の機能を盛り込むのは現実的でしょうか?
DBを取得する処理が星の数ほどもあれば、DBを更新する処理も星の数ほどあるでしょう。ラッパークラスである程度部品化したとしても、そこそこの数になることが想定されます。それらすべてにDB更新通知機能を追加するのはバグの温床になりかねません。

また、通知処理本体の実装自体も手間がかかりますし、できるだけゲームと関係ないロジックは作りたくないですよね。

Q.じゃあどうすればいいの?


A.フックという解決策

これらの悩みはSQLite3がC/C++向けに提供しているインタフェース の中にあるsqlite3_update_hookというAPIを使うことで解決できます。
使用イメージとしては以下の通りです。

ざっくりと言えば、DBを更新したら勝手にSQLite3からプッシュ通知してくれるというインタフェースになります。
これを使えば前述した悩みのほとんど全てが解決されます!

  • ゲーム画面側で画面リフレッシュ毎にデータ取得を行う必要がない。
    ✅性能影響最小化

  • プログラマがわざわざ「DB更新待ちのポーリング処理」や「DB更新時のプッシュ通知処理」を実装する必要がない。
    ✅工数とリスク削減 -> ゲームロジックに集中できる

  • ゲーム画面側はORMの機能を最大限使える。
    ✅実装の自由度が低下しない

  • 画面固有の処理が画面内に閉じている。
    ✅メンテナンス性が低下しない

では、実際にやってみましょう。

試してみた


qlite3_update_hookを使って画面に対してDB更新を通知する仕組みを実装していきます。

サンプル配置先

今回作ったサンプルはGitHubのリポジトリに格納しておりますので、詳しく見たい方は参照ください。

前提条件

■UnityEditorバージョン 2022.3.12f1(C# 9.0 / .NET 5.0)

■使用パッケージ(NuGet For Unity 4.1.0経由でインストール)

表内URL:https://www.nuget.org/packages/SQLitePCLRaw.lib.e_sqlite3

サンプルの動作イメージ

冒頭に張り付けた戦略シミュレーションゲーム風の画面をベースに作っていきます。

次ターンボタン(画面右下)を押下するとDB上のリソース数量が増減し、連動してリソースパネル(画面上部ペイン)のリソース数量表示も増減する処理にします。

処理シーケンスのイメージは以下の通りです。

サンプルコード構成

以下にサンプルコードのディレクトリ構成を示します。
この内、★で示すソースがDB更新通知に関連するソースのため、この後の章で解説します。
その他のファイルに関してはリポジトリを覗いて確認してみてください。

📦shiftDBNotifySample
 :
 ┣ 📂Assets
 ┃ ┣ 📂Data
 ┃ ┃ ┣ 📂CREATE_TABLE                           ->  スキーマ定義を配置
 ┃ ┃ ┗ 📂INSERT                                 ->  初期投入データを配置
 ┃ ┣ 📂Image                                    ->  サンプルで使用する画像データを配置
 ┃ ┣ 📂Packages                                 ->  NuGetで取得したパッケージの格納先
 ┃ ┣ 📂Plugins                                  ->  SQLite3のプラグインを配置
 ┃ ┣ 📂Scenes
 ┃ ┃ ┣ 📜Loading.unity                          ->  画面定義ファイル:ロード画面
 ┃ ┃ ┗ 📜StrategyMap.unity                      ->  画面定義ファイル:ワールドマップ画面
 ┃ ┣ 📂Script
 ┃ ┃ ┣ 📂COMMON
 ┃ ┃ ┃ ┣ 📂DBMapper                             ->  ORMの各テーブルDTOクラスを配置
 ┃ ┃ ┃ ┣ 📂Extentions                           ->  オブジェクトメソッドを拡張するクラスを配置
 ┃ ┃ ┃ ┣ 📂StaticParameterStruct                ->  staticパラメータの構造体を配置
 ┃ ┃ ┃ ┣ 📜DbAccessController.cs                ->  ★DB制御クラス
 ┃ ┃ ┃ ┣ 📜DBDataExchanger.cs                   ->  SQLファイルからスキーマ及び初期データ投入を行うクラス
 ┃ ┃ ┃ ┣ 📜FileSearch.cs                        ->  指定ディレクトリからファイル検索を行うクラス
 ┃ ┃ ┃ ┣ 📜Logger.cs                            ->  ロガー
 ┃ ┃ ┃ ┣ 📜SettingFileController.cs             ->  設定ファイル制御クラス
 ┃ ┃ ┃ ┣ 📜StaticParameters.cs                  ->  staticパラメータ定義
 ┃ ┃ ┃ ┣ 📜TextureUtil.cs                       ->  画像ファイルをテクスチャに変換するクラス
 ┃ ┃ ┣ 📂Loading
 ┃ ┃ ┃ ┣ 📜Loading_GameLoad.cs                  ->  ★ロード画面:ロード処理
 ┃ ┃ ┗ 📂StrategyMap
 ┃ ┃    ┣ 📜StrategyMap_Button_Exit.cs          ->  ワールドマップ画面:Exitボタン制御
 ┃ ┃    ┣ 📜StrategyMap_Button_TurnProgress.cs  ->  ★ワールドマップ画面:次ターンボタン制御
 ┃ ┃    ┣ 📜StrategyMap_CamCtrl.cs              ->  ワールドマップ画面:カメラ制御
 ┃ ┃    ┣ 📜StrategyMap_MapCreate.cs            ->  ワールドマップ画面:マップ生成処理
 ┃ ┃    ┣ 📜StrategyMap_ResourcePanel.cs        ->  ★ワールドマップ画面:リソースパネル制御
 ┃ ┣ 📂StreamingAssets
 ┃ ┃ ┣ 📜mainDB.sqlite3                         ->  DBファイル(起動毎に再作成)
 ┃ ┃ ┗ 📜settings.json                          ->  設定ファイル
 ┃ :
 :

サンプルコード説明

ロード画面:ロード処理

ゲーム起動時に最初に呼ばれる処理です。
ここで、DBの起動とsqlite3_update_hookへコールバックの登録処理を行います。

「あれ、解決策では画面側でsqlite3_update_hookにコールバックを登録するシーケンスになっていなかったか?」

イメージではわかりやすくするためにそう記載していました。しかし実際には画面や更新されるデータに応じてどのようなコールバックを呼び出してほしいかというパターンは大量に存在するでしょう。
そういったニーズを満たすために、まずはロード画面で共通的なコールバックを登録して、そこから更新データに応じて振り分けを行い個別のコールバックを呼び出す処理にしています。

Assets\Script\Loading\Loading_GameLoad.cs

using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using SqlKata.Execution;

public class Loading_GameLoad : MonoBehaviour
{
    [SerializeField] private Slider slider;
    [SerializeField] private TMP_Text loadingText;

    // Start is called before the first frame update
    void Start()
    {
        slider.value = 0;
        // DB接続
        loadingText.text = "create DB connection...";
        QueryFactory factory = DbAccessController.createDbConnection();
        Logger.DebugLog("DB接続完了");

~中略~

        slider.value = 0.2f;
        // ゲームファイルのDB書き込み
        loadingText.text = "GameFile Loading...";
        DBDataExchanger.importInitialSqlToDB(factory);
        // DB更新通知push受信コールバック関数リスト初期化
        DbAccessController.initReceiveNotifyUpdateDbCBDic();
        Logger.DebugLog("ゲームファイルのDB書き込み完了");

        //100% 次画面遷移
        slider.value = 1;
        StaticParameters.playingData.gamename = "Game1";  // ゲーム名仮値
        StaticParameters.playingData.currentTurn = 1;  // 初期ターン数を設定
        StaticParameters.playingData.playerNationName = "EarthEmpire";  // 国名仮値
        Logger.DebugLog("StaticParameters.playingData:" + StaticParameters.playingData.ToStringReflection());
        SceneManager.LoadScene("StrategyMap");
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

シーケンスに関連する部分のみ、かいつまんで解説します。

StartメソッドはUnityにおいて画面起動時に呼ばれる処理となります。
つまりロード画面の最初に実行する処理をここに定義できます。

    // Start is called before the first frame update
    void Start()
    {

DBファイルの作成、接続、およびsqlite3_update_hookへ共通コールバックの登録処理を行います。
詳細は後述のDB制御クラスを参照。

        // DB接続
        QueryFactory factory = DbAccessController.createDbConnection();

初期データ投入を実施し、個別コールバックのリストを初期化します。
このあたりの細かい説明は割愛しますが、気になる方はリポジトリを参照ください。

        // ゲームファイルのDB書き込み
        DBDataExchanger.importInitialSqlToDB(factory);
        // DB更新通知push受信コールバック関数リスト初期化
        DbAccessController.initReceiveNotifyUpdateDbCBDic();

諸々が終わったタイミングでワールドマップ画面に遷移します

        SceneManager.LoadScene("StrategyMap");

DB制御クラス
今回の本丸となるクラスです。
DB作成、接続およびsqlite3_update_hookへの共通コールバック登録と、共通コールバック内部の処理の詳細を記述します。

Assets\Script\COMMON\DbAccessController.cs

using System.Collections.Generic;
using UnityEngine;
using Microsoft.Data.Sqlite;
using SqlKata.Execution;
using SqlKata.Compilers;
using System;
using System.Reflection;

/// <summary>
/// SQLite3のDBアクセス制御クラス
/// </summary>
public static class DbAccessController
{
    public static QueryFactory createDbConnection()
    {
        if( null != StaticParameters.queryFactory ){
            Logger.DebugLog("DB接続存在済みの為return");
            return StaticParameters.queryFactory;
        }

        DeleteDB();
        CloneDB();

        SqliteConnectionStringBuilder builder = new SqliteConnectionStringBuilder
        {
            DataSource = System.IO.Path.Combine(Application.persistentDataPath, StaticParameters.s_dbName)
        };

        SQLitePCL.Batteries_V2.Init();

        Logger.DebugLog("接続確認");

        StaticParameters.dbConnection = new SqliteConnection(builder.ConnectionString);
        StaticParameters.dbConnection.Open();  // ここでOpenしなくてもqueryFactoryでクエリ時に勝手にOpenになるがDB更新のフックが登録できないのでOpenする

        // SQLiteのバージョン出力
        Logger.DebugLog("バージョン情報:" + StaticParameters.dbConnection.ServerVersion);

        SqliteCompiler compiler = new SqliteCompiler();
        StaticParameters.queryFactory = new QueryFactory(StaticParameters.dbConnection, compiler);

        // DB更新通知受信コールバック関数登録
        Logger.DebugLog("DB更新通知受信コールバック関数登録");
        SQLitePCL.raw.sqlite3_update_hook(StaticParameters.dbConnection.Handle, pushNotifyUpdateDbCB, null);

        return StaticParameters.queryFactory;
    }

~中略~

    // DB更新通知受信コールバック関数(各通知先へpush)
    // 引数はSQLitePCL.delegate_updateを参照
    static void pushNotifyUpdateDbCB(object userData, int queryType, SQLitePCL.utf8z updateDB, SQLitePCL.utf8z updateTable, long updateRowID){
        string updateDBstr = updateDB.utf8_to_string();
        string updateTablestr = updateTable.utf8_to_string();

        Logger.DebugLog("pushNotifyUpdateDbCB START queryType:" + queryType + " updateDB:" + updateDBstr + " updateTable:" + updateTablestr + " updateRowID:" + updateRowID);
        Logger.DebugLog("StaticParameters.receiveNotifyUpdateDbCBList Count:" + StaticParameters.receiveNotifyUpdateDbCBDic.Count);

        // Push通知先関数実行
        if( StaticParameters.receiveNotifyUpdateDbCBDic.ContainsKey(updateTablestr)){
            Logger.DebugLog("receiveNotifyUpdateDbCBList contain FuncList of " + updateTablestr);
            List<Func<object, int, string, string, long, int>> receiveNotifyUpdateDbCBList = StaticParameters.receiveNotifyUpdateDbCBDic[updateTablestr];
            if( receiveNotifyUpdateDbCBList is not null ){
                Logger.DebugLog("receiveNotifyUpdateDbCBList is not null count:" + receiveNotifyUpdateDbCBList.Count);
                foreach( Func<object, int, string, string, long, int> receiveNotifyUpdateDbCB in receiveNotifyUpdateDbCBList ){
                    Logger.DebugLog("execute start " + receiveNotifyUpdateDbCB.GetMethodInfo().Name);
                    int result = receiveNotifyUpdateDbCB(userData, queryType, updateDBstr, updateTablestr, updateRowID);
                    Logger.DebugLog("execute end " + receiveNotifyUpdateDbCB.GetMethodInfo().Name + " result:" + result);
                }
            } else {
                Logger.DebugLog("receiveNotifyUpdateDbCBList is null, skip");
            }
        } else {
            Logger.DebugLog("receiveNotifyUpdateDbCBList don't contain FuncList of " + updateTablestr + ", skip");
        }

        Logger.DebugLog("pushNotifyUpdateDbCB END");
    }

~後略~

}

長いので重要な関数だけピックアップして他は端折りました。
それぞれの処理を解説します。

DB接続(createDbConnection)

ロード画面で最初に呼ばれる処理です。
DBファイルの作成、接続などの諸々を行いコネクションハンドラが取得できたら、sqlite3_update_hookに対して共通コールバック(pushNotifyUpdateDbCB)を登録します。

        // DB更新通知受信コールバック関数登録
        Logger.DebugLog("DB更新通知受信コールバック関数登録");
        SQLitePCL.raw.sqlite3_update_hook(StaticParameters.dbConnection.Handle, pushNotifyUpdateDbCB, null);

引数はsqlite3_update_hookのリファレンス を参照します。
ただし、型の記述がC言語のものになっているのでC#の型は以下を参照します。

void SQLitePCL.raw.sqlite3_update_hook(SQLitePCL.sqlite3 db, SQLitePCL.delegate_update f, object v)

なお、第一引数のハンドラはオープンされているものでないとエラーとなる点に注意してください。

共通コールバック(pushNotifyUpdateDbCB)

sqlite3_update_hookに登録するコールバック本体です。
ロード処理の冒頭で説明したとおり、フックの受け口として機能し、その後各画面の個別コールバックを起動して更新通知を配信します。

    // DB更新通知受信コールバック関数(各通知先へpush)
    // 引数はSQLitePCL.delegate_updateを参照
    static void pushNotifyUpdateDbCB(object userData, int queryType, SQLitePCL.utf8z updateDB, SQLitePCL.utf8z updateTable, long updateRowID){

DBが更新された際に最初に呼ばれる処理となりますが、これに与えられる引数についてもsqlite3_update_hookのリファレンスを参照します。
こちらも、型の記述がC言語のものになっているのでC#の型はSQLitePCL.delegate_updateを参照します。

delegate void SQLitePCL.delegate_update(object user_data, int type, SQLitePCL.utf8z database, SQLitePCL.utf8z table, long rowid)
(※)https://www.sqlite.org/c3ref/c_alter_table.html

更新通知としては十分な種類の情報が渡されている事がわかります。
これだけあれば様々なパターンに対応可能でしょう。


        // Push通知先関数実行
        if( StaticParameters.receiveNotifyUpdateDbCBDic.ContainsKey(updateTablestr)){
            Logger.DebugLog("receiveNotifyUpdateDbCBList contain FuncList of " + updateTablestr);
            List<Func<object, int, string, string, long, int>> receiveNotifyUpdateDbCBList = StaticParameters.receiveNotifyUpdateDbCBDic[updateTablestr];
            if( receiveNotifyUpdateDbCBList is not null ){
                Logger.DebugLog("receiveNotifyUpdateDbCBList is not null count:" + receiveNotifyUpdateDbCBList.Count);
                foreach( Func<object, int, string, string, long, int> receiveNotifyUpdateDbCB in receiveNotifyUpdateDbCBList ){
                    Logger.DebugLog("execute start " + receiveNotifyUpdateDbCB.GetMethodInfo().Name);
                    int result = receiveNotifyUpdateDbCB(userData, queryType, updateDBstr, updateTablestr, updateRowID);
                    Logger.DebugLog("execute end " + receiveNotifyUpdateDbCB.GetMethodInfo().Name + " result:" + result);
                }
            } else {
                Logger.DebugLog("receiveNotifyUpdateDbCBList is null, skip");
            }
        } else {
            Logger.DebugLog("receiveNotifyUpdateDbCBList don't contain FuncList of " + updateTablestr + ", skip");
        }

        Logger.DebugLog("pushNotifyUpdateDbCB END");
    }

このあたりはごちゃごちゃしていますが、ざっくり説明すると登録されている個別コールバックリストから、更新されたテーブルのものをピックアップして順次起動する処理です。
これによって個々の画面のニーズに応じた通知を行う仕組みです。

【参考】staticパラメータ定義
上記の個別コールバックリストを管理するパラメータです。
基本的にはフック側から渡されたものをそのまま受け流すイメージです。

Assets\Script\COMMON\StaticParameters.cs

using System;
using System.Collections.Generic;

/// <summary>
/// static変数管理クラス
/// </summary>
public static class StaticParameters{

~中略~

    /*  DB更新通知push受信コールバック関数ディクショナリー
        -> 通知受信が必要になったタイミングで追加し、不要になったタイミングで削除する事。
        Dictionary<
          string -> 更新対象テーブル名。該当テーブル更新時のみValueの関数リストが実行される。
          List<Func> -> push通知受信コールバック関数リスト
            引数1:ユーザデータ。基本はnullなので参照不要
            引数2:DB更新の原因操作。SQLITE_INSERT、SQLITE_DELETE、またはSQLITE_UPDATEのいずれか
            引数3:更新対象DB名
            引数4:更新対象テーブル名
            引数5:更新対象rowid
            戻値:実行結果 ※実行元は特にこの結果を意識しない
        >
    */
    public static Dictionary<string, List<Func<object, int, string, string, long, int>>> receiveNotifyUpdateDbCBDic = new Dictionary<string, List<Func<object, int, string, string, long, int>>>();
}

ワールドマップ画面:リソースパネル制御

実際にDB更新通知を受け取るワールドマップ画面側です。
リソースパネル更新に特化した個別コールバック関数の登録と、通知を受けた際のリソースパネル表示更新処理を記述します。

Assets\Script\StrategyMap\StrategyMap_ResourcePanel.cs

using SqlKata.Execution;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;

public class StrategyMap_ResourcePanel : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        Logger.DebugLog("StrategyMap_ResourcePanel OnStart START");
        SceneManager.sceneUnloaded += SceneUnloadedResourcePanel;  // シーン破棄時の実行関数追加
        // DB更新通知push受信コールバック関数を登録
        DbAccessController.addReceiveNotifyUpdateDbCBDic(nameof(ResourcePerNationsByGame), receiveResourceUpdateDbCB_StrategyMap);

        // 初回のリソースパネル更新を実施
        updateResourcePanel();
        
        Logger.DebugLog("StrategyMap_ResourcePanel OnStart END");
    }

~中略~

    // SceneUnloaded is called after this scene is unloaded.
    void SceneUnloadedResourcePanel(Scene thisScene){
        Logger.DebugLog("StrategyMap_ResourcePanel OnSceneUnloaded START");
        SceneManager.sceneUnloaded -= SceneUnloadedResourcePanel;
        // DB更新通知push受信コールバック関数を削除
        DbAccessController.removeReceiveNotifyUpdateDbCBDic(nameof(ResourcePerNationsByGame), receiveResourceUpdateDbCB_StrategyMap);
        Logger.DebugLog("StrategyMap_ResourcePanel OnSceneUnloaded END");
    }

    // ResourcePerNationsByGameの変更通知を受け取った際にリソースパネルの表示数を更新する関数
    int receiveResourceUpdateDbCB_StrategyMap(object userData, int queryType, string updateDBstr, string updateTablestr, long updateRowID){
        Logger.DebugLog("receiveResourceUpdateDbCB START queryType:" + queryType + " updateDBstr:" + updateDBstr + " updateTablestr:" + updateTablestr + " updateRowID:" + updateRowID);
        int result = 0;

        updateResourcePanel();

        Logger.DebugLog("receiveResourceUpdateDbCB END");
        return result;
    }

    // リソースパネルの値を更新する
    void updateResourcePanel(){
        Logger.DebugLog("updateResourcePanel START");
        Logger.DebugLog("StaticParameters.playingData-> " + StaticParameters.playingData.ToStringReflection());

        QueryFactory factory = DbAccessController.getDbQueryFactory();
        foreach (ResourcePerNationsByGame table in factory.Query(nameof(ResourcePerNationsByGame))
                                                          .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.gamename), "=", StaticParameters.playingData.gamename)
                                                          .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.nationName), "=", StaticParameters.playingData.playerNationName)
                                                          .Get<ResourcePerNationsByGame>())
        {
            Logger.DebugLog("ResourcePerNationsByGame-> " + table.ToStringReflection());
            updateResourcePanelPerResource(table);
        }

        Logger.DebugLog("updateResourcePanel END");
    }

    // 個々のリソースの値を更新する
    void updateResourcePanelPerResource(ResourcePerNationsByGame dbData){
        Logger.DebugLog("updateResourcePanelPerResource START ResourcePerNationsByGame-> " + dbData.ToStringReflection());
        GameObject content = transform.Find( dbData.resourceName + "_AmountPanel/" + dbData.resourceName + "_AmountText").gameObject;
        TextMeshProUGUI amountText = content.GetComponent<TextMeshProUGUI>();
        Logger.DebugLog("beforeAmount ResourceName-> " + dbData.resourceName + " ResourceAmount-> " + amountText.text);
        amountText.text = dbData.resourceAmount.ToString();
        Logger.DebugLog("afterAmount ResourceName-> " + dbData.resourceName + " ResourceAmount-> " + amountText.text);

        Logger.DebugLog("updateResourcePanelPerResource END");
    }
}

個々の処理を解説します。

開始、終了処理(Start、SceneUnloadedResourcePanel)

リソースパネルの起動時に、共通コールバックを介してDB更新通知を受け取るための画面個別コールバック関数(receiveResourceUpdateDbCB_StrategyMap)を登録します。
登録に際しては、関連するテーブルの更新時のみ通知を受け取るようテーブル名も合わせて渡します。
同時に、リソースパネルの終了(画面を離れる)時にコールバック関数を登録解除する処理(SceneUnloadedResourcePanel)を、シーンマネージャに登録しておきます。

    // Start is called before the first frame update
    void Start()
    {
        Logger.DebugLog("StrategyMap_ResourcePanel OnStart START");
        SceneManager.sceneUnloaded += SceneUnloadedResourcePanel;  // シーン破棄時の実行関数追加
        // DB更新通知push受信コールバック関数を登録
        DbAccessController.addReceiveNotifyUpdateDbCBDic(nameof(ResourcePerNationsByGame), receiveResourceUpdateDbCB_StrategyMap);

        // 初回のリソースパネル更新を実施
        updateResourcePanel();
        
        Logger.DebugLog("StrategyMap_ResourcePanel OnStart END");
    }
    // SceneUnloaded is called after this scene is unloaded.
    void SceneUnloadedResourcePanel(Scene thisScene){
        Logger.DebugLog("StrategyMap_ResourcePanel OnSceneUnloaded START");
        SceneManager.sceneUnloaded -= SceneUnloadedResourcePanel;
        // DB更新通知push受信コールバック関数を削除
        DbAccessController.removeReceiveNotifyUpdateDbCBDic(nameof(ResourcePerNationsByGame), receiveResourceUpdateDbCB_StrategyMap);
        Logger.DebugLog("StrategyMap_ResourcePanel OnSceneUnloaded END");
    }

画面個別コールバック(receiveResourceUpdateDbCB_StrategyMap)

DB更新通知を受け取る本体です。
この関数が起動されると、リソースパネルに関連したテーブルのデータ取得クエリを実行し、リソースパネル内の表示を更新します。

    // ResourcePerNationsByGameの変更通知を受け取った際にリソースパネルの表示数を更新する関数
    int receiveResourceUpdateDbCB_StrategyMap(object userData, int queryType, string updateDBstr, string updateTablestr, long updateRowID){
        Logger.DebugLog("receiveResourceUpdateDbCB START queryType:" + queryType + " updateDBstr:" + updateDBstr + " updateTablestr:" + updateTablestr + " updateRowID:" + updateRowID);
        int result = 0;

        updateResourcePanel();

        Logger.DebugLog("receiveResourceUpdateDbCB END");
        return result;
    }

    // リソースパネルの値を更新する
    void updateResourcePanel(){
        Logger.DebugLog("updateResourcePanel START");
        Logger.DebugLog("StaticParameters.playingData-> " + StaticParameters.playingData.ToStringReflection());

        QueryFactory factory = DbAccessController.getDbQueryFactory();
        foreach (ResourcePerNationsByGame table in factory.Query(nameof(ResourcePerNationsByGame))
                                                          .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.gamename), "=", StaticParameters.playingData.gamename)
                                                          .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.nationName), "=", StaticParameters.playingData.playerNationName)
                                                          .Get<ResourcePerNationsByGame>())
        {
            Logger.DebugLog("ResourcePerNationsByGame-> " + table.ToStringReflection());
            updateResourcePanelPerResource(table);
        }

        Logger.DebugLog("updateResourcePanel END");
    }

    // 個々のリソースの値を更新する
    void updateResourcePanelPerResource(ResourcePerNationsByGame dbData){
        Logger.DebugLog("updateResourcePanelPerResource START ResourcePerNationsByGame-> " + dbData.ToStringReflection());
        GameObject content = transform.Find( dbData.resourceName + "_AmountPanel/" + dbData.resourceName + "_AmountText").gameObject;
        TextMeshProUGUI amountText = content.GetComponent<TextMeshProUGUI>();
        Logger.DebugLog("beforeAmount ResourceName-> " + dbData.resourceName + " ResourceAmount-> " + amountText.text);
        amountText.text = dbData.resourceAmount.ToString();
        Logger.DebugLog("afterAmount ResourceName-> " + dbData.resourceName + " ResourceAmount-> " + amountText.text);

        Logger.DebugLog("updateResourcePanelPerResource END");
    }

ワールドマップ画面:次ターンボタン制御

DBを更新するゲームロジックを模擬した処理です。
ボタンを押すたびに各リソースに仮で固定値100を積み上げていきます。
これを起点にDB更新通知が行われることになります。

Assets\Script\StrategyMap\StrategyMap_Button_TurnProgress.cs

using UnityEngine;
using TMPro;
using SqlKata.Execution;

public class StrategyMap_Button_TurnProgress : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI currentTurnText;

~中略~

    // clickButton is called when player clicked return button
    public void clickButton()
    {
        Logger.DebugLog("StrategyMap_Button_TurnProgress onclick start");
        // 現在のターン数を更新
        StaticParameters.playingData.currentTurn++;
        // テキストのターン数を更新
        currentTurnText.text = "Turn " + StaticParameters.playingData.currentTurn;
        // TODO: 仮でリソースを固定値で更新
        QueryFactory factory = DbAccessController.getDbQueryFactory();
        foreach (ResourcePerNationsByGame table in factory.Query(nameof(ResourcePerNationsByGame))
                                                          .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.gamename), "=", StaticParameters.playingData.gamename)
                                                          .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.nationName), "=", StaticParameters.playingData.playerNationName)
                                                          .Get<ResourcePerNationsByGame>())
        {
            Logger.DebugLog("ResourcePerNationsByGame-> " + table.ToStringReflection());
            table.resourceAmount += 100;
            factory.Query(nameof(ResourcePerNationsByGame))
                   .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.gamename), "=", StaticParameters.playingData.gamename)
                   .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.nationName), "=", StaticParameters.playingData.playerNationName)
                   .WhereColumns(nameof(ResourcePerNationsByGame) + "." + nameof(ResourcePerNationsByGame.resourceName), "=", table.resourceName)
                   .Update(table);
            Logger.DebugLog("Updated ResourcePerNationsByGame-> " + table.ToStringReflection());
        }
        Logger.DebugLog("StrategyMap_Button_TurnProgress onclick end currentTurnText.text: " + currentTurnText.text);
    }
}

実行結果

ロード画面から実際に起動してみます。
次ターンボタンを押下するたびに、リソースパネルの値が上昇していることがわかります。

また、ログ上もDB更新タイミングでコールバック関数が起動していることが確認できました。

[StrategyMap_Button_TurnProgress] [clickButton()] ResourcePerNationsByGame-> ,gamename:Game1,nationName:EarthEmpire,resourceName:Steel,resourceAmount:400
[DbAccessController] [pushNotifyUpdateDbCB()] pushNotifyUpdateDbCB START queryType:23 updateDB:main updateTable:ResourcePerNationsByGame updateRowID:3
[DbAccessController] [pushNotifyUpdateDbCB()] StaticParameters.receiveNotifyUpdateDbCBList Count:9
[DbAccessController] [pushNotifyUpdateDbCB()] receiveNotifyUpdateDbCBList contain FuncList of ResourcePerNationsByGame
[DbAccessController] [pushNotifyUpdateDbCB()] receiveNotifyUpdateDbCBList is not null count:1
[DbAccessController] [pushNotifyUpdateDbCB()] execute start receiveResourceUpdateDbCB_StrategyMap
[StrategyMap_ResourcePanel] [receiveResourceUpdateDbCB_StrategyMap()] receiveResourceUpdateDbCB START queryType:23 updateDBstr:main updateTablestr:ResourcePerNationsByGame updateRowID:3
[StrategyMap_ResourcePanel] [updateResourcePanel()] updateResourcePanel START[StrategyMap_ResourcePanel] [updateResourcePanel()] updateResourcePanel END
[StrategyMap_ResourcePanel] [receiveResourceUpdateDbCB_StrategyMap()] receiveResourceUpdateDbCB END
[DbAccessController] [pushNotifyUpdateDbCB()] execute end receiveResourceUpdateDbCB_StrategyMap result:0
[DbAccessController] [pushNotifyUpdateDbCB()] pushNotifyUpdateDbCB END
[StrategyMap_Button_TurnProgress] [clickButton()] Updated ResourcePerNationsByGame-> ,gamename:Game1,nationName:EarthEmpire,resourceName:Steel,resourceAmount:500
[StrategyMap_Button_TurnProgress] [clickButton()] StrategyMap_Button_TurnProgress onclick end currentTurnText.text: Turn 6

おまけ:他DBの類似機能


ここで終わってもいいのですが、少し気になったので他のDBでも類似の機能が無いか調べてみました。
DBランキングで上位6つのDB(2024年時点)に同じようなDB更新通知機能はあるのでしょうか。

結論から言えば全てのDBで似たような機能がありました。
ただ面白いのはDBによって通知の仕方にバリエーションがある点です。

大別して以下の3パターンに分けられそうです(独断と偏見)。

  • コールバック型(ライブラリ)

  • コールバック型(ストアドファンクション)

  • イベント監視型

どの種類が便利かはシステムの特性などにもよると思いますので一概には言えませんが、個人的には「コールバック型(ライブラリ)」がシンプルに呼ばれたときに動けばいいだけなので癖がなくて使いやすいかと思います。

ただ、DBは通知機能の便利さによって選ぶものではないと思うので、結局はプロジェクトで使われるDBに備えられたもので何とかするしかないパターンが多いかなと思います。
「置かれた場所で咲きなさい」とは良く言ったものですね。

■主要6DBにおける通知機能一覧

おわりに


今回は、ありふれた機能ながら実は意外と難しいDB更新からのリアルタイム画面反映を行う方法の一つとして、SQLite3のDB更新通知機能(sqlite3_update_hook)をまとめてみました。
このあたりは意外と参考となるような解説が少なくリアルに困ったというのもあり、スキマ産業的に記事を執筆させていただきました。
もちろんUnityでSQLite3を使っているケース自体が恐らく少ないかと思いますし、今回の解決策もあくまで一例ですので刺さる方は全国で数人程度しかいない可能性が高いですが…笑

Sqlite3のC/C#向けインタフェース は実は結構色々あるので、他にも知られざる便利機能が埋もれているかもしれません。私自身ほとんど把握できていませんがこれを機会に発掘していくのも面白いかもしれませんね。

最後になりますが今回サンプルのワールドマップはAzgaar’s Fantasy Map Generatorというツールで生成させていただきました。指輪物語っぽいファンタジー世界のマップをワンクリックで作れる優れものです。この場を借りてこのツールの製作者様に謝辞を述べさせていただきます。

ではまたどこかでお会いしましょう。


執筆者プロフィール:髙橋 一生
2024年8月に株式会社SHIFTへ入社。新卒で受託開発系のソフトウェアベンダーで通信/Web系でフルスタックエンジニアとして各種プロジェクトに従事。退職し漫画家を目指して1年程度独学で修行する傍ら、並行してUnityでゲーム制作を始める。その後、再びエンジニアを志しほぼ未開拓であったテスト自動化やCICDの領域を学ぶべくSHIFTにジョイン。空っぽの頭に夢と知識を詰め込みながら仕事をしています。

SHIFTグループ公式アドベントカレンダー2024【A】 IT技術関連トピック Day22は「便利なPCリモコン “nanoKVM” の紹介」(中冨勝利)

お問合せはお気軽に

SHIFTについて(コーポレートサイト)

SHIFTのサービスについて(サービスサイト)

SHIFTの導入事例

お役立ち資料はこちら

SHIFTの採用情報はこちら

ゲーム画面内マップ:Azgaar’s Fantasy Map Generatorにて作成