MasterMemoryの小ネタ

MasterMemoryがv3になりました。めでたい!
ということでMasterMemoryの小ネタを2つ書いてみます
すでに使いこなしている方には退屈な内容かもしれませんがそこはご容赦 github.com

小ネタ1. 複合キーの一部を指定して範囲取得する

テーブルは以下のとおりです. (公式githubにあるやつ)

public enum Gender
{
    Male, Female, Unknown
}

[MemoryTable("Person"), MessagePackObject(true)]
public record Person
{
    [PrimaryKey]
    public int PersonId { get; set; }

    public Gender Gender { get; set; }

    public int Age { get; set; }

    public string Name { get; set; }
}

このテーブルに対して以下の要件があるとします

  • Genderだけを指定して抽出したい
  • Gender指定で抽出されたレコードは常にAge順にソートされていて欲しい

まずは素朴な実装をしてみましょう

MasterMemoryの自動生成コードで"Genderだけを指定できるメソッド"を生やすには GenderSecondaryKeyを貼ります

    [SecondaryKey(0), NonUnique]
    public Gender Gender { get; set; }

これで、FindByGenderが生えてくれますが、Age順にソートされている保証がないので、ソートまでするメソッドをPersonTableに生やします

//PersonTable.Partial.cs

public Person[] FindRangeByGender(Gender gender)
{
    return FindByGender(gender)
        .OrderBy(x => x.Age)
        .ToArray();
}

毎回ソートするコストはありますが、一応要件は満たせます

しかし、これ正直気持ち悪いですよね
MasterMemoryはインデックス順にソートした配列を保持してくれるのだから、
GenderAgeに複合インデックスを貼ってGender指定で抽出したい
と感じるはずです
そうすれば、Ageのソートに毎回コストを払う必要はありません

これを実現するのは結構簡単です
MasterMemoryはminとmaxを指定した範囲抽出メソッドを生やしてくれるのでそれを使います.

まずSecondaryKeyは複合インデックスに変更します

[SecondaryKey(0, keyOrder: 0), NonUnique]
public Gender Gender { get; set; }

[SecondaryKey(0, keyOrder: 1), NonUnique]
public int Age { get; set; }

Genderを指定して抽出するコードは以下のようにします

//PersonTable.Partial.cs

public RangeView<Person> FindRangeByGender(Gender gender)
{
    return FindRangeByGenderAndAge(
        min: (gender, int.MinValue),
        max: (gender, int.MaxValue)
    );
}

後ろのキーにint.MinValueint.MaxValueを指定することで、すべてのAgeを含めることができるのでGenderのみ指定と同じになります そして、Ageでのソート順も保証されます。ソートするコストはインデックス構築時の一回だけです

この方式の注意点として、MinValueMaxValueが定まっていない型では使えないので、
stringは複合キーの先頭にしか使えません. stringMaxValueが謎だからです
もしかしたら存在するのかもしれませんが、超巨大文字列だったらと思うと怖くて使えません

小ネタ1は以上です

小ネタ2. EnumをキーにするとGC.Allocするので注意

EnumのComparerがしょぼいので、Compare毎にGC.Allocしてしまうという悲しい現実があります
実際に計測してみましょう. Unity6のエディタで実行しています

var people = new Person[]
{
    new Person{ PersonId = 0, Gender = Gender.Male, Age = 9, Name="P0" },
    new Person{ PersonId = 1, Gender = Gender.Male, Age = 11, Name="P1" },
    new Person{ PersonId = 2, Gender = Gender.Female, Age = 10, Name="P2" },
    new Person{ PersonId = 3, Gender = Gender.Female, Age = 23, Name="P3" },
    new Person{ PersonId = 4, Gender = Gender.Male, Age = 19, Name="P4" },
};

var builder = new FooDatabaseBuilder();
builder.Append(people);
var bits = builder.Build();

var db = new FooMemoryDatabase(bits);

Profiler.BeginSample("FindRangeByGenderAndAge");

var maleView = db.PersonTable.FindRangeByGenderAndAge(
    min: (Gender.Male, int.MinValue),
    max: (Gender.Male, int.MaxValue)
);

Profiler.EndSample();

これを回避するのは結構面倒くさいですが、
方法の一つとしてはEnumのUnderlyingTypeにキャストしてからCompareさせるという手が考えられます
つまり、MasterMemoryが生成している

this.secondaryIndex0Selector = x => (x.Gender, x.Age);

this.secondaryIndex0Selector = x => ((int)x.Gender, x.Age);

にできればGC.Allocは消えてくれるわけです.

ですが、言うは易くというやつで、残念ながらこれを実現するにはGeneratorを改変するか、EnumがかかわるIndexについては自動生成を諦めて手書きするかの二択です.

手書きする場合の手順を以下に記載します.

