第15回コベチケの会

コベリンの最近の取り組みとして、業務などで役立ちそうな知見を共有する会を開催することになりました。 そのついでに発表に使ったアジェンダもそのまま公開してしまおうという豪快な企画です。

※ アジェンダをそのままコピペして公開したものなので若干見にくい箇所もあるかもしれませんが、ご了承ください。

ViewModelがFatになっていない? @numa08

Androidの話。

今までの歴史

Activity/Fragmentの実装を集中してしまう問題が発生(2010年代中盤)。Fat Activityとか呼ばれていた時代 ↓ DataBinding/ViewBindingでViewのコードを簡略化 ↓ ViewModelの導入で、ロジックとViewの切り分けに成功(2010年代末) ↓ ViewModelになんでもかんでも実装して、ViewModelが肥大化してない?(個人の感想です)(現代)

例えば、Twitterのプロフィール画面を作るとき。(注意:画面の例としてTwitterを挙げただけで、Twitterを例にしているわけではない)

画面 簡単な仕様
自分のプロフィール image.png (1.8 MB) ヘッダー画像
名前、自己紹介文
プロフィールを編集するボタン
ツイートのリスト
他人のプロフィール image.png (3.1 MB) ヘッダー画像
名前、自己紹介文
フォロー状態ボタン
ツイートのリスト

共通する仕様 : Fragment の起動パラメータに渡されたidのプロフィールを表示する。BottomNavigationBarから遷移するときは、自分のプロフィールを表示する。このときパラメータのidはnull

画面仕様が微妙に異なるが、構成要素はほぼ同じなので1つのViewModelで実装をしよう

class ProfileViewModel() :ViewModel() {
 val isMyProfile: LiveData<Boolean>
 val profile: LiveData<Profile>
 val posts: LiveData<List<Posts>>
<Button android:text="@{viewModel.isMyProfile ? R.string.edit_profile : viewModel.followState}" />

APIのエンドポイントはこんな感じ

  • /users/me : 自分のプロフィールを返す
  • /users/{id} : 他人のプロフィールを返す
  • /users/{id}/posts : 特定ユーザーの投稿一覧を返す

Repositoryを定義して、ViewModelからデータを取り扱えるようにしよう

  • UserRepository
    • myId: Flow\
    • myProfile: Flow\
    • usersProfile(userId): Flow\
  • PostRepository

    • posts(userId): Flow<List\>
  • 議論: 特定ユーザーの投稿一覧はUserRepositoryとPostRepositoryのどちらにあるべき?

