- 投稿日:2021-10-22T22:24:17+09:00
物理シミュレーションソフトを作りたい
大まかな構成 ・右側にいろいろな条件を指定できるタブ ・実行結果を映し出す 現状 ・ゼロからスタート・・・ってわけではないけどunityかじったことはある ・物理シミュレーションソフト自体はあるのは知ってるが、自分自身物理がとても苦手なためここで克服したい気持ちがあるのだ
- 投稿日:2021-10-22T19:38:40+09:00
音声波形を解析しないリップシンク(Lipsync)を実装する(比較)
この記事の内容 前回の記事 音声波形を解析しないリップシンク について、備忘録を書いたものの、きちんと動作比較ができていなかったので残します。 Oculus LipSync(Oculus社) OVRLipSync ACUAH editor(自前 音声解析なしのリップシンク) 2.のソースコードも。 音声データ(WAV形式)とリップシンク用データは別途用意必要ですが、 コピペで動作すると思いますので参考まで。 1. Oculus Lipsync(Oculus社) OVRLipSync 0:08~ 2. ACUAH editor(自前 音声解析なしのリップシンク) 0:41~ ソースコード VRM の GameObject に AudioSource を AddComponent 同 AudioSource の Audio Clip に音声ファイル(WAVファイル)を設定 以下のソースコード LipSyncController.cs を VRM の GameObject に AddComponent ソースコード内の testdata は音声ファイルに合わせたデータを準備 Scene上に UI Button を配置。 OnClick で LipSyncController.OnPlayButtonClick() を実行させる using System.Collections; using System.Collections.Generic; using UnityEngine; using VRM; public class LipSyncController : MonoBehaviour { private static string locale = "ja_"; // 日本語 private Dictionary<string, float[]> lipShapeTable = new Dictionary<string, float[]>() { { "ja_a", new float[5] { 0.45f, 0f, 0.05f, 0f, 0.06f } }, { "ja_i",new float[5] { 0f, 0.28f, 0f, 0f, 0f } }, { "ja_u",new float[5] { 0.25f, 0f, 0.37f, 0f, 0.05f } }, { "ja_e",new float[5] { 0.20f, 0f, 0f, 0.12f, 0f } }, { "ja_o", new float[5] { 0.18f, 0f, 0.27f, 0f, 0.22f } }, { "ja_k", new float[5] { 0f, 0f, 0.06f, 0f, 0.28f } }, { "ja_s", new float[5] { 0.15f, 0.13f, 0.44f, 0f, 0f } }, { "ja_t", new float[5] { 0f, 0.10f, 0.39f, 0f, 0.08f } }, { "ja_n", new float[5] { 0f, 0f, 0.13f, 0f, 0f } }, { "ja_h", new float[5] { 0.12f, 0.23f, 0.21f, 0f, 0f } }, { "ja_m", new float[5] { 0f, 0f, 0.25f, 0f, 0f } }, { "ja_y", new float[5] { 0f, 0.39f, 0.19f, 0f, 0f } }, { "ja_r", new float[5] { 0f, 0f, 0.06f, 0f, 0.24f } }, { "ja_w", new float[5] { 0.19f, 0f, 0.27f, 0.05f, 0.10f } }, { "ja_p", new float[5] { 0f, 0f, 0.41f, 0f, 0f } }, { "ja_b", new float[5] { 0f, 0.01f, 0.47f, 0f, 0.04f } }, { "ja_f", new float[5] { 0f, 0f, 0.37f, 0f, 0.22f } }, { "ja_d", new float[5] { 0f, 0f, 0f, 0f, 0.21f } }, { "ja_z", new float[5] { 0f, 0.16f, 0f, 0.28f, 0f } }, { "ja_g", new float[5] { 0.12f, 0f, 0.01f, 0.05f, 0f } }, { "ja_c", new float[5] { 0.07f, 0.17f, 0f, 0.06f, 0f } } }; private AudioSource voiceSource; private int qualityType; private VRMBlendShapeProxy blendShapeProxy; void Start() { // Unity の画質設定を取得 qualityType = UnityEngine.QualitySettings.GetQualityLevel(); // VRM の BlendShapeProxy blendShapeProxy = this.GetComponent<VRMBlendShapeProxy>(); // VRM に AddComponent した AudioSource voiceSource = this.GetComponent<AudioSource>(); } void Update() { } public void OnPlayButtonClick() { // 画面上にボタンを配置して、クリック時にこれを実行させる StartCoroutine("PlayVoice"); } private IEnumerator PlayVoice() { //---------------------------- // 以下のデータを音声データに合わせて準備する // // 「おしごとおつかれさまです、なんぷんにせっとする」 音声から作成したデータ string testdata = "nn,0.14,oo,0.09,si,0.14,go,0.12,to,0.15,oo,0.10,tu,0.12,ka,0.11,re,0.13,sa,0.11,ma,0.18,de,0.13,su,0.26,nn,0.68,na,0.10,nn,0.12,pu,0.08,nn,0.12,ni,0.09,se,0.21,to,0.09,su,0.15,ru,0.21"; //---------------------------- string[] splitline = testdata.Split(','); int j = (int)(splitline.Length / 2); string[] lipSynchLetters = new string[j]; float[] lipSynchTimes = new float[j]; for (int i = 0; i < j; i++) { lipSynchLetters[i] = splitline[i * 2]; lipSynchTimes[i] = float.Parse(splitline[i * 2 + 1]) - 0.05f; // 0.05f は調整値 if (lipSynchTimes[i] < 0.01f) { lipSynchTimes[i] = 0.01f; } } StartCoroutine(PlayLipSynch(lipSynchLetters, lipSynchTimes)); voiceSource.Play(); while (voiceSource.isPlaying) { yield return null; } yield break; } //-------------------------- // LipSynch //-------------------------- IEnumerator PlayLipSynch(string[] lipSynchLetters, float[] lipSynchTimes) { // lipSyncTimes の値は PlayVoice 実行時に -0.05(秒)されている // 口の開き具合を ボリューム設定で変える。 // 日本語は音声波形データの音量でリップシンクの大きさを変えてもあまり良い効果がなかった float vv = 0.5f * voiceSource.volume + 0.5f; for (int i = 0; i < lipSynchLetters.Length; i++) { string shiin = locale + lipSynchLetters[i].Substring(0, 1); string boin = locale + lipSynchLetters[i].Substring(1, 1); // 次の子音 string nextShiin = ""; if (i == lipSynchLetters.Length - 1) { nextShiin = locale + "n"; } else { nextShiin = locale + lipSynchLetters[i + 1].Substring(0, 1); } // 画質によって変更する(Very Low, Low, Medium の場合はリミテッドっぽい表示にする) if (qualityType < 3) { // 子音 if (lipSynchTimes[i] > 0.05f) { // 子音の表示時間は0.05秒で固定 blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), lipShapeTable[shiin][0] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), lipShapeTable[shiin][1] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), lipShapeTable[shiin][2] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), lipShapeTable[shiin][3] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), lipShapeTable[shiin][4] * vv); blendShapeProxy.Apply(); } else { yield return new WaitForSecondsRealtime(0.05f); } // 母音 blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), lipShapeTable[boin][0] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), lipShapeTable[boin][1] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), lipShapeTable[boin][2] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), lipShapeTable[boin][3] * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), lipShapeTable[boin][4] * vv); blendShapeProxy.Apply(); yield return new WaitForSecondsRealtime(lipSynchTimes[i] - 0.05f); } else { // 子音 // 子音の表示時間は0.05秒で母音までの時間を見てSmoothStepで滑らかに動かす float elapsedTime = 0.0f; //経過時間 float t = 0.0f; if (lipSynchTimes[i] > 0.05) { while (elapsedTime < 0.1f) { t = elapsedTime / 0.05f; blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), Mathf.SmoothStep(lipShapeTable[shiin][0], lipShapeTable[boin][0], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), Mathf.SmoothStep(lipShapeTable[shiin][1], lipShapeTable[boin][1], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), Mathf.SmoothStep(lipShapeTable[shiin][2], lipShapeTable[boin][2], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), Mathf.SmoothStep(lipShapeTable[shiin][3], lipShapeTable[boin][3], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), Mathf.SmoothStep(lipShapeTable[shiin][4], lipShapeTable[boin][4], t) * vv); blendShapeProxy.Apply(); elapsedTime += Time.deltaTime; yield return null; } } else { yield return new WaitForSeconds(0.05f); } // 母音 // 母音は次の子音までの時間をSmoothStepで動かす elapsedTime = 0.0f; // 子音の時間分短くする lipSynchTimes[i] -= 0.05f; while (elapsedTime < lipSynchTimes[i]) { t = elapsedTime / lipSynchTimes[i]; blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), Mathf.SmoothStep(lipShapeTable[boin][0], lipShapeTable[nextShiin][0], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), Mathf.SmoothStep(lipShapeTable[boin][1], lipShapeTable[nextShiin][1], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), Mathf.SmoothStep(lipShapeTable[boin][2], lipShapeTable[nextShiin][2], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), Mathf.SmoothStep(lipShapeTable[boin][3], lipShapeTable[nextShiin][3], t) * vv); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), Mathf.SmoothStep(lipShapeTable[boin][4], lipShapeTable[nextShiin][4], t) * vv); blendShapeProxy.Apply(); elapsedTime += Time.deltaTime; yield return null; } } } // リセット blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), 0.0f); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), 0.0f); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), 0.0f); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), 0.0f); blendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), 0.0f); blendShapeProxy.Apply(); yield break; } } まとめ ・OVRLipSync、自前実装 は好みの問題でしょうか。 ・自前実装はリップシンク用のデータを事前に作らないといけないので、手間ですが動作は軽量です。 (リップシンク用データは ACUAH editor で作成できます)
- 投稿日:2021-10-22T17:39:47+09:00
【win32metadata / CsWin32】ANSI版 Win32APIの定義が生成されないことの解決策
予備知識 「win32metadata」とは Win32API を手軽に呼び出すためのメタデータ(?)らしい。 「CsWin32」とは 前述の「win32metadata」を使い、C# 用に、Win32API 関係の定義を自動で生成してくれるソースジェネレーター。 (プロジェクト直下に「NativeMethods.txt」というファイルを作成し、その中に改行区切りで Win32API の名前を書くだけで、定義が自動で生成される) 本題 問題 NativeMethods.txt に Win32API の名前を書いていたところ、なぜか ANSI 版の Win32API(末尾に"A"が付くやつ)だけ、定義が生成されないことに気づいた。 NativeMethods.txt // 生成される SendMessage SendMessageW // 生成されない... SendMessageA 解決策 プロジェクト直下に「NativeMethods.json」(CsWin32 が生成するコードを設定するためのファイル)を作成し、下のように記述する。 NativeMethods.json { "$schema": "https://aka.ms/CsWin32.schema.json", "wideCharOnly": false } どうやら、デフォルトで wideCharOnliy(UTF-16 版の Win32API のみ生成)が true らしく、ANSI 版の Win32API が生成されなかったみたい。 副作用 NativeMethods.txt に末尾 A / W を省略して書いた場合、省略した名前ではなく、UTF-16 版(末尾にWが付く)の名前で定義されるようになる。 例えば、下のように書いた場合、従来ではそのまま「SendMessage」と定義されるが、「SendMessageW」と定義されるようになる。 NativeMethods.txt SendMessage ※ 当たり前だが、そもそも ANSI 版・ UTF-16 版がない Win32API に影響はない
- 投稿日:2021-10-22T14:47:09+09:00
【C#】定義済みのテストケースを流用する
概要 既に決められたテストモデルAがあって、テストモデルB1~B3はテストモデルAから派生したテストに、追加されたものがある。 派生されたテストの入力データや結果はテストモデルAとは挙動が異なる場合がある。 そのような場合、わざわざテストモデルをいちいち書いていると、ソースコードの再利用性に問題が生じるので、 いくつかの工夫をして全てのテストを整合性を保って実施できるようにしたい 開発環境 環境こそ古いC#ではあるが、ちょっとしたTipsであるので、 モダンな言語であればよく似たやり方で出来るはず 言語: C# 7.0 ユニットテスト: MSTest 手順 前提 入力データをInput型のInputDataとして、それを可変配列で管理している。 出力データをOutput型のOutputDataとして、それを可変配列で出力される。 InputData, OutputDataの内容はテストケースごとに変化する可能性がある。 共通のモデルクラスSomeModelは、メソッドRunを実行出来る。 Runは引数はInput1つとし、Outputを返り値として出力する ① アサート用の関数をTestCaseに準備 例えば、入力データをInput型のInputData, 出力データをOutput型のOutputDataというオブジェクトに設定していて、 それを配列で管理しているものとすると、 public class TestCaseFormat { // ポイント1:InputDataの変更権限は親のテストケースのみとする private List<Input> InputData { get; } // ポイント2:InputDataのsetterはアクセス権の問題があるので別途メソッドを追加する protected void UpdateInputData(IEnumerable<Input> input_data) { InputData.clear(); InputData.AddRange(input_data); } // アサーション処理 // ポイント3:modifiedはList型(データ変更が可能)だが、assertはIEnumerable型(データ変更が出来ない)とする。 protected void RunAndAssert( Action<List<Input>> modified, Action<IEnumerable<Output>> assert) { modified( InputData ); var model = new SomeModel(); OutputData = model.Run(InputData); assert( OutputData ); } } ② TestCaseAを用意 [TestClass] public class TestCaseA : TestCaseFormat { // nullの場合はデフォルト値(自分で実行)なので、 // このクラスで定義されたデフォルトのinstanceを選択する private void Define<T>( ref T instance, T default_instance ) where T : class { if(instance == null) { instance = default_instance; } } // テストケースでnull入力で、型が異なる場合は // テストとして異常なのでエラーにする private bool IsValidTestCalling( Action<List<Input>> modified, Action<IEnumerable<Output>> assert ) { if( modified == null && assert == null && GetType() != typeof(TestCaseA) ){ return false; } else { return true; } } /** 以下、流用前のソースコード(ちょっとだけ加工する) ***/ // ポイント4:コンストラクタでテストケースAの場合の入力データを入れる public TestCaseA() { UpdateInputData( /* (...Inputデータを入れる...) */ ); } // ポイント5:各パラメータが'null'の場合は自分自身のテストとする [TestMethod] [DataTestMethod] [DataRow(null, null)] public virtual void TestCase1( Action<List<Input>> modified, Action<IEnumerable<Output>> assert ) { if( !IsValidTestCalling( modified, assert) ) { throw new NotImplementedException("子テストケースが実装されていません"); } Define( modified, (input_data) => { //...(Inputの変更処理)... }); /* TestModelAのTestCase1のデフォルトの入力パラメータ */ Define( assert, (output_data) => { //...(アサーション)... }); /* TestModelAのTestCase1のデフォルトのアサーション */ RunAndAssert( modified, assert ); } [TestMethod] [DataTestMethod] [DataRow(null, null)] public virtual void TestCase2( Action<List<Input>> modified, Action<IEnumerable<Output>> assert ) { if( !IsValidTestCalling( modified, assert) ) { throw new NotImplementedException("子テストケースが実装されていません"); } Define( modified, (input_data) => { //...(Inputの変更処理)... }); /* TestModelAのTestCase1のデフォルトの入力パラメータ */ Define( assert, (output_data) => { //...(アサーション)... }); /* TestModelAのTestCase1のデフォルトのアサーション */ RunAndAssert( modified, assert ); } } ③ テストケースB1に派生する [TestClass] public class TestCaseB1 : TestCaseA { public TestCaseB1() { UpdateInputData( /* (...Inputデータを入れる...) */ ); } // ポイント6:テストに変更がある場合、定義を追加する // なお、TestCase2はオーバーライドしないので内容が変わらない [TestMethod] [DataTestMethod] [DataRow(null, null)] public override void TestCase1( Action<List<Input>> modified, Action<IEnumerable<Output>> assert ){ base.TestCase1( (input_data) => { //...(Inputの変更処理)... }, (output_data) => { //...(アサーション)... } } } 効果 アクセス権限を上手く切り替えながら実装しており、いくつかのメリットがあると思う. テストケース修正不要部分のコードでは、追加でテストコードを書かなくても良い 実装異常の検出が出来る(派生したテストケースの引数がnullの場合は未実装扱いにできるなど) TDD(テスト駆動開発)を適用しやすい気がする モデルに入れるデータがちょっと違う場合でも、「違う結果である」ことをテストツールが検出してくれるので、データの差分によりテストを評価しやすい Assertの段階でIEnumerableを使うことで、テスト作業者がアサートで返すべき結果を加工することをある程度防げる 課題 クラス設計がややこしいので、どういう風にすればもう少し汎用的に取り扱いしやすいかを考える必要はありそうだ
- 投稿日:2021-10-22T10:25:50+09:00
Unity設計入門:第2章「MVXの思想」理論編その1
Unity設計入門の目次はこちら はじめに 前回、ModelとViewがそれぞれ何者であるかに加えて、それらを分離することの利点について書きました。 しかし、現実問題としてModelとViewを分離することには大きな困難を伴います。 今回は、なぜModelとViewがキレイに分離できないのか、具体的な原因の特定を目指します。 ModelとViewが分離できない状況 1. 表示制御のためのデータが増加する どのようなデータが「表示制御のための一時データ」に該当するか 複雑なゲーム画面を組み立てていくと、ModelでもViewでもないデータが増加します。 これらのデータは「サンプルコードではシンプルすぎて出てこないので解説されにくいけれど、現実的にゲームを組むとどうしても出てきて扱いに困る」という厄介な存在です。 これらのうち、具体的には以下のようなものを「表示制御のための一時データ」と呼ぶこととします。 アニメーションの秒数を管理するためのフラグ マウスを早く動かしたときにのみ動かす演出のために、マウス座標の差分をとっておくための変数 スナップするスクロールビューにおいて、現在どこのページにいるかを保存しておく変数 UIの入力モードが複数あるときに、そのどれであるのかを保存しておく変数 このようなデータは、業務用のソフトウェアでは無視できるほど少ないでしょう。ほとんどが演出用であり、省いてしまえばよいのです。しかし、ゲームは演出です。これらの変数が莫大に増加することがほとんどなのです。 さて、これらのデータをどこに記述していくのかについて考えるとなんとModel・Viewのどちらにも書けないということが分かります。 Model側に記録した場合の問題点 Modelにアニメーション状況や表示可否などの情報を記憶させる方式です。 Modelが画面に依存してしまいます。 Modelが画面のレイアウト・演出に合わせて変更に迫られるということです。 これはViewの柔軟性を損なうとともに、Modelの一部のテストにViewが要る状況を生んでしまいます。 これは前回の原則「Modelは純粋にする」に反するコードです。 View側に記録した場合の問題点 Modelから受け取った情報とViewが持っている情報を合わせて演出を出す方式です。 Viewに条件判断が増え、肥大化します。 Unityの起動回数は増え、面倒なテストを何度もする羽目になります。 これは前回の原則「Viewは極限までシンプルにする」に反するコードです。 2. シンプルなViewから受け取る入力は低レベルすぎる ユーザー入力はUnity、すなわちViewを通して受け取らざるを得ません。そしてユーザーはViewを通してModelを操作しようとするのです。小さなサンプルコードだけでは前回のように「ボタンを押して、画面に表示してハッピーだね!」というようになって終わりなのですが、ユーザーの操作は ダブルクリック判定をするために、「直前15フレーム以内にクリックがあったか」を保存しておく変数 3回のボタンクリックをすることで初めて意味を持つ入力 カードをドラッグアンドドロップして別の場所に置くときの入力 など多岐にわたります。そしてこれらの入力は、意味を持つタイミングが「複数のデータ・一次変数から複合的に判断される」という特徴を持ちます。これはボタンによる入力が「押された瞬間に意味が確定する」ものであったこととは対照的です。 さて、それでは上に挙げた例の「直前15フレームの入力の有無」「ボタンクリックが現在何回されているか」「ドラッグ中か否か」などの情報はどこに保存すればいいのでしょうか? じつはこれもModel・Viewどちらにも書けないのです。 Model側に記録した場合の問題点 Modelが「画面の(x,y)がクリックされたときに呼ぶ関数」などを用意して、低レベル情報を直接受け取る方式です。 この場合、同じくModelがViewを気にしてしまうためModelの純粋性を損ないます。 さらに、この手の関数をテストしようとするとテストケースに「(23,455)をクリックしたときの関数を呼ぶ→マウスの座標(22,444)に移動したときの関数を呼ぶ」と言ったように非常に低レベルなものになります。この(23,455)とかの座標は結局Viewを見ないとダメですし、こんなテストを書くぐらいなら、Viewを起動して動作確認するほうがよっぽど早いですね。つまりこれは、「Modelがテストを書くことを容易にする」というメリットを完全に破壊しているのです。 View側に記録した場合の問題点 Viewが「どこをクリックされたか」などの情報をためておき、ある程度たまったタイミングで、画面から離れた抽象的な意味を持つUI操作としてModelの関数を呼び出す方式です。 この場合、Viewが大きくなり、「Modelの関数がちゃんと呼ばれるか」の検証が非常に面倒です。 Viewはシンプルにするという原則に反するコードです。 3. 「ModelとViewの責務」という言葉を曖昧に使っている これは1,2とは異なり、構造的にどうしようもないもの、といえるものではありません。 Viewを見た目だ!ぐらいのぼんやりしたイメージ、Modelをデータの入出力だ!ぐらいのぼんやりしたイメージで組み立てると、往々にしてViewがModelの処理を奪い取ります。(ModelがViewを奪うことはあまりありません。というのも、Viewは最小限の状況が一番いい状況だからです) また、View・Modelの2人体制だと、ViewはModelを参照できるわけですから、Modelをpublic変数から好き勝手操作できてしまいます。 結果としてModelとViewの密結合を生みやすくなります。 ModelとViewの原則、「純粋性」と「シンプルさ」を強く意識したコーディングである程度は防げますが、根本的にModelへの参照をなくしたり、疎結合にしたりする策を講じないとView側でいじってしまうという問題はなくなりません。 おわりに 経験的には、「サンプルコードで書けてるようにキレイにならない!!なんで!!」という叫びの原因のうち、大部分はここにあると思います。また、漠然とViewとModelとX(ControllerなりPresenterなりViewModelなり)を切り分けたいというだけの思考でMVXの設計をするのではなく、「どうしようもないからXへ持っていく。ModelとViewは明確な原則に従って設計する」の方がよい設計になることが多いと思います。次回、今回上げた問題の解決策を探していきます。