EnumがかかわるIndex定義を消します. ここではコメントアウトしました

[MemoryTable("Person"), MessagePackObject(true)]
public record Person
{
    [PrimaryKey]
    public int PersonId { get; set; }

    //[SecondaryKey(0, keyOrder: 0), NonUnique]
    public Gender Gender { get; set; }

    //[SecondaryKey(0, keyOrder: 1), NonUnique]
    public int Age { get; set; }

    public string Name { get; set; }
}

つぎに、これまで自動生成されたコードをPartial側に書きます
コンストラクタにあった内容はOnAfterConstructに記載し、readonlyは除外、Gender型はint型に変更、値はキャストします

    public partial class PersonTable
    {
        //readonlyは外さざるをえない
        Person[] secondaryIndex0;
        //Genderの型をUnderlyingTypeに変更
        Func<Person, (int Gender, int Age)> secondaryIndex0Selector;

        partial void OnAfterConstruct()
        {
            //GenderをUnderlyingTypeにキャスト
            this.secondaryIndex0Selector = x => ((int)x.Gender, x.Age);
            //Genderの型をUnderlyingTypeに変更
            this.secondaryIndex0 = CloneAndSortBy(this.secondaryIndex0Selector, System.Collections.Generic.Comparer<(int Gender, int Age)>.Default);
        }

        public RangeView<Person> SortByGenderAndAge => new RangeView<Person>(secondaryIndex0, 0, secondaryIndex0.Length - 1, true);

        public RangeView<Person> FindByGenderAndAge((Gender Gender, int Age) key)
        {
            //Genderの型をUnderlyingTypeに変更したkey2を使う
            var key2 = ((int)key.Gender, key.Age);
            return FindManyCore(secondaryIndex0, secondaryIndex0Selector, System.Collections.Generic.Comparer<(int Gender, int Age)>.Default, key2);
        }

        public RangeView<Person> FindClosestByGenderAndAge((Gender Gender, int Age) key, bool selectLower = true)
        {
            //Genderの型をUnderlyingTypeに変更したkey2を使う
            var key2 = ((int)key.Gender, key.Age);
            return FindManyClosestCore(secondaryIndex0, secondaryIndex0Selector, System.Collections.Generic.Comparer<(int Gender, int Age)>.Default, key2, selectLower);
        }

        public RangeView<Person> FindRangeByGenderAndAge((Gender Gender, int Age) min, (Gender Gender, int Age) max, bool ascendant = true)
        {
            //Genderの型をUnderlyingTypeに変更したmin2, max2を使う
            var min2 = ((int)min.Gender, min.Age);
            var max2 = ((int)max.Gender, max.Age);
            return FindManyRangeCore(secondaryIndex0, secondaryIndex0Selector, System.Collections.Generic.Comparer<(int Gender, int Age)>.Default, min2, max2, ascendant);
        }

        //
        // 自動生成の改修ではない、自前の追加コード
        //

        public RangeView<Person> FindRangeByGender(Gender gender)
        {
            return FindRangeByGenderAndAge(
                min: (gender, int.MinValue),
                max: (gender, int.MaxValue)
            );
        }
    }

効果があるかを計測します. 初回だとよくわからないAllocが入ったため、2回計測しました

Profiler.BeginSample("FindRangeByGenderAndAge-0");

var maleView = db.PersonTable.FindRangeByGenderAndAge(
    min: (Gender.Male, int.MinValue),
    max: (Gender.Male, int.MaxValue)
);

Profiler.EndSample();

Profiler.BeginSample("FindRangeByGenderAndAge-1");

var femaleView = db.PersonTable.FindRangeByGenderAndAge(
    min: (Gender.Female, int.MinValue),
    max: (Gender.Female, int.MaxValue)
);

Profiler.EndSample();

2回目のAllocが0になったので効果が確認できました.

EnumのAlloc対策は、ゲームアプリケーションの初期化時など
マスタをフェッチしまくるタイミングにおいて結構なチューニング効果になることがあります.
私の携わったアプリでは1フレあたり10MB以上のAlloc削減につながり、画面のカクツキも軽減されました.

小ネタ2は以上です.

おわりに

MaterMemoryはソート済みのインメモリ配列に対するBinarySearchをコンセプトにした 高速で省メモリで使いやすいライブラリです.
自前でDictionaryをどかどかつくったり、ScriptableObjectに保存した配列に線形検索かけるよりも絶対に良いです.
v3のSourceGenerator対応により、実装時のトライアンドエラーもやりやすくなりお手軽さが増しています.
私はv1から使っていますが、ずっと惚れ込んでいるライブラリです. 多くのUnityプログラマにお勧めします.

2022年の振り返り

仕事関連

個人事業主一年生存

メインの取引先と切れていないのでサバイバルしたぜ感はまったくないですが、つつがなく一年生存しました。安定は良いこと。