    • UserRepository派 : ユーザーの持っているデータだから、UserRepository
    • PostRepository派: 投稿データだから PostRepository

ログイン状態は別のサービスを使って管理しているので、Repositoryを分けておこう - AuthRepository - authState: Flow\

ViewModelの実装をする

class ProfileViewModel(
    private val userRepository: UserRepository,
    private val postRepository: PostRepository,
    private val authRepository: AuthRepository
    savedStateBundle: SavedStateBundle) : ViewModel () {
    private val args: ProfileFragmentArgs.fromBundle(savedStateBundle) // navigatio graphで定義されているFragmentの起動パラメータ
    
    private val isMyProfileFlow: Flow<Boolean> = if(args.userId == null) {
            flowOf(true)
        } else {
              authRepository
                .authState
                .mapNotNull { it as? Authorized }
                .map { it.id == args.userId } 
           }
    
    val isMyProfile: LiveData<Boolean> = isMyProfileFlow.asLiveData()
    
    val profileFlow: Flow<Profile> =isMyProfile
            .flatMapLatest { myProfile ->
                if (myProfile) 
                    userRepository.myProfile()
                else
                    userRepository.profile(requireNotNull(args.userId))
            }
    val profile = profileFlow.asLiveData()

isMyProfileFlowとかprofileFlowの生成をテストしたいなー

class ProfileViewModelTest() {
    @Mockk lateinit var userRepository: UserRepository
    @Mockk lateinit var authRepository: AuthRepository
    @Mockk lateinit var postRepository: PostRepository
    
    @Test
    fun showMyProfile(){
        val args = ProfileFragmentArgs(userId == null).toBundle()
        val viewModel = ProfileViewModel(userRepository, postRepository, authRepository, args)
        asserEqualst(viewModel.isMyProfile.value, true)
    }
    
    @Test
    fun showMyProfileWithId()
        val myId = "1111"
        val args = ProfileFragmentArgs(userId = myId).toBundle()
        every { authRepository.authState } returns flowOf(AuthState.Authorized(userId = myId) }
        val viewModel = ProfileViewModel(userRepository, postRepository, authRepository, args)
        asserEqualst(viewModel.isMyProfile.value, true)
    }        

isMyProfileをテストするのに、PostRepositoryは不要だがモックは必要!!

  • 今後ViewModelに機能が加わって、依存関係が増えた(減った)ときテストコード全体の修正が必要
  • ViewModelの実装によっては、テストに関係ないモックインスタンスの呼び出しが発生するため、適切にRelaxedなどの設定が必要
  • そもそも、多くのモジュールに依存している状態でunittestとは言えないのでは?

どうすれば良いんだー?

image.png (50.3 kB)

みたいな構造を作る? 参考DroidKaigi/conference-app-2019: The Official Conference App for DroidKaigi 2019 Tokyo

class Dispatcher {
    private val actionFlow = ShareFlow<Action>()
    val action: Flow<Action>
    
    fun send(action: Action) { actionFlow.value = action }
}
class ProfileActionCreator(
    private val dispatcher: Dispatcher
    private val userRepository: UserRepository,
    private val authRepository: AuthRepository,
) {
    suspend fun showProfile(
        userId: String?) {
        if (userId == null) {
            userRepository.myProfile().collect {
            dispatcher.send(ProfileAction::ShowMyPrfoile)    
            return
        }
        authRepository
            .mapNotNull { (it as? Authorized)?.userId }
            .flatMap {
                if (it == userId) {
                    userRepository.myProfile().map(ProfileAction::ShowMyProfile)
                } else {
                    userRepository.profile(userId).map(ProfileAction::ShowOthersProfile)
            }
            .collect(dispatcher::send)
     }
class ProfileViewModel(
    private val dispatcher: Dispatcher) : ViewModel() {
    val isMyProfile = dispatcher.action.filter(it is ProfileAction).map { it is ProfileAction.ShowMyProfile }.asLiveData()
    val profile = dispatcher.action.filter(it is ProfileAction).map { it.profile }.asLiveData()
}
class ProfileFragment()  : Fragment{

    @Inject lateinit var profileActionCreator: ProfileActionCreator
    val viewModel: ProfileViewModel by { viewModels }
    
    override fun onViewCreated(view: View) {
        binding.viewModel = viewModel
        profileActionCreator.showProfile()
}
  • 自分のプロフィール表示については、ProfileActionCreatorのテストだけで完結する
  • 適切にActionCreatorを実装できれば、不必要なモックの挿入は不要になる
  • VIewModelはViewの状態に専念できる

Swift Concurrency @mironal

Swift Concurrency とは?

https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html

// Serial
let hoge = await asyncFunction()
let huga = await asyncFunction2()
// asyncFunction -> asyncFunction2 の順番で実行される

// Parallel
async let hoge = asyncFunction()
async let huga = asyncFunction2()

let results = await [hoge, huga]
// asyncFunction と  asyncFunction2 が同時に実行される.

// AsyncSequence や AsyncStream などの戻り値の場合は for で loop できる
    
for await value = asyncStreamFunc() {
    print(value)
}

// Sequence なので filter とかできる
for await value = asyncStreamFunc().filter{...}.map {...}  {
    print(value)
}
    
// async な context じゃない場合に await したいときは Task を使う
// むしろ await は `Task {}` のシンタックスシュガーみたいなもの

let task = Task {
    await hogehoge()
}

// task を保持しておけば後でキャンセルとかできる
// ViewController で cancel ボタン押したときとか.
task.cancel()
    
// XCTest でも async できる
func testHoge() async throws {
    let a = awit asyncFunc()
}

既存の callback(closure) style のやつを対応させるには?

  • withTaskCancellationHandler
  • withCheckedContinuation

とかを使う.

失敗する可能性のあるなしなどでいろんなメソッドがあるので適切なものを使う.

https://github.com/mironal/TwitterAPIKit/blob/c99bcc696ed40b9786ed02677ab6b43bf2e3b540/Sources/TwitterAPIKit/Extensions/Concurrency.swift#L48

## 罠

Xcode 13.2.1 (今現在の最新版) でビルドしたバイナリは iOS 12.5.5 でクラッシュする.

https://forums.swift.org/t/swift-concurrency-back-deploy-issue/53917

こんな感じで #if で区切っていても xcode のバグで libswift_Concurrency をリンクしようとして、起動時にクラッシュする.

image.png (43.7 kB)

https://github.com/mironal/TwitterAPIKit/blob/c99bcc696ed40b9786ed02677ab6b43bf2e3b540/Sources/TwitterAPIKit/Extensions/Concurrency.swift#L5

  • Xcode 13.3 では直るらしい
  • iOS 13系では発生しない模様

難しいところ

  • await の前後で thread が何なのかわかりにくい
    • 解決: @MainActor 使う
  • ミスってちゃんと task を終わらせないとある行でハングしてるように見える
    • callback でも同じこと起こるけど async await は慣れてないからミスりがち

VRM Live Viewer で MMDのモーションを取り込む @takkumattsu

Magica Voxelで作っていたモデルをVRMに出力することが出来たので何か遊べないかと思って見つけたVRM Live Viewer。MMDのモーションが利用できるみたいなので遊んでみた時の備忘録

booth.pm

  • モーションを探す
  • カメラのモーションもある
  • モデルを読み込み
  • それぞれのモデルにモーション設定
  • カメラのモーション
  • 音楽の設定

モーションを探す

MMDと言ったらニコニコ。
ニコニコは昔からこの辺のモーションなどを公開してくれている人がたくさんいます。「mmdモーション配布あり」などのタグで検索すると色々出てくるので公開してくれているモーションをダウンロードしましょう

https://www.nicovideo.jp/search/mmd%E3%83%A2%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E9%85%8D%E5%B8%83%E3%81%82%E3%82%8A?hft=0&st=1646195544432&itc=10&ct=2&cip=3

今回はダンスのモーションは https://www.nicovideo.jp/watch/sm28405062 をお借りしました

カメラのモーションもある

カメラの動きもモーションとして読み込めるので今回は https://www.nicovideo.jp/watch/sm28615575 をお借りしました

モデルを読み込み

image.png (573.2 kB)

ファイル読み込みの横が青いプラスボタンになっていることを確認して、vrmを読み込みます(VRoid Hubでも可)

image.png (617.0 kB)

わかりにくいけど二体読み込めています

それぞれのモデルにモーション設定

プラスボタンの下の人のアイコンをクリックすると現状選択しているモデルが表示されます

image.png (492.6 kB)

いったん画面を閉じてオレンジの...をクリックします

image.png (545.2 kB)

モーションの部分をクリックして落としてきたvmdファイルを読み込みます image.png (36.5 kB)

そのあとプラスボタンの下の人のアイコンをクリックしてモデルを切り替え

image.png (476.3 kB)

同じようにvmdを読み込みます。これでモーションの設定は完了

カメラのモーション

次に右上のモーションのフォルダをクリックして

image.png (444.6 kB)

下のフォルダボタンを押してカメラのモーションを読み込みます

image.png (467.2 kB)

そうするとこんな感じで視点が変わるかと思います

音楽の設定

右下の音符マークをクリックして、フォルダから音楽を読み込むことで好きな音楽を設定できます。

image.png (441.1 kB)

完成

あとは再生ボタンを押せば踊りだします!
音楽やモデルに著作権があるので個人で楽しむ分にはいいですが公開するときなどはしっかり許諾を得てから楽しみましょう。

イメージ図