こんにちは。技術部平山です。
もうすぐAddressable Asset Systemなるものが出て、 AssetBundle関連の問題が何もかもが解決するように言われている昨今ですが、 いろいろありまして待っていられないので、自分で作ってみました。
コードはテストプロジェクトごとgithubに置いてあります。 ライブラリ本体はAssets/Kayac/Loader以下です。 しかしながら、実戦投入してはおりません。あくまでサンプルとお考えください。
製品のAssetBundle関連コードを置き換えて動かしたみたり、 ランダムにロードと解放を繰り返す耐久テストをしたりはしていますが、 全てのバグが取れたとは到底思えませんし、不足機能もあるかと思います。 そうこうしているうちにAddressable Asset Systemが現れて魔法のように何もかもを 解決してくれるようであれば、さっさと捨てると思います。
今回作ってるものの機能概要
簡単に言えば、 AssetBundleをダウンロードして、ローカルのストレージに置いて、 そこからアセットをメモリにロードする仕掛けです。
特徴はこんな感じです。
- ダウンロードは指定した並列数で行う
- 大きなファイルをダウンロードしてもメモリ使用量が増えない
- ローカルストレージへの書き込みは非同期でスパイクしない
- メモリにロードしたアセットは参照カウント管理で自動解放
- アセットのメモリ内キャッシュ機能
- 起動時の初期化処理は別スレッドで非同期
- ローカルストレージからのデータ削除は別スレッドで非同期
- AssetBundle以外にpng/jpg/ogg/テキストファイルもストレージに保存+バージョン管理可能
- 依存関係サポート
詳細は使い勝手を見ていただいてからの方がわかりやすいでしょうから、 先にインターフェイスを簡単に紹介します。
使い勝手
だいたいこんな感じです。
class Main : MonoBehaviour { class AssetFileDatabase : Kayac.Loader.AssetFileDatabase { public override bool ParseIdentifier( out string assetFileName, out string assetName, string assetIdentifier) { var sharpPos = assetIdentifier.IndexOf('#'); // #までがファイル名、#の後がアセット名。例えば、の実装。 assetFileName = assetIdentifier.Substring(0, sharpPos); assetName = assetIdentifier.Substring(sharpPos + 1, assetIdentifier.Length - sharpPos - 1); return true; } } public RawImage _image; Kayac.Loader _loader; void Start() { _loader = new Kayac.Loader( storageCacheRoot: Application.persistentDataPath + "/AssetFileCache", // ローカル保存場所 useHashInStorageCache: true); // ハッシュ値をファイル名に加えて保存 _loader.Start( "http://hoge/assetBundles/", new AssetFileDatabase(), downloadParallelCount: 8); _loader.Load( "hoge.unity3d#fuga", typeof(Texture2D), onError: (errorType, name, exception) => { Debug.LogError(errorType + " " + name + " " + exception.Message); }, onComplete: asset => { _image.texture = asset as Texture2D; }, holderGameObject: this.gameObject); } }
まず、Loader.IAssetFileDatabaseを実装します。ここでは、 デフォルト実装であるLoader.AssetFileDatabaseを継承して、 ParseIdentifier()のみ実装しました。これは、 Loader.Load()に渡すアセットの識別子からファイル名とアセット名を取り出す関数です。
次に、Loaderをnewします。ストレージのファイル保存場所を指定してnewをすると、 別のスレッドで何が保存されているかを調べ始めます。
そして、ダウンロードするサーバのurlと、さっき実装したIAssetFileDatabaseを渡して、 Loader.Start()します。ダウンロードの並列数などのパラメータもここで与えます。 すると、ローカルのストレージにあるファイルで不要になったものを消したりする 処理が別スレッドで走り始めます。
コンストラクタとStartを別にしたのは、 ローカルのファイルのリストを作るのは起動と同時に始めたい一方で、 ダウンロード元のurlや、ファイル名からハッシュ値を引くテーブルを用意したりすることは、 サーバと通信してからでないとできないケースもあると思うからです。 東京プリズンはそうでした。
そして最後にLoad()でロード要求を出します。ローカルにあればローカルから読み、 なければサーバからダウンロードします。エラー時のコールバックと、完了時のコールバック、 そして、アセットの生存期間を決めるためにgameObjectを一つ渡します。
この例では、エラー時にはDebug.LogErrorし、完了時には 中に入っているTextureをRawImage.textureに差しています。 そして、このMainなるスクリプトがついているgameObjectが破棄される時に、 一緒にassetも破棄される、ということを指定しています。
コルーチンで待ちたい
今の例では完了コールバックでデータを受け取りましたが、 コルーチンで待つこともできます。
IEnumerator CoLoad() { var handle = _loader.Load( "hoge.unity3d#fuga", typeof(Texture2D), onError: (errorType, name, exception) => { Debug.LogError(errorType + " " + name + " " + exception.Message); }, onComplete: null, holderGameObject: this.gameObject); yield return handle; _image.texture = handle.asset as Texture2D; }
LoadはLoadHandleなるクラスを返します。 これはIEnumeratorでして、完了するとMoveNextがfalseを返しますので、 yield returnできるようになっています。 終わったらassetプロパティで結果を取れます。
gameObjectと関係なく生存期間を管理したい
Load()にgameObjectを渡さなかった場合、Load()が返すLoadHandleがGCされた時に ロードしたアセットが解放されます。この場合はLoadHandleをフィールドにでも 持っておいて、解放したいタイミングでnullを代入することになります。 ただしGCがいつ走るかは不定ですので、画面を暗くして「今やってくれ!」 という場合は、GC.CollectなりResources.UnloadUnusedAssetsなりを呼んでください。
なお、生存期間の管理をgameObjectでなく、シーンに紐付けたい、 という要望もあるかと思います。 その場合はシーンにあるどれかのgameObjectを渡せば良いでしょう。 例えば弊社のシーン管理ライブラリでは、「シーンに一つだけ配置するMonoBehaviour」 的なクラスがありますので、それがついているgameObjectを渡すのが自然です。 そういうこともあって、特別にシーンを対象とするインターフェイスは作っていません。
起動時のローカルファイル検索時間を有効に使いたい
前述のように、Loaderのコンストラクタを呼ぶと、ローカルストレージに何のファイルがあるかを 調べ始めます。ただし、Start()までにもしそれが終わっていなければ、 Start()で処理を止めて完了を待ちます。
同様に、Start()を呼ぶと、渡したIAssetFileDatabaseを使って、 不要なファイルを消す処理を別スレッドで始めます。 そして、Loader.Load()などの多くの関数は、これの完了までブロックします。
これらのブロックは、Loader.readyを定期的に呼んで、trueが返るまで待てば、 避けることができます。ただし、 完了までの時間が短くなるわけではありません。
pngやoggも読みたい
pngやjpgを指定してLoad()した場合、Texture2Dが出てきます。 また、oggを指定した場合、AudioClipが出てきます。 さらに、拡張子がtxt、html、json、xml、csv、yamlの場合は、TextAssetが出てきます。 AssetBundleと同じ仕組みでローカルストレージに保存され、 ハッシュ値が提供されればバージョン管理もされます。
メモリ内キャッシュ
ある二つの画面を頻繁に行き来する、といった場合、 前の画面でロードした素材を、次の画面で使わないからといって破棄してしまうと 毎度ロードがかかってお客さんを待たせてしまうことになります。 画面ごとに面倒なコードを書かずにこれを緩和するために、 メモリ内に一定容量までアセットを捨てずに取っておく機能があります。
_loader.SetMemoryCacheLimit(32 * 1024 * 1024);
といった感じで、この例では32MBまでは捨てずに置いておきます。 端末のメモリ量をSystemInfoで見て、それに応じた量を指定すると良いかもしれません。 製品の想定使用メモリ量によっても違うでしょうが、 今時のスマホゲームであれば、 1GB端末ならゼロ、2GB端末なら32MB、3GB端末なら64MB、 といったあたりではいかがでしょうか。
ストレージ保存ファイルの破棄
Caching.ClearCache() に相当する機能が当然あります。
_loader.StartClearStorageCache();
これで専用スレッドが起動してファイルを消し始めます。 消し終わる前に他の関数を呼ぶと、多くの場合消し終わるまで待ちますが、 すぐにロードが走りさえしなければ、画面遷移等々をしている間に終わりますので、 お客さんを待たせることは少ないかと思います。
ただし、この関数でファイルを消せるのは、 ロードしたアセットが全て解放されており、 ロード中のアセットもない時だけです。 そうでなければfalseが返って何もしません。 AssetBundleが開いている状態でそこから出てきたアセットを消すことには 諸々面倒があり、実験も必要ですので、今はできなくしてあります。 一切のアセットロードを行っていない状態(例えばタイトル画面) でしか叩かれないという前提です。
なお、単ファイル削除機能もあります。
_loader.DeleteStorageCache("hoge.unity3d");
ゆくゆくは、ロードに失敗してファイルが壊れていることが疑われる時に、 自動で削除して再ダウンロードするようにした方が良いかなと思っていますが、 短いコードで楽に実装する方法が浮かぶまでは放置かなという気もします。
ローカル保存ファイルの管理について
UnityEngine.Caching に相当する機能ですが、挙動はだいぶ違います。 それは用途が違うからです。 本ライブラリでは以下のような管理になっています。
- IAssetFileDatabase.GetFileMetaData()がfalseを返した場合、ローカルファイルを消す
- IAssetFileDatabase.GetFileMetaData()が返したハッシュと異なるローカルファイルは消す
- 時間で消す処理はない
- 容量制限で消す処理はない
消すのはLoader.Startのタイミングだけです。 また、起動中にIAssetFileDatabase.GetFileMetaDataが返すハッシュ値が変わって あるファイルをダウンロードし直した場合、古いファイルは即消します。
UnityEngine.Cachingクラスは、おそらく名前のように「キャッシュ」として実装されています。 ある程度の容量ストレージを割くことで、ダウンロード待ちを軽減する、という意図でしょう。 ダウンロード可能なファイルが全部ローカルに置かれる、 といったことは想定しておらず、 時間で勝手に消えるとか、容量超えたら消えるとか、そういう機能が実装されています。 同じファイルが複数バージョン併存しても、一定容量を超えた分は消され、 その容量が小さく設定されていれば問題ないわけです。
しかし、以前作った東京プリズンや、その他弊社で多いタイプの製品の場合、 これが全く異なります。 サーバにあるファイルは全部ローカルにダウンロードして保存する前提で、 容量制限は設定しません。 時間で勝手に消える必要はなく、複数バージョン併存する必要もありません。 時間を見て消す仕掛けは無駄、むしろ邪魔ですし、 複数バージョン保存されたままになるとストレージが圧迫されて困ります。 また、サーバ側で「どのファイルを消したいか」の制御ができないのも困ります。 期間限定イベントが終わったら即端末からもデータを消したい、 ということはあるわけで、 「サーバから配信するAssetBundleのリストから消せばローカルからも消える」 という作りであれば非常に簡単です。
サーバ上のURLとローカル保存パスの関係について
現状、サーバ上のフォルダ構造を保ってローカルに保存します。
例えば、http://hoge.com/assetbundles/character/1.unity3d
をロードするとしましょう。
Loaderにダウンロードルートフォルダとして
http://hoge.com/assetbundles/
、ローカルの保存ルートフォルダとして
Application.persistentDataPath + "/assetFileCache"
を指定した場合、
assetFileCache/character/1.ハッシュ値16進32桁.unity3d
として保存されます。
ハッシュ値はIAssetFileDatabaseにて供給します。
拡張子は最後にしてハッシュ値を挟みますので、PC上でダブルクリックして開く機能を邪魔しません。
pngやjpgが直接置いてある場合にはこの方が便利かと思います。
フォルダ構成を保つことで、サーバ側に同名のファイルがあっても、 区別して保存することができます。 標準のCachingを使う場合はCachedAssetBundle を使えば同じことができます。 これを使わないとフォルダ構成を無視して置かれてしまい、 同名のファイルの区別がつかなくなるので注意が必要です。
なお、サーバ側の置き方として、ハッシュ値をパスに含めている場合もあるかと思います。
この場合はファイル名にハッシュ値を足す必要がありません。
このためにLoaderのコンストラクタの引数で、ハッシュ値をファイル名に足す処理を無効化できます。
例えば、
http://hoge.com/assetbundles/0123456789abcdef0123456789abcdef/1.unity3d
という具合にパスにハッシュ値が含まれている場合は、
assetFileCache/0123456789abcdef0123456789abcdef/1.unity3d
として
素直に保存すればいいわけです。
さて、フォルダ構造を保存することには当然コストがかかります。 フォルダはOS的にはファイルの一種であり、増やせばコストがかかります。 フォルダの中の物を列挙する関数を呼ぶ回数も増え、 起動時に中に何が保存されているか調べる処理も増えます。 ですから、「同名のファイルはない」とわかっているのであれば、 ローカルではフォルダを作らずに一つのフォルダに全部入れてしまえば諸々効率的です。 しかし現在のところそのような選択肢は設けていません。 それをやるには、ファイル名からサーバ上のパスを引ける表が別途必要になるからです。 IAssetFileDatabaseの機能を増やさねばならず、実装の手間が増えてしまいます。 フォルダ構造が保存されていれば、ローカルのフォルダを含んだパスから サーバ上のURLが決定できますから、追加情報はいらないのです。 現段階では、インターフェイスを小さくすることを優先して、 このようにしておきました。必要があれば機能を足します。
依存関係
アセットバンドル同士の依存関係にも対応しています。 上の簡単な例にはありませんが、IAssetFileDatabaseを実装するクラスに
IEnumerable<string> GetDependencies(string fileName);
を実装する必要があります。例えば"hoge.unity3d"が"fuga.unity3d"に依存する場合、
var dependencies = database.GetDependencies("hoge.unity3d"); foreach (var dependency in dependencies) { Debug.Log(dependency); }
と書いた時に、"fuga.unity3d"が出てくるようにしておきます。
こうしておくと、hoge.unity3dからアセットをロードする時には、 自動でfuga.unity3dのロードも行われますし、 ローカルのストレージになければダウンロードも行われます。
ついでに依存関係について少し詳しく
依存関係については説明があまりありませんので簡単に説明しましょう。
例えば、hogeに入っているspriteが、fugaに入っているtextureを参照している場合、 hogeとfugaの両方のAssetBundleクラスの インスタンスを生成する必要があります。 本実装では非同期処理以外はしていないので、用いる関数は AssetBundle.LoadFromFileAsync() です。これが済んだ後に、問題のアセット、つまりこの例ではspriteをhogeの方から AssetBundle.LoadAssetAsync() でロードします。
もしここでfugaのAssetBundleインスタンスを作り忘れると、 spriteをロードしたがそこにテクスチャがくっついていない、という事態になります。 テクスチャであれば真っ白になるだけで済みますが、他のアセットだとそうは行きません。 hogeに入っているプレハブに、fugaに入っているアニメーションが差してあって、 スクリプトからそのアニメーションにアクセスする、なんてことになると、 実行時にnull例外で死ぬことになります。
依存関係は芋蔓的に何段もつながっている可能性があり、 手でコードを書くと相当面倒です。 そこでライブラリの内部で面倒を見るようにしてあります。
ついでにAssetBundleのロードとAssetのロードについて少し詳しく
AssetBundle.LoadFromFileAsync() するとAssetBundleのインスタンスができますが、 これは何をしているのでしょうか?
実際のアセットは、AssetBundle.LoadAssetAsync() しない限り使えるようにならないわけで、AssetBundleのインスタンスができたところで、 アセットのロードがされたわけではないのです。
AssetBundleがLZ4圧縮されている、ということを前提とすれば、 「AssetBundle型のインスタンスを作る」とは「AssetBundleの目次をメモリにロードする」 ことを意味します。目次だけです。 AssetBundleファイルの先頭部分には、そのファイルに どんなAssetが入っているかを記した目次部分があり、これだけをロードします。 テクスチャや音声の本体はファイルに置きっぱなしです。
そして、LoadAssetAsync()を呼んだ時に初めて、テクスチャや音声などの実際のデータを ファイルからメモリに読み込んで初期化を行います。
したがって、AssetBundleのインスタンスを作ってもアセットをロードする行程は ほとんど丸々残っています。「使う少し前にAssetBundleをロードしておいて後を高速化しよう」 と思うならば、AssetBundleをロードする所で止めず、 中のアセットのロードもやってしまう必要があります。でなければ効果がありません。
同期ロードと非同期ロード
本実装は、非同期ロードのみに対応しています。 ストレージやネットワークがどれくらい遅いかはわかりませんから、 ロードが終わるまでガツンと止める、というのはやりたくありませんし、 これを使って作る製品側チームにもやって欲しくありません。 ですので、同期ロードの機能自体を省いてあります。
いいえ。そのはずでした。
実は今回の実装をテストするために既存製品に組み込んでみた際、 同期ロードの機能なしではゲームを動かせない事がわかり、 やむを得ず同期ロードの機能も作ってしまいました。Loaderに、
LoadHandle LoadSynchronous_SHOULD_BE_REMOVED(string identifier, Type type)
という、いかにも使ってほしくなさそうな関数が用意してあります。 この関数が返すLoadHandleはisDoneがtrueであることが保証されますが、 アセットバンドル以外だと動かず、ダウンロードが済んでいないと失敗する、 といった制約があります。もし積極的な意図で同期ロードと非同期ロードを使い分けたい 方がいらっしゃるならば、名前を普通にして、 中身の実装もまともにして頂けると良いかと思います。
余談: 同期ロードの方が速い?
「非同期ロードはゲームを止めないためにあるだけのもので、 ゲームが多少止まってもいいなら同期ロードの方が速い」 と考えていらっしゃる方が多いかと思います。 なるほど、Unityの実装によっては本当にそうかもしれません。
しかし、もし実装がまともであれば、そんなことはないだろう、と私は思います。
データのロードには、大きく分けて二つの処理があります。 IOと初期化です。IOというのはネットワークからのダウンロードや、 ローカルストレージからの読み出しで、CPUを使いません。 CPUを使う初期化とは並列できます。
まともな実装であれば、複数のアセットのロードがかかった時には、 1個目のロード後に初期化をしている間に2個目のIOを行い、 2個目の初期化をしている間に3個目のIOを行い... といった具合に並列性を最大に活かすはずです。 それならば、それぞれを同期処理で待つよりも速いはずでしょう。
また、初期化に関しても、メインスレッドを使わねばならない部分は最小に留めて、 極力別のスレッドで並列で進めるように実装するはずです。 一つのアセットを複数スレッドに分割、というのは難しく効果も出ないでしょうが、 アセットが複数あればそれらを別のスレッドにやらせることは難しくありません。 したがって、初期化に関しても同時に複数投げてしまって非同期処理にした方が 速いはずだ、ということが言えます。
とはいえ、1個しかアセットがなく、1フレーム未満の時間しかかからないものに 関して言えば、確かに同期処理の方が速いでしょう。 Unityの非同期版関数は、どんなに小さなアセットであっても、 次のフレームまで完了を返さないように見えます。
var req = assetBundle.LoadAssetAsync("hoge"); while (!req.isDone){}
と書いてその場でひたすらループしていても、永遠に終わらないのです。 つまり、最低でも1フレームかかってしまうわけで、 もし1msで終わる小さなアセットであれば同期ロードの方が良い、 ということになります。
しかしさしあたり、今回の実装では全て非同期に統一しました。 アセットのサイズが一定以下なら自動で同期版を使う、 といった改良は後でもできますが、 同期で作ってしまってスパイクしたから非同期にする、 という逆の改造は後からではできないのです。
デバグ機能
状態取得
内部の状態を文字列に吐き出すLoader.Dump()を用意してあります。 画面写真には、ローカルストレージにダウンロードしたファイル数、 ローカルストレージの保存場所、ダウンロード中のファイル数、 読み込まれたアセットの数、容量、などが出ています。
また、画面に出すには適しませんが、 どんなアセット、どんなファイルがロードされていて、 それぞれの参照カウントはいくつか、 といった情報も出すことができます。 これは大変な量になるので、 ボタンを押すとログファイルに書き込まれる、 といったようにするのが良いかと思います。
ロード制限
Loaderには「これ以上メモリを使っていたら、ロードを失敗させる」 という値を設定できます。例えば、
_loader.SetLoadLimit(4 * 1024 * 1024);
とすれば、4MBを超えた状態で呼んだLoad()が全て失敗してnullを返すようになります。 開発中はこれを妥当な数字に設定しておくべきです。
どうしても、物を作るのに忙しく余裕がないと、 メモリや処理負荷のことは後回しになってしまいますが、 それがもたらすものは、 最も忙しく貴重な発売寸前の時期にメモリ削りや負荷削りをやる羽目になるという地獄です。 前もって厳しめの制限を課しておいて、 それを超えた時にすぐにわかるようにしておく方が無駄は少ないと思います。
ただし、そこには多少の強制力が必要でしょう。「警告が出る」程度だとすぐにスルーするようになります。 「コードを書き換えて制限を緩めるかデータを小さくしないとゲームが動かない」 くらいの力強さが必要です。 結局はどんどん制限が緩んでいくことになったとしても、 「どれくらいの制限にしているのか」という自覚があるだけマシではないでしょうか。
ただし、何分Unityを使っている関係上、 メモリ量のカウントはテキトーです。 そもそもアセット以外が何メガ使っているのかを知る術もありません (Unity以前はmallocから自作していたので完全に把握できたのですが...)。
さらに、アプリ組み込みのデータが支配的で、 AssetBundle等のファイルからの動的読み込みの比率が小さければ、 それだけ測ってもあんまり意味がない、ということにもなります。 シーンやプレハブに参照がささっているアセットによるメモリ消費は、 今回の仕掛けでは測定できません。
また、メモリ使用量自体の計算にも問題があります。 どうせ容量のほとんどはテクスチャだろう、ということで、 テクスチャに関してはそれなりに計算していますが、 サウンドは全部16bit無圧縮で計算していますし、 サイズがよくわからないものは一括で4KB(後で変えるかも)としています。 それでもないよりはマシです。
1GBメモリの機械は相手にしなくても良くなりつつありますが 2GBの機械はいくらでもありますし、 メモリ量を多く使えば、バックグラウンドに回った時にアプリを落とされやすくなります。 ちょっとツイートして戻ってきたらアプリが死んでて30秒かけて再起動しないといけない、 なんてゲームは、続けてもらう上で非常に不利です。 「動かなくなると困るからメモリを減らす」という考えでいると、 「動けばいい」になってしまいます。 一歩進めて「メモリ使用が小さいほど良い製品になってお客が喜ぶ」 と考えるくらいでも良いかと思います。
未実装機能と改善予定
未実装機能は以下です。
- AssetBundleのVariant。使ったことがないのでよくわからないのです。
- Resources経由とサーバからのダウンロードの切り換え。
- ローカルファイルの破壊が疑われる場合の自動破棄と自動再ダウンロード
- あるAssetBundle内の全アセットを一度にロードする機能
- ダウンロードされていないファイルだと失敗するようにする機能
- subAssetのロード
- CRCチェック
- 暗号化対応
どれも実際に必要な空気を感じたら実装することになると思います。 必要な空気を感じなければ永遠に実装しないでしょう。 特にVariantは、IAssetFileDatabaseの実装をユーザが工夫すれば、 それで済む気がします。
また改善予定があるのは以下です。
- 性能。特にGCAllocの回数。
- ストレージにあるファイルの列挙と削除の高速化
現状オブジェクトの使い回しを全くしていないので、 GCAllocの回数が結構なことになっています。 しかし、たかだかファイルやアセットの数の数倍程度の回数で、 毎フレーム増えていくわけでもない、と考えると、 それほど製品の動作に影響を与えるとは思えず、 コードを複雑化してまでやる価値はないかもしれません。 もし「同時に多量のアセットを破棄するとスパイクする」 というようなことがあれば、それは製品の質を落としますので、 対応しようと思っています。
そして、起動時に行うストレージのファイル列挙と、不要ファイルの削除は、 まだ高速化の余地があります。フォルダごとにスレッドを分割すれば、 おそらく高速化できるでしょう。 しかし実装がややこしくなりますし、 「そもそもそんなにファイル数が多いのが悪い」という話でもありますので、 正直やらずに済むならこのままでいいかなと思っています。
おわりに
二週間くらいかけて作ってしまいましたが、 実のところ弊社内でも「これを使うことが決まっている」というわけではありません。 「こんなこともあろうかと」を言うため、 勉強のため、現状の問題把握のため、といった目的です。 そういうわけで、いつ実戦投入するかわかりませんし、そもそもしないかもしれません。 しかし、これを作ったおかげでいろいろなことがわかりました。 もし弊社内で使わなくても十分今後のためになったと思いますし、 公開したことで誰かが使ってくれたり参考にしてくれたりすれば、 なおさら良いかなと思っております。
フィードバックが頂けると泣いて喜びます! 修正要望を頂ければすぐ直しますよ!
なお、現在までに行ったテストは、
- 公開しているテストアプリと、弊社製品の実データの組み合わせで耐久試験
- 弊社製品2つに試験組み込みして、不足機能を足し、なんとなく動くところまで修正
という感じです。間違いなくまだバグは残っていると思いますが、 そこそこ動くんじゃないかなあ、とは思っております。
なお、今回の記事では実装には触れませんでしたが、 ネタが尽きたり、要望があるようでしたら、 実装についても記事を書くこともあるかと思います。