特に仕事を詰め込もうとはしていませんが、メインの仕事+αな感じで、頼まれたら空き時間にお手伝い的な仕事を増やしたりはしました。

やってみて感じたのはどうせやるなら成長につながる仕事がいいなぁということ。ITの仕事なんてのはいつも日本のどこかで火を噴いていて、私のスキルが役に立つ場面というのはそりゃああるわけですが、手持ちのスキルを適用していくだけである程度の改善を見込める仕事というのはいまいち成長につながらず、時間を売ってお金に換えてるだけな気がしてしまうんですよね。

仕事をいただけること自体はありがたいことですし手伝って欲しいと言われて邪険にしようとは思わないですが、こちらから頭を下げてでも入りたいという仕事も見つけていかねばと感じました。

メインのお仕事について

モバイルゲーム、リリースから一年たちましたがまあいろいろありました。最初の半年は「これ直さないとサービス終わるぞ」レベルのやつをずっと直してましたね。最近になってようやくやりたいこと(チューニング)に時間をさけるようになってきたので、どんどんユーザーが快適に遊べるようになってくると思います。次の半年である程度出し切れればいいなと思っています。本プロジェクトにおける私の個人目標は十分速いアプリを作ることでしたので、最低限そこまでやらないと数年間捨てたことになりますからね。半端なところでは投げ出せない。

「ここまで作りこまないとゲームとしてまともな性能はでないんだぜ」という基準をきちんと自分の中で確立すること。確立した基準でもって基盤をととのえ、ドキュメントを整備し、他のメンバーの緩いコードに対しては否と言えること。なあなあではいけません。自分の基準に自信がないと「このコードは遅いかもしれないが一旦これで行ってみてダメだったら直そうか」なんて曖昧な判断が増えてしまいます。それが増えるほど終盤の修正では手がまわらなくなります。

リリースしたそれなりのランキングのタイトルの保守運用に関われるというのは、特に性能面の経験を積むのに非常に価値があります。最大限に活かしたいところです。

技術

2021年の振り返りで書いたことがまったくできていない衝撃…いや、触りはしたんだけど思ったより手触りが悪かったというか時期尚早な気がするものが多かったんですよね。Visual Scriptingとか。一日触って放り投げてしまった。

Unity ECSはversion1.0に向けて大きな動きがありましたね。まだExperimentalではありますが、実験プロジェクトではなくちゃんとリリースへの道筋を見せてきたことは評価したいと思います。1.0向けの機能セットも出そろったので学ぶには良い時期です。私がサボっているだけで。

Blazorまわり。全然やってネェ。切腹

なんだかんだで一番触ったのはC++/WinRTとかWinUIな気がします。こちらはv1.2がリリースされUWPからの移行先としての機能は出そろいつつありますね。Content Island(XAML Island)周りがまだExperimentalですが、さすがにv1.3には入るのではなかろうか。

そこまで来たら次はWPFからの移行先としてふさわしい機能が増えてくるはずです。v1.4(2023/11ぐらい?)で片鱗が見えるといいなと思っています。はたしてWindowsのクライアントUIフレームワークの長い混乱を収めることができるのでしょうか。インストーラの品質がひどいのも直してくださいね。

生活、健康面

片手20kgの可変重量のダンベルx2を5月に買ってからぼちぼち筋トレを続けております。腕や胸はすこしずつゴツくなってきました。1年で体重はあまり落ちなかった(-5kg)のですが、健康診断の数値は大きく改善されたので効果はあったのでしょう。来年は月1kgぐらいのペースで体重を落としたいですね。目指せ標準体重。

生活リズムはまたちょっと悪化してきました。慣れって怖い。仕事には悪影響ないようにしていますがもうちょっと朝型になりたい。

趣味

ピアノ丸二年を過ぎて成長鈍化を実感。中級者への壁みたいなものを感じます。ちゃんとした先生に師事するのが良いのでしょうけどうちにあるのMIDIキーボードだしな… もともとの目標である作曲に役立つ最低レベルの演奏力というのは達成しているので、真面目にやるかは悩み中です。

9月に自分への誕生日プレゼントということでエレキギターを買いましたが、夜に音鳴らせないので頻度は微妙。シールド挿すところが硬くて個体の不良っぽく、PCのアンプシミュに挿して引いてみても7khzより上が出なくて抜けが悪く、なんか変なところがあるんだろうなという感じ。扱いが難しい子になってしまっています。そのうち修理にだしてみようかな。

作曲の真似事というか、メロディを作ることができるようになってきたのでDAWを触る時間が増えました。メロディを作れるようになると、今度は編曲とかミックスとかマスタリングとかいろいろあって音楽は奥が深いのだなと遠い目になっているところです。Black Fridayでソフトは十分すぎるほど整ってしまったので、来年は学びながらなんか出せるといいなと思っています。

