土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity CSVファイルを読み込むシンプルな汎用ロジックを書いた

 csvファイルをUnityで読み込む時、毎回、一部だけ異なる似たようなコードを書いてしまいます。その頻出処理をイテレーター化したので紹介します。

Unityでcsvファイルを操作する動機

 ゲームで使うデータ、例えばアイテムやモンスターなどのデータは、1個当たりが持つパラメータの数(と型の種類)が多く、またデータの数自体が数百から場合によっては数千個と非常に多くなるため、開発中はスプレッドシートで管理しています。
 このデータは最終的にはバイナリファイルにコンバートし、それをプログラムから読み込む形になります。ただ、検証作業などではいちいちバイナリ化するのは面倒です。データのサイズや更新頻度次第ではハードコーディングしても良いでしょうが、更新のたびに再コンパイルするのは避けたい場合もあります。
 こういう時に、csv、つまりカンマ区切りのテキストファイルでサクッとデータを用意し、それをC#でパパッと読み込めれば便利なわけです*1。かつ、汎用的に使い回せるコードが書けると更に嬉しいというのが今回の動機です。

よく書きがちなコード

 UnityでCSVを読む場合、土屋が良く書いていたのはこういうコードです。

List<(int, string)> data = new();

static void CsvReader(string path, List<(int, string)> data)
{
    //①csvファイルをTextAssetとして読み込み、StringReaderに格納する
    var textAsset = Resources.Load(path) as TextAsset;
    StringReader reader = new StringReader(textAsset.text);

    //②全ての行をReadLineするまで
    while (reader.Peek() != -1)
    {
        string row = reader.ReadLine(); //一行読み込み
        //③カンマで区切り、文字配列とする
        var columns = row.Split(',');

        //④各要素を適宜変換してListに追加
        data.Add((int.Parse(columns[0]), columns[1]));
    }
}

 操作対象のcsvファイル*2は2列構成で、1列目に整数、2列目に文字列が格納されている想定です。
 このコードは普通に動きますが、フォーマットの異なるcsvを読み込みたい場合、④の部分だけを変えたメソッドを別に作る事になり、よろしくないなと思っていました。そこで、この処理全体をイテレーターメソッドに組み直し、④の部分だけをくくり出せるようにしました。ついでに気になっていた箇所を修正しました。

※なお、そもそもResources.Load()を使うべきじゃないという話もあるかと思います。作業が楽なので今回は見逃して下さい……。

サンプルコード(抜粋)

    private static IEnumerable<string[]> CsvReader(string assetName)
    {
        //①csvファイルをTextAssetとして読み込み、StringReaderに格納する
        TextAsset textAsset = Resources.Load<TextAsset>(assetName);
        using var stringReader = new StringReader(textAsset.text);

        var row = stringReader.ReadLine(); //一行読み込み

        //②全ての行をReadLineするまで
        while (row != null)
        {
            //③カンマで区切り、文字配列とする
            var columns = row.Split(',');

            //foreachブロックに文字配列を投げる
            yield return columns;

            row = stringReader.ReadLine(); //一行読み込み
        }
    }

 CsvReader()イテレータメソッドは以下の様に使います。

    private readonly List<(int, string)> data = new();

    void Start()
    {
        foreach (var columns in CsvReader("master"))
        {
            //④各要素を適宜変換してListに追加
            data.Add((int.Parse(columns[0]), columns[1]));
        }
    }

 処理全体をイテレーター化したので、foreachで巡回して、各行について処理を施せるようになりました。異なるフォーマットのcsvを操作する際にも対応が容易なのがわかるかと思います。
 以下、他にもコードを微調整した箇所について。

Resources.Load()はジェネリクス版を使う。

TextAsset textAsset = Resources.Load<TextAsset>(assetName);

 ネットで検索すると、Resources.Load()の戻り値をキャストやas演算子で型変換するコードが結構ひっかかりました(というか自分もそう書いてた)。Resources.Load()メソッドにはジェネリクス版が用意されているのでそちらを使いましょう。

StringReader.Peek()を使わない。

 こちらは趣味の問題な気もしますが。

var row = stringReader.ReadLine(); //一行読み込み
while (row != null)
{
    //...
    row = stringReader.ReadLine(); //一行読み込み
}

 元のコードではStringReader.Peek()が-1を返すかどうかでファイル終端を確認していたんですが、ここでマジックナンバーが出てくるのがどうにも気に入らず、StringReader.ReadLine()の結果がnullかどうかで判定する事にしました。その結果、StringReader.ReadLine()を2回書く事になっていますが、まあこれはいいか……?

終わりに

 ロジックを奇麗にくくり出せたので満足です。とはいえ気になる所が幾つかアリ。

 一つは③で行ごとに文字配列が生成されている点。他に書きようが思いつかないけど、メモリ効率的に大丈夫なんだろうか? 本来はSplit()は使わず、コード側でパースしてカラムを切り出すべきなんでしょうが、それだと汎用foreachにならないし……。その場合は既製品のシリアライザを使用するのが良さそうです。

 もう一つは④でカラムごとにキャストして格納してる点。これどうにかならないんだろうか。まあ、生csv使ってるのが悪いって話ではあるんですが。あとホントはタプルではなく構造体なりクラスなりにした方が良いと思います。

サンプルコード(完全版)

 GitHubにはサンプルのcsvファイルを含めて全部アップしてあるのでそちらもどうぞです。実行するとResources/master.csvを読み込んでコンソールに出力します。
github.com

補足

補足1:スプレッドシートって言うかexcel

 冒頭でスプレッドシートと書きましたが実質excel一択です。Google SpreadSheetだとパフォーマンス上数千行のシートの管理が難しいのと、ファイルを単位にした方がソースコードとバージョンの同期を取るのが容易だからです。また、扱うスプレッドシートの数自体も数十~百数十になり得るので、Webアプリで制御するのは現実的では無いと(土屋は)考えています*3
 ただ、どれも土屋が昔検証した時の話なので、今は状況が違うかもしれないので運用してる人いたら教えて欲しい。

補足2:ScriptableObjectを使わないの?

 土屋はこういうケースではScriptableObjectを使いません。今回の様なデータ構造を表示するのが面倒という事もありますが、数千行のデータになった時に作業効率や実行効率のトレードオフが成立するのか不安があるためです。
 また、インスペクタに表示されたデータは不用意に更新(かつ保存)しかねない為、マスターデータの作成には向いていないのではないかと考えています。
 ただ、実行効率についてはパフォーマンス計測してみないとわからないので、いずれやらなきゃと思っています(というか誰か既にやってくれてたりせんか)。

注意:cvsはutf-8で保存する。

 TextAssetはShift-JISで保存されたcvsを正常に読み込めない為、utf-8で保存する必要があります。これすごくやりがちなので気を付けましょう。

参考リンク

learn.microsoft.com
 StringReader巡回時のnullによるファイル終端チェック(結果、ReadLine()を2回書く事になるアプローチ)は、こちらの記事を参考にしました。マイクロソフトが提案してるんだから、真っ当なコードなのだろう多分。

*1:イージーハックを気にしない場合、この形式のままリリースしている物もあるかもしれません

*2:このファイルはResourcesフォルダ配下に配置する

*3:だからOfficeもWeb版ではなくアプリ版を使います