2023年もよろしくお願いします。

SampleTank4の不具合

2022/12/29 SampleTank4 version 4.2.3時点で私が遭遇した不具合です。 IK multimedia のサポートには連絡済み。 そのうち修正されることを期待します。

当環境

Windows 11 22H2

DAWでSampleTank4を使ったプロジェクトを開く際にSampleTank4.vst3がエラーで落ちる

これは、10GBを超える特定の音色を使用していると発生します。

回避方法

単に上記音色を使わなければOKです。ものによっては、軽量版であるところのSE音色がありますのでそれを使えばよいでしょう。

  • C7 Grand Binaural - Natural SE (3.2GB)
  • C7 Grand Close Mic - Natural SE (3.1GB)
  • C7 Grand Coincident Mics - Natural SE (3.1GB)

発生すると該当のSampleTank4のインスタンスを消してから立ち上げなおしになりますので、音色やエフェクトはすべて設定しなおしとなり、なかなかのストレスですが回避が容易なのでさほどきつくありません。C7 Grand Binaural - Naturalは好きな音色なので気兼ねなくリッチなほうを使えるようになると嬉しいなぁとは思っています。

音色切り替え時に稀にSampleTank4が落ちる

発生確率は低いですが、すでに音色をロード済みのPartに別音色を読み込み、つまり切り替え時にSampleTank4が落ちることがあります。

回避方法

私の知る限りではありません。

回避できないですが、Studio Oneはこのエラーが発生しても踏ん張ってくれるのでさほど困っていません。発生してもちょっとウザい程度です。 DAWによっては発生時にDAWごと死ぬので結構なストレスになるかもしれません。

余談

IK multimediaのフォーラムにもお怒りの方がいらっしゃるので、私の環境だけではなさそう。 https://cgi.ikmultimedia.com/ikforum/viewtopic.php?f=12&t=23636#p100238

SampleTank4世代の音色はROUND ROBIN表示が逆である

Edit画面でROUND ROBINのON/OFFができますが、SampleTank4世代の音色は表示が逆なバグがありそうなので注意が必要です。 ROUND ROBINがあるとウリになるはずのドラム音色で初期値OFFなのでわけわからんなと思っていたらただの表示バグっぽい。 SampleTank3世代のドラム音色はROUND ROBINがONだったので、もしかして4になって劣化したかと心配しましたが、実サウンドのほうは問題なさそうです。

DAWでSampleTank4を演奏したときにプチノイズが発生

これはSampleTank4の不具合ではなく私のオーディオ設定の問題でした。 SampleTank4は全然悪くないのですが、参考になる人がいるかもしれないので記載しておきます。

SampleTank4の推奨バッファは最低でも1024です。

この値をオーディオインターフェースに設定することで直りました。単に私が切り詰めすぎていただけなのです。

一部音色をロードできない

SampleTankというよりはIK Product Managerによるサウンドのインストールに問題があると発生します。 インストールの不備に関しては別途記載しています。

enrike3.hatenablog.com

Total Studio 3.5 MAXのインストール

Black Fridayなので、評判がよくて長く使えそうなMODO BASS 2を買ったのですが、そこから

「Amplitube 5 MAX 87%オフ!ギターも買ったことだしアンプシミュも持っておくか!」

「え、MAX製品もってたらTotal Studio 3.5 MAXも87%オフなの?」

と87%OFFの連鎖につられて気が付けばTotal Studio 3.5 MAXを丸っとMy New Gearしてしまっていました。IK商法おそろしい…… なお先にTotal Studio 3.5 Maxを買ってからMODO MAXでMODO BASS 2を買うのが最安だった模様。これがDTM界隈の黒金か。

というわけで大量のソフトウェアを手に入れてしまったのですがインストールがすんなりはいきませんでした。

同じように激安黒金に釣られてTotal Studio MAXを買ったけどいまいちちゃんと動作しない、という人が他にもいるかもしれないのでここにログを残します。

私の環境だけで上手くいかない(いわゆる「おま環」)ということもあるでしょうから、この記事をもって製品を批判する意図がないことは明記しておきます。

この記事の要約

  • webのMy Productをみて、IK Product Managerが落としたファイルが不足ないか確かめよう
  • LibraryのUpdateを当てよう(SampleTank4 / Miroslav Philharmonik2)
  • Syntronik2については、不足ファイルを手動ダウンロードすることはあってもそのインストールはIK Product Managerに任せよう

当方の環境

Windows 11 22H2

IK Product Manager でインストール

インストールとAuthorizeは基本的にIK Product Mangerを使います。 ダウンロードするものはソフトウェア本体とサウンドファイルですが、ソフトウェア本体のダウンロードとインストールはIK Product Manger経由で全く問題はありませんでした。

困ったのはサウンドファイルのほうで、SampleTank 4 MAXとSyntronik 2のサウンドファイルが一部インストールされませんでした。IK Product Mangerは正常にインストールされたと言い張るのですが、実際にSampleTank4やSyntronik2のStandalone版を立ち上げて音色を読み込もうとすると読み込めない奴がでてきます。VST3として使っても同様です。

修正方法

サウンドファイルは、IK multimedia公式サイトにログインして、My Product→該当製品→Souds Downloadをたどることで手動で落とすことができます。

このWebからのダウンロード一覧と、IK Product Mangerがローカルに落としたzipの一覧を突合して不足分は手動でダウンロードします。 ローカルのダウンロードパスは、たとえばSampleTank4 Maxであれば、

%UserProfile%\Documents\IK Multimedia\IK Product Manager\SampleTank 4 MAX

にあります。IK Product Manager上でダウンロード先を変更している場合はそちらを参照します。

私のケースでは、以下のファイルを手動で落とす必要がありました。

SampleTank4 - SampleTank_3_Sound_Content_Part_5.zip - SampleTank_3_Sound_Content_Part_6.zip

Syntronik2 - SYN で始まるファイル多数 (SYN2のほうは大丈夫だった)

zipを全量揃えたら、IK Product Manager上でre-installします。おそらくこれで直るはずです。

LibraryのUpdateについて

webのMy Product → SampleTank4 Max → Sounds Library Updateに、音色のアップデートパッチ(2022/12/29時点ではUpdate 1.5)があります。 多くの木管や弦などの音色にKeySwitchの使いやすいやつが増えるのでこれは適用したほうが良いものですが、 おそらくですが、IK Product Manager経由でこれのパッチを当てる方法はありませんので手動でやることになります。私は手動でやりました。

Miroslav Philharmonik 2のほうにも、Download ResourcesのほうにLibrary Updateがあります。なぜ統一されていないのか… これも手動であてました。

Syntronik2の手動インストールについて

サウンドのzipファイルの中にはインストーラのexeが同梱されています。

基本的にはこのexeをたたけば手動でインストールしていくことが可能です。 ただし、Syntronik2に関しては手動インストールの難易度が高いです。 Syntronik2は、製品の構成としてSampleTank3世代とSampleTank4世代のハイブリッドになっているため、インストーラのインストール先が分裂しがちなのです。

といってもぱっとは理解しにくいでしょうから詳細に入ります。

SampleTankはIK multimediaの他の製品をSampleTank内で扱える

SampleTankはIK multimediaの他の製品をSampleTank内で扱えます。具体的には以下です。

  • Miroslav Philharmonik2
  • Syntronik2
  • SampleTron2

これら、他の製品をSampleTank内に取り込む仕組みが、SampleTank3世代とSampleTank4世代で異なります。

SampleTank4世代の他製品取り込み

SampleTank4の中のSoundContentの中に、取り込む製品のパスを追加します。(つまり複数のパスを持てる) これらのパスはレジストリの「HKEY_CURRENT_USER\Software\IK Multimedia\SampleTank 4」の最大32まで持てるLibraryPathの中に保存されます。

SampleTank3世代の他製品取り込み

SampleTank3世代の製品は、SampleTank4と違いサウンドのLibraryPathを一つしかもてません。 レジストリの「HKEY_CURRENT_USER\Software\IK Multimedia\SampleTank 3」の中をみても1つのパスしかないはずです。

つまり、複数製品を取り込むには、同じフォルダにコンテンツを入れるしかありません。

例としてMiroslav Philharmonik 2があります。これはSampleTankの拡張音源ではありませんが、連携のためSampleTank3のフォルダに入ります。

Syntronik2は二世代ハイブリッド

Syntronik2は、初代Syntronikの音資産(SampleTank3世代)と新規パッチ(SampleTank4世代)が混ざった世代ハイブリッド製品となっています。 これはInstrumentsの拡張子をみるとわかります。

Factoryは初代Syntronikの音色ですが、拡張子がst3iとなっています。これはSampleTank3Instrumentを表します。

Syntronik2フォルダは、名前の通りSyntronik2世代の音色が入っていて、拡張子はst4iです。

この2世代のファイル群は、ダウンロードするzipファイルの時点で分かれています。下記画像の左右のファイル群は同じフォルダに入っています。 SYN_xxx.zipが初代Syntronikの、つまりSampleTank3世代のzip、SYN2_xxx.zipがSyntronik2の、つまりSampleTank4世代のzipです。

SYN_xxx.zipの中のインストーラーはSampleTank3世代なのでファイルをSampleTank3フォルダに入れようとしますが、SYN2_xxx.zipの中のインストーラーはSampleTank4世代なので、ファイルをSyntronik2フォルダに入れようとします。

これがSyntronik2の手動インストールが難しい理由です。何も知らずにやるとインストール先が割れてしまいます。

IK Product Managerはここをちゃんと処理しますから、Syntronik2に関しては、不足ファイルを手動ダウンロードすることはあってもインストールはIK Product Managerに任せたほうが良いでしょう。

おまけ:Miroslav Philharmonik 2 CEを消したい

Miroslav Philharmonik 2 CEは、Miroslav Philharmonik 2の音色削減版で、SampleTank4 MAXに含まれます。 Total Studio 3.5 MAXにはMiroslav Philharmonik 2が含まれるので、SampleTank4 MAXとMiroslav Philharmonik 2をインストールするとCEの音色は重複します。 これを消したい。

SampleTank 3 のインストールフォルダにあるMiroslav Philharmonik系のファイル、フォルダを片っぱしから消して、Miroslav Philharmonik 2のサウンドをre-installするのがベストです。(re-install後はLibrary Updateも忘れずにどうぞ)

InstrumentsやSamples、Library Infoは、CEとCEじゃないほうが明確に分かれていますが、Patternsフォルダの中身はCEとCEじゃないほうのファイルがごっちゃになってしまっています(CEフォルダに分離されていない)。同名のファイルすらあるので、Patternsだけは、CEのファイルだけを綺麗に消すことは不可能です。

PatternsもCEフォルダを作って、CEのファイルはそこに入れてくれるようにサポートに要望は出しました。

HSTRING完全に理解した

HSTRINGというのはUWPやWindowsAppSdkで使われる、Windows Runtimeの文字列です。 Windows APIでは文字列の種類がいろいろあります。LPTSTRやらBSTRやら……これらの新顔で、オーバーヘッド大き目のかわりに柔軟です。

HSTRINGは基本的には<winstring.h>ヘッダーに含まれる WindowsCreateString APIで作成します。

HSTRING str = nullptr;
auto hr = ::WindowsCreateString(L"hoge", 4, &str);

このときヒープに以下のようにメモリが確保されます。

HSTRING_HEADERは、hstring.hに以下のように定義されています。 内部にポインタがあるので、32bit環境と64bit環境ではサイズが異なります(20byteと24byte)。

// Declare the HSTRING_HEADER
typedef struct HSTRING_HEADER
{
    union{
        PVOID Reserved1;
#if defined(_WIN64)
        char Reserved2[24];
#else
        char Reserved2[20];
#endif
    } Reserved;
} HSTRING_HEADER;

byte数だけを定義しているようなものなので、画像内のHSTRING_HEADER内のレイアウトやメンバ名は私が勝手につけたものになります。正確さは保証しません。

HSTRINGを使い終わったら、WindowsDeleteString APIで参照カウントを減らします。参照カウントが0になったら削除されます。

メモリレイアウト的には、HSTRINGHSTRING_HEADER構造体へのポインタであり、HSTRING_HEADER内のptrRawBufferポインタがWCHAR*のNULL終端文字列を指していれば成立します。 よって、プログラマが任意の場所にHSTRING_HEADERとWCHAR*のNULL終端文字列分のメモリを確保すれば作成することができます。 スタック、つまりローカル変数を用いてもOKです。スタックを用いる場合はヒープにメモリを確保するコストがないのでfast stringなどと呼ばれるようです。 メモリを自前で管理してHSTRINGを作成するには、WindowsCreateStringReference APIを使用します。

wchar_t raw[5] = L"hoge";
HSTRING_HEADER header{};
HSTRING str = nullptr;
hr = ::WindowsCreateStringReference(raw, 5, &header, &str);

bFastStringのフラグがたって1になり、HSTRING_HEADERとRawBufferが不連続となり、WindowsCreateStringのときは間にあった参照カウント領域が存在しません。

WindowsCreateStringReferenceは渡されたメモリがどのように確保されたかを知りませんから、WindowsDeleteStringが適切にメモリを解放することは不可能なので、そもそも管理できないなら参照カウントもなくしてしまえということでしょう。実際にWindowsCreateStringReferenceで作成されたHSTRINGをWindowsDeleteStringに渡しても何もおきません。HSTRING_HEADERとRawBufferのメモリについてはWindowsCreateStringReference呼び出し側が解放の責務を負います。スタックを用いた場合はもちろん明示的な解放は不要です。

スタックから作成したHSTRINGを戻り値で返したい場合は、解放されてしまうのでそのまま返すわけにはいきません。その場合はWindowsDuplicateString APIで文字列を複製します。WindowsCreateStringReferenceで作成されたHSTRINGを複製すると、同内容のものがWindowsCreateStringで作られたときと同じようにヒープに作成され、参照カウントで管理されます。

HSTRINGはImmutable、つまり不変なのでWindowsCreateStringで作られたHSTRINGに対してWindowsDuplicateStringを呼ぶと、複製が作られずに参照カウントだけが増えます。WindowsCreateStringReferenceで作成されたHSTRINGの場合は、寿命管理外のものを寿命管理下のものに変更するためにコピーをしますが、もともと寿命管理下のものはコピーする意味がありません。

ボタンの命名は連番!?

Twitterで話題になったこちらについて。

「用途別ではなく機械的に名前をつけるべき」という内容に対して各所から非難轟々だったのですが「ちゃんとした名前つけるのが当然だろ」みたいな反論ばかりだったのが気になりました。

そもそも用途と見た目は独立して取り扱うべきものです。

「一つの用途のUI部品に多数の見た目があるとユーザーが混乱するので避けるべき」という一般論があるのは理解していますが、それでも現実の問題として見た目にはバリエーションがあります。スマホゲームにおいてその最たるものは画面遷移ボタンだと思います。グローバルメニューにあったりヘッダーフッターにあったり、画面内のボタンにあったりし、また重要機能(ガチャやメインコンテンツなど)への導線の見た目が盛られたりと、とても多様になりがちです。

逆に、一つの見た目がさまざまな用途のボタンに割り振られることもあります。

f:id:enrike3:20220222173006p:plain

この図はプリコネから抜粋した例ですが「入手方法」と「キャンセル」が同一用途だと言い張る人はまずいないでしょう。汎用系のスタイルは多用途に横展開されるのが常です(それが汎用系ということですからね。トートロジー)。

冒頭のTwitterの例をみてみると、「用途別」の命名を独立した要素であるはずの「見た目」の問題で否定しているのがそもそもの間違いで、二つの世界が混ざってしまっています。見た目の横展開が楽な見た目都合の連番方式というのは、ロジックを組む用途側の都合を考慮していないということで、変数名であったりコーディング側の感覚で反論が来るのです。

もし「青ボタンスタイル」の見た目のボタンを用途別にOKButton, SubmitButton、SceneTransitionButton…として別々にアセットを作っていたら、デザイナさんが「青ボタンスタイルを更新したい」というときに全部直す必要がでて大変です。当たり前ですがデザイン側は一元管理したいのであり、そのニーズを一方的に無視するわけにはいきません。一方で見た目単位のアセットを正とし、BlueButton1の単位でアセットを各シーンに配置してしまうと、OKButtonだけスタイル差し替えたい、というときに全箇所修正が必要ということになって非常に厳しいことになります。

見た目ボタンと用途別ボタンはどちらも別々に作り、用途別ボタンに見た目を割り当てるということができればベストです。WPFなどのフレームワークならBasedOnプロパティを使ってStyle継承ができますので見た目重視のStyleを継承して用途単位でサブクラス化できます。Unityの場合UI向けのStyle機構がないので、用途別ボタンのPrefabに見た目Prefabを組み込むとか、ツールを使ってスタイル一括適用とかになるでしょうか。とはいえ用途別も見た目別も全部作るなら管理するアセット数は最大になるので、どちらか片方だけを使うというプロジェクトも多いはずです。

さて、見た目ボタンを作るなら命名は連番とちゃんとした名前付けどちらが好ましいのかですが、用途から切り離して名前を付けるというのはなかなか難しいところがあります。物理的なプロパティをかき集めてbutton-blue-frame02-r16 みたいな方法が考えられますがデザイン変更したら名前が変わることになるので変更に弱く、結局button-type01, みたいな連番と大差ないものに落ち着くと思います。テーマやアクセントカラーがはっきりしたデザインならそれらを名前に使うこともできるでしょう。もちろんアクセント1みたいな名前付けをしたとしてもこれは色に対するエイリアスに過ぎず、用途別命名とは程遠いものになります。

「ああ、この人は見た目管理の世界に軸足を置いているんだな」というところが分かればそこまで叩かれるような記事とは思わないのですが、OKボタンの見た目をキャンセルボタンに使うことがあるから、のあたりはさすがに不味かったですかね。

(連番→機械的、に一部修正)

EFCoreに深入り(DbContextをnewするあたり)

2/22追記、この記事の内容の応用としてPomelo.EFCoreのシャーディング対応をgistにあげてみました Pomelo.EFCoreでSharding · GitHub


この記事は、以下のコードを読んでみた結果です。 github.com 当記事内のコードはどれも簡略化したものなので本当の実装が気になる型は上記公式リポジトリのコードを確認してください。 またDbContextPoolまでは踏み込めていません。後日調べようと思っています。

DbContext継承クラスは、基本的にDbContextOptions<TDbContext>を受け取るコンストラクタが必要です。

public partial class MyDbContext : DbContext
{
    public MyDbContext (DbContextOptions<MyDbContext > options) : base(options)
    {
    }
}

これをDIに登録するときはStartup.csに以下のようなコードを書きます。例はPomelo.EntityFrameworkCoreのものですが、SqlServerでも似たようなものです。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<MyDbContext>(b => b.UseMySql(
            Configuration.GetConnectionString("ConnectionString"),
            new MySqlServerVersion(new Version(8, 0))
        )
    );
}

ラムダの引数であるところのbDbContextOptionsBuilder<MyDbContext>になります。DbContextOptions<TDbContext>を直接弄るのではなく、Builderのほうを弄ることで、弄った内容がOptionsに反映される、という形になっています。

AddDbContext<TDbContext>拡張メソッドで登録されるDI設定は、デフォルトがServiceLifetime.Scopedです。つまりWebの場合は同一リクエスト内ならば同じオブジェクトが使いまわされます。AddDbContext<TDbContext>オーバーロードによってはServiceLifetime.Scopedを引数で指定できますが、ほとんどのケースでScopedのほうが望ましいでしょう。

また、DbContext以外にもDbContextOptionsのほうもDI登録されていて、同じようにServiceLifetime.Scopedの寿命を持ちます。MyDbContextのコンストラクタに入ってくるDbContextOptionsはDIが解決しているのです。

思うにDbContextOptionsBuilder<MyDbContext>DbContextOptions<MyDbContext>ジェネリックな型になっているのはDIの都合です。もしジェネリックでないDbContextOptionsを引数に取るとしたら、DbContext継承型が複数あった場合、つまりMyDbContext1とMyDbContext2で引数が同じオブジェクトになってしまいますから、分離するために型をつけているのです。このあたりはちょっとしょぼいというか強引というか。

さて、ServiceLifetime.ScopedなのでMyDbContextはリクエスト毎にnewされるわけですが、コンストラクタ引数のDbContextOptionsも同じ寿命であって、リクエスト毎に以下のようなコードが呼び出されることになります。(簡略化してあります)

private DbContextOptions<TDbContext> CreateOptions<TDbContext>(Action<DbContextOptionsBuilder<TDbContext>>? optionAction)
{
    var builder = new DbContextOptionsBuilder<TContext>();
    optionAction?.Invoke(builder); //UseMySqlとかUseSqlServerとかがこの中で呼ばれてConnectionStringをセットしたりする
    return builder.Options;
}
//DI登録
services.TryAdd(new ServiceDescriptor(typeof(TDbContext), lifetime);
services.TryAdd(new ServiceDescriptor(typeof(DbContextOptions<TDbContext>), sp => CreateOptions(optionAction),  lifetime);

そもそもDbContextOptionsとは何ぞやというと、複数のIDbContextOptionsExtensionを束ねたDictionaryです。

abstract class DbContextOptions
{
    IReadOnlyDictionary<Type, IDbContextOptionsExtension> _extensions;
}

IDbContextOptionsExtension自体にめぼしい機能はありませんが、これを実装した抽象クラスであるRelationalOptionsExtensionがConnectionStringを持っており、SqlServerOptionsExtensionMySqlOptionsExtensionの親クラスになっています。

public abstract class RelationalOptionsExtension : IDbContextOptionsExtension
{
    string _connectionString;
    //他にもTimeoutとかMigrationAssemblyとか設定いろいろ
}

public class SqlServerOptionsExtension : RelationalOptionsExtension 
{
}

public class MySqlOptionsExtension : RelationalOptionsExtension 
{
}

DbContextOptionに対してUseMySqlMySqlOptionsExtensionを突っ込むし、UseSqlServerSqlServerOptionsExtensionを突っ込みます。

public static DbContextOptionsBuilder UseMySql(this DbContextOptionsBuilder builder, string connectionString,  ServerVersion serverVersion, Action<MySqlDbContextOptionsBuilder> optionsAction)
{
    MySqlOptionsExtension extension = GetOrCreateExtension(builder)
        .WithServerVersion(serverVersion)
        .WithConnectionString(connectionString);

    builder.AddOrUpdate(extension);

    optionsAction?.Invoke(new MySqlDbContextOptionsBuilder(builder));

    return builder;
}

さて、これでMyDbContextがnewされるまでの大体の要素が出そろいました。

  1. DbContextOptionsBuilderをnewする
  2. UseMySqlUseSqlServer相当のコードで、IDbContextOptionsExtensionDbContextOptionsBuilder.Optionsに登録する。この過程でConnectionStringが決まる
  3. ActivatorUtilities.CreateInstance<MyDbContext>DbContextOptionsBuilder.Optionsを引数に渡してインスタンスを作る
  4. 上記1~3を行うファクトリをDI登録する

これで生成周りを乗っ取れそうです。RDS自前シャーディングなどでConnectionStringをKeyによって切り替えたい時などに使えるかもと思っています。