言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

「チームで育てるAndroidアプリ設計」を読んだ

[PR] 献本いただいた1「チームで育てるAndroidアプリ設計」を読んだので感想文です。

peaks.cc

全体を通して

本書は大まかに前後半に分かれていて、前半は新規開発について、後半は大規模チーム開発について、それぞれAndroidアプリ設計の勘所について述べている。よって、すでにAndroidエンジニアとして働いている人であれば大体どちらかは経験があるだろうし2、これからAndroidエンジニアとして働く人にとっては事前に勘所が予習できるので大きな価値があると思う。つまり対象読者がとても広い。Android開発に興味がある人だったら何かしらの共感もしくは知見が得られると思う。

以下、面白かった部分について書いていくが、普通に全部読んで欲しいのでほんの一部だけ紹介する。

前半パート:新規開発について

MVVMとだけ決めたチームの例 (p.4)

本パートでは実際に開発を始める前に設計することの重要性を繰り返し述べている。一例として「全くアーキテクチャを決めずに開発に走り出してしまったケース」と「MVVMとだけ決めたケース」を比較しているが、実は後者もなかなかヤバくて、徐々におかしなことになっていく様子がめちゃめちゃリアルで読んでいてニヤニヤが止まらない。これは「3.3.2 統一性が崩れる時」(p.57) とも関連していて、「何世代も書き方が存在する時、どれが最新なのかわからない問題」はある程度の開発期間を重ねたrepositoryあるあるではないだろうか。

アーキテクチャの選定と背景 (p.13)

実際にどういったアーキテクチャを選定するかの実例とその理由が豊富で読んでいて楽しい。特に多層アーキテクチャでどう言ったアプリは何層にするのがおすすめか指針が書いてあるので、分け方に迷っている人はとりあえず読んで試してみるのが良いと思う。

より多くのチームへ (p.61)

第4章では自分たちのチームでうまくいった知見を、全社に展開することの重要性といいやり方について説明している。話が少しずれるが、最近ジェンダー研究の本をよく読んでいて、こう言った自チームを超える動きは女性が比較的苦手な傾向があるらしい。3

自分もやれと言われない限り積極的にこういうムーブできてないなと反省した。やっていきます。はい。

後半パート:大規模チーム開発について

合意をとってやっていくことの重要さと難しさ

後半パートでは大規模チーム開発について語られるが、「大規模開発でも新規開発と同じくチーム内でアーキテクチャ命名規則などの合意をとってから前進していく」(p.87)とあるように、できれば前半を読んでから後半を読んだ方が理解が深まって楽しいと思う。合意をとってやっていくのは新規開発と同じだが、大規模チーム開発では合意をとるべき人数が多く、全員の目線を合わせるだけでも本当に大変。一つのプロダクトでAndroidエンジニアだけで数十人超えるような大規模チームだとどうやってるんだろうと思わずにいられない。やっていきましょう。

大規模チームのアーキテクチャ: モジュラーモノリスの例(p.99)

著者の経験を踏まえ、どう言った状況でどのようなアーキテクチャを採用したのかステップバイステップで説明されているのでわかりやすい。ただ、この例では割とメインストリームから外れた技術が採用されていて(それについては別の章でもふれられている)そのまま真似をすれば良いというわけではなく、あくまで考え方の参考に留めるべきだが参考になる。
突然GoF本のFacadeパターンが出てきて思ったが、やはり大規模なアプリの設計を考える人は引き出しをちゃんと持ってるんだなー。GoF本は前職でみんなで読書会(みんなで本を読んで1パターンずつ担当を決めてそのパターンで小さいプログラムを実装する)をやったのだが楽しかったので一人で挫折しちゃった人にはこの方法がおすすめ。

オンボーディングの知見(p.91)

人数が多くなるとそれだけ人の入れ替わりが頻繁に発生する。在籍年数を2年と仮定して、24人チームメンバーがいた場合は毎月誰かがやめていくという計算は思わず笑ってしまった。実際は人が辞めやすい時期が偏っている気がするが、人が頻繁に入れ替わっていくというのはまさにそうなのでオンボーディングを整備していこう。この箇所は組織が急拡大するフェーズのベンチャーで頑張っている人にも参考になると思う。オンボーディング具体的に何すればいいかの知見ってそういえばあまり明文化されていない気がするので貴重かもしれない。

最後に

著者の釘宮さん、横幕くん、編集のひつじさん、お疲れ様でした!


  1. 献本いただいたが、実は元々peaksでポチっていたので普通にお金は払っていますw

  2. 読んでて「わかる〜〜〜」「それな〜〜〜〜〜〜」ってめちゃめちゃ心の中で言ってた

  3. 邦題はアレですが、中身は面白いのでおすすめです https://www.amazon.co.jp/dp/B07N65TC1S/

Kotlin 1.4からFlowのcombine()がより綺麗に書けるようになった(suspend conversion on callable references)

今回は小ネタ。Kotlinがますます可愛いという話をします。

Kotlin 1.4からsuspend conversion on callable referencesがsupportされました。

(^ もう数ヶ月も前のtweet...)

リリースノートではさらっと

In addition to suspend conversion on lambdas, Kotlin now supports suspend conversion on callable references starting from version 1.4.0.

としか書かれていないのであまり気に留めた人もいないんじゃないかなと思いますが、これによってKotlin coroutineの Flow#combine() がより綺麗に書けるケースがあります。

例えば、二つのflowをcombineして、それをそのまま collect に渡したい場合、1.4より前ではこのような書き方ができませんでした。

suspend fun foo() {
    combine(
        flowA, flowB, ::Pair
    ).collect { (a: Int, b: Int) ->
        println("$a and $b")
    }
}

val flowA = flowOf(1, 2)
val flowB = flowOf(3, 4)

1.4より前だと上記のコードはコンパイルエラーになります。combine()の第3引数はsuspend function lamdbaなので、suspendじゃないPiarクラスのコンストラクタは受け付けてもらえません。1.4からはsuspendに自動で変換してくれるようになったので、上記のコードは問題なく動きます。

ちなみに、collect()の引数のlambdaの引数(a: Int, b: Int)destructuring declarationsを使っていて、いちいちPairのfirst/secondに名前を付け直したりしなくてもここでabといった名前をつけられます。可愛い。

a, bの後ろの型の明示は省略可能ですが、destructuring declarationsを使っているので()はないと動きません。

    combine(
        flowA, flowB, ::Pair
    ).collect { (a, b) -> // OK
        println("$a and $b")
    }
    combine(
        flowA, flowB, ::Pair
    ).collect { a: Int, b: Int -> // NG (コンパイルエラー)
        println("$a and $b")
    }

1.4より前はどういうコードを書いていたか参考までに残しておきます。やはり少し冗長に見えます。

suspend fun fooBefore4_1() {
    combine(
        flowA, flowB
    ) { a, b ->
        a to b
    }.collect {
        val a = it.first
        val b = it.second
        println("$a and $b")
    }
}

val flowA = flowOf(1, 2)
val flowB = flowOf(3, 4)

言いたいことは以上です。

私が出会った最高のEMたち

[2020.12.8 追記]
ブコメでEMが何かわからないと書かれていたので補足。EM = Engineering Managerです。EM菌ではありません!!!
[追記ここまで]

今の会社でお世話になったEMの人たちのマネジメントがとてもよかったので育休で全てを忘れる前にメモを残す。EMの話題はよく見かけるけれど、マネジメントされる側の視点で語られることがあまりなかった気がするのでいい記録になるかもしれない。

前提

  • 自分: メンバー(マネジメントされる側)。Androidエンジニア。ある程度放置されても自走できる。
  • EM: 一人ではなく複数。(歴代という意味。同時に複数人のEMにマネジメントされたという意味ではない。)彼らはみなAndroidエンジニアではないがモバイルもしくはフロントエンドのエンジニア。なので技術の相談はしないが、開発業務そのものについてはとても詳しい。
  • 組織: エンジニアリング組織がある程度きっちり作られているので、レポートラインも明確だし評価の基準も明確。OKRで目標を管理する。

ここがよかった!マネジメントポイント

認識を最初に合わせる

マネジメントで個人的に一番重要だと思っているのは、(1)期待値を共有して (2)目標を合意し (3)目標が達成できたか一緒に振り返る(=評価) のサイクルがちゃんと回っていることだと思う。1
(1)の期待値を共有する前に、まずマネジメントされる側(私)がどういうタイプで何を重視しているか聞いてもらえたのが面白いなと思った。確かに、「会社ではそこそこのお給料もらって残業せずに趣味のOSS活動に時間と脳のリソースを割きたい」というタイプの人に「こうすれば昇進できるからもっと頑張ろう!こういう目標どう!?!?」みたいな話しても全く響かないと思う。ちなみに自分は普通に昇進してお給料上げたいタイプ。今の自分がこういうタイプです、という前提を共有して(1)、じゃあ今後どういうキャリアに進みたいか?そのために今期はこういう目標にしましょうという合意をする(2)。(2)で決めた目標は定期的な1on1で二人でチェック。ヤバそうだったら相談し、期末の評価時に決定的な手遅れな状態になるのを防ぐ。2 この1on1も、何のためにやるのか(目標の進捗チェック?それともただの雑談?)を最初に共有するのが大事。ある程度放っておかれてもそれなりに仕事をこなせる人にもちゃんと定期的に時間をかけてくれるのはありがたかった。3
また、1on1ではマネジメントされる側の話を一方的にするのではなく、マネジメントするがわが今どういう状況なのかシェアしてくれるのもよかった。

相談した時のアクションがめちゃ早い

1on1で「これもしかしたら後々問題になるかも」とぽろっと言ったことに対し、数時間後には関係者に連絡がとられ会議が設定され、そのままいい感じに解決されたことがあって本当にすごいと思った。相談した時のアクションが素早いと信頼貯金が爆上がりするし次も相談しようという気になる。次も相談したくなるというのは大事なポイントだと思っていて、「この人に何言ってもしょうがないな…」と思われたらマネジメント関係が成り立たなくなり相互に不幸である。

叱る時のポイント

マネージャーは何かをやらかしたメンバーを叱る必要がある。よく言われる事だが、みんなの前ではなく会議室に呼び出されて注意されたのは注意される側としてよかった。叱るときも「今回は初めてだからいいよ。誰でも失敗するから。でも次からはダメだよ」と仏のような注意だった😇

キャリアアップを手伝ってくれる

「一つ上のレベルに昇進させたいんだけど、ボードメンバーを説得するにはhogehogeの経験がないと厳しい…そういうわけでこのプロジェクトやってみない?」その後本当にレベルが上がって最高オブ最高でした。ありがとうございました。

信頼貯金を失うことをしない

私は初対面の人のことを基本的にいい人だと思っているので、その人が(私にとって)残念なことをしない限りは信頼貯金が失われることはない。信頼貯金を失うような行動というのは人によると思うが、一般的に「約束をちゃんと守る」「人を傷つけるような行動はしない」「自分の職務は責任を持って行う」など人として当たり前のことを守っていれば大丈夫じゃないかなと思っている。当たり前と書いたが、この当たり前を当たり前に実行できる人は意外と少ないのではないかなと思う。

マネジメントされる側としてやるといいこと

ドキュメントを用意する

話したいトピックを1on1までに事前にドキュメントに書いておく。箇条書きレベルで良い。1on1ではそのドキュメントをお互い見ながら話をする。Google docsなどリアルタイムで共同編集できる物があると良い。話しながらアドバイスやNext action、追加で話した話題などを書き込んでいくとそのまま議事録になるし、書かれたことが意図と違うとそこで気がつけるのであとで困ったことにならないで済む。この議事録は期末の自己評価の際にも使えるので便利。

自分からアラートがあげられる

当たり前だけど、困ったことが起きたときは自分からちゃんとアラートをあげられることが大事。定期的な1on1の予定がずっと先で、このままでは手遅れになりそうだったらちゃんとEMを捕まえて報告・相談する。上記の「認識を最初に合わせる」でも書いたが、ここで「EMがどれくらい忙しそうか?」「今EMがやっている仕事内容よりも優先的に聞いてもらうべき内容か?」を判断するために日頃からEMが今何をやっていてどれくらい忙しいかを把握しておいた方がいい。

おまけ:1on1のときにいつも聞いていたこと

  • 「私が知っておくべきことは何かありますか?」
    • 自分が関わる他チームの動向なんかを教えてもらえることがある
  • 「今何に忙しいですか?」
    • 上で書いた通り

言いたいことは以上です。


  1. あくまでマネジメントするのであればという前提の話。初期フェーズのベンチャーでは期毎の目標とか決めてもすぐに状況が変わったりするので、とにかく前に進めることが最重要でマネジメントとかしないんじゃないかなと妄想しています。初期のベンチャーで働いたことないので妄想です。

  2. 個人的に期末で「あの時のあれがアレだったので評価据え置き」「本当はあの時ああして欲しかったんだよね」みたいな事言われるの、結構モチベーション削られる。

  3. 手をかけないといけない人に手をかけるのは必要だが、実は手をかけなくても大丈夫な人にこそもっと伸びてもらうために手をかけるべきという説がこの本で紹介されていて面白かった。

産休・育休中に読んで良かった本のまとめ

はじめに

産休・育休中に読んで良かった本を周囲の方々に教えてもらったので読んで良かったもののメモ。オススメしてもらった量がすごいので、特に自分の興味にあっているものや実際の育児に役立った(子どもの個性に合ったor親のライフスタイルに合った)物を選んで書いていく。

実用的なやつ

ネントレ本

賛否両論ある(?)ジーナ本、うちの子には合っているようでやって良かった。生活リズムが早いうちから整ったことで家族全員が楽になった。あと1日のスケジュールが細かく決まっているのが逆に助かった。子育て中は意思決定に脳のリソースがかなり持っていかれる実感があるので、「あと1時間でミルク」「これが終わったらお風呂」などタスクを誰かに決めてもらうのが精神的に楽だった。

発達全般

細かい月齢ごとに何ができるようになる・どんなことに気をつけるかが書いてあるので非常に参考になった。月齢が上がるたびに見返している。

離乳食

これを読んで主食はライスシリアルにしたけど、その他は今のところ手作りしている。うちの子はブレンダーの音が怖いらしくマジ泣きするのでBaby Brezzaを買ってかなり助かった。

産後すれ違い対策

もしこれを読んでいるあなたが「今妊娠中で最高に幸せでハッピーな子育てライフが待っているはず!」という人であればここから先は読まない方がいいかもしれない。(私はビビリなので妊娠中にこの手の本をたくさん読んだが、今のところ夫との関係は変わらず良好である。)

産後クライシス

(009)産後クライシス (ポプラ新書)

(009)産後クライシス (ポプラ新書)

第1章から衝撃的なデータに横っ面を殴られる。そのデータとは、「配偶者といると本当に愛していると実感する」と答えた人の割合が男女でかなり差が出る。2歳時期になると夫が51.7%に対して妻が34.0%...ちなみに妊娠期は夫も妻も同じ74.3%なので2年間に何が起こったのか想像すると恐ろしくなる。こうした事態を避けるために妻向け・夫向けにそれぞれアドバイスが書かれていた。これだけ見ると夫を責める方向に向かうように見えるかもしれないが、実際は今の日本の社会システムのせいでで夫が育児参加したくてもできないなどの問題点が指摘されていて妻側の啓蒙にも良いと思う。

ふたりは同時に親になる

先ほどの「産後クライシス」よりも若干マイルドな気がする。一番記憶に残ったのは挿絵で、妻が赤ちゃんを背負って崖にぶら下がっているところを「(仕事で)俺無理だから家事代行頼めば?」と言って立ち去り家事代行の人と母が妻を引っ張り上げている絵(NGケース)と、「みんなを連れて助けに来たぞ!」と言って家事代行の人と母と一緒に妻を引っ張り上げる男性の絵(OKケース)。文字で説明するのが難しいので何言ってんだって感じだと思うけど、この絵を見たときにとても腹落ちした。

その他の感想はtweetのツリーをどうぞ

科学的な読み物

ここからは完全に趣味の話です。

ヒトの発達の謎を解く

赤ちゃんが大きくなる時に脳の中で何が起こっているのか?読んでいると「人間、よくこんなので社会を維持してるな…」って思える。内容が気になる方はこちらのツリーをどうぞ。

「家族の幸せ」の経済学

これは紹介してもらったのではなく、Kindleセールで見つけて面白そうだったのでポチったやつ。かなり良かった。社会学というか面白い統計データの紹介で、日本だとデータが少ないのか外国の事例が多かった。そのためどこまで日本社会に適用できるかわからないが普通に読んでて楽しかったのでおすすめ。 面白ポイントはこちらのツリーをどうぞ。

子どもは40000回質問する

これもKindleセールで買ったやつ。邦題が割とアレで、実際は子供にフォーカスした本ではなく、原題(Curious)の通り好奇心全般について書かれた本。なので育児本だと思って読み始めると肩透かしな可能性が高い。個人的には面白かったので興味がある人はこちらのツリーをどうぞ。

言いたいことは以上です。

Datastoreのエラーハンドリングの仕組みを調べたメモ

Android JetpackのDatastoreがリリースされた。1

developer.android.com

非同期処理でread/writeができるshared preferenceみたいなものかというぼんやりした理解だったが、このつぶやきを拝見して

Datastoreはどうやってエラーハンドリングしているのか興味を持ったので、内部実装を調べてみた。(ver. 1.0.0-alpha01)

Datastore内部実装

まず、writeに失敗してIOExceptionが出るのはここ

internal class SingleProcessDataStore<T>(

    internal fun writeData(newData: T) {
        file.createParentDirectories()

        val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)
        try {
            FileOutputStream(scratchFile).use { stream ->
                serializer.writeTo(newData, stream)
                stream.fd.sync()
                // TODO(b/151635324): fsync the directory, otherwise a badly timed crash could
                //  result in reverting to a previous state.
            }
            if (!scratchFile.renameTo(file)) {
                throw IOException("$scratchFile could not be renamed to $file")
            }
        } catch (ex: IOException) {
            if (scratchFile.exists()) {
                scratchFile.delete()
            }
            throw ex
        }
    }

ここで投げられたexceptionはdownstream channelに流れる。

    private val actor: SendChannel<Message<T>> = scope.actor(
        capacity = UNLIMITED
    ) {
        try {
            messageConsumer@ for (msg in channel) {
                if (msg.dataChannel.isClosedForSend) {
                    // The message was sent with an old, now closed, dataChannel. This means that
                    // our read failed.
                    continue@messageConsumer
                }

                try {
                    readAndInitOnce(msg.dataChannel)
                } catch (ex: Throwable) {
                    resetDataChannel(ex)
                    continue@messageConsumer
                }

                // We have successfully read data and sent it to downstreamChannel.

                if (msg is Message.Update) {
                    msg.ack.completeWith(
                        runCatching {
                            // この関数の中で上記のwriteData()がcallされている
                            transformAndWrite(msg.transform, downstreamChannel())
                        }
                    )
                }
            }
        } finally {
            // The scope has been cancelled. Cancel downstream in case there are any collectors
            // still active.
            downstreamChannel().cancel()
        }
    }

runCatching{}はblock内で発生したexceptionをResultでwrapして返却するので、exceptionはcompleteWith()の引数に渡される。
Message.UpdateのackはupdateData()でawaitされてreturnしている。

    override suspend fun updateData(transform: suspend (t: T) -> T): T {
        val ack = CompletableDeferred<T>()
        val dataChannel = downstreamChannel()
        val updateMsg = Message.Update<T>(transform, ack, dataChannel)

        actor.send(updateMsg)

        // If no read has succeeded yet, we need to wait on the result of the next read so we can
        // bubble exceptions up to the caller. Read exceptions are not bubbled up through ack.
        if (dataChannel.valueOrNull == null) {
            dataChannel.asFlow().first()
        }

        // Wait with same scope as the actor, so we're not waiting on a cancelled actor.
        return withContext(scope.coroutineContext) { ack.await() }
    }

で、このupdateData()DataStore#edit()でcallされる。

suspend fun DataStore<Preferences>.edit(
    transform: suspend (MutablePreferences) -> Unit
): Preferences {
    return this.updateData {
        // It's safe to return MutablePreferences since we make a defensive copy in
        // PreferencesDataStore.updateData()
        it.toMutablePreferences().apply { transform(this) }
    }
}

こうやって書けばエラーをハンドリングできる

Datastoreの使い方の概要ページ見たらeditをコールする時にexceptionのcatchとかしてないけど、disk書き込みエラーがたまーに発生するのであればちゃんとcatchした方がいいと思った。

下記は既存の値を読み込んで1足して保存する場合の例。

try {
    dataStore.edit { settings ->
        val currentValue = settings[KEY_FOO]?.toMutableSet() ?: mutableSetOf()
        settings[KEY_FOO] = currentValue + 1
    }
} catch (e: IOException) {
    // do something if need (e.g. retry)
}

ちなみにshared preferenceのapply)は特にexceptionを投げてくれないのでそもそもエラーを検出できないっぽい。


  1. Datastoreという名前だと、ググった時にGoogle Cloud Datastoreとカニバるので名前もうちょっとどうにかして欲しかったです。

MotionLayoutでvisibilityを制御するときのメモ書き

MotionLayoutでvisibilityを制御するときにちょっと困ったのでメモを残す。

やりたいこと

  • あるイベント契機でアニメーションを表示する
    • イベントはコードからtrigger
    • アニメーションとして画像を画面中央から上部に動かす(Twitterのお誕生日の風船みたいな感じ)
  • アニメーションはMotionLayoutでstartとendを定義するが、startのタイミングでvisibilityをVISIBLEにしたい。

こうやれば動くよ

Layout定義

最低限のattributeだけ書く。残りはいい感じに補完すること。 アニメーションしたい画像のvisibilityはGONEにしておく。

<androidx.constraintlayout.motion.widget.MotionLayout
    android:id="@+id/motion_base"

    >

    <ImageView
        android:id="@+id/animation_image"
        android:visibility="gone"

    />
/>

MotionScene

ポイントはvisibilityの設定をLayoutタグの中ではなく、PropertySetの中に記載すること。ちなみにLayoutタグの中でvisibilityをVISIBLEにしてしまうとxml layout fileが読み込まれたタイミングでvisibilityがVISIBLEになってしまう。(Layoutタグの中は省略しているので適宜補完。)

<MotionScene 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="1000">
    </Transition>

    <ConstraintSet android:id="@+id/start">
        <Constraint android:id="@+id/animation_image">
            <Layout
                android:layout_width="0dp"
                android:layout_height="wrap_content"/>
            <PropertySet
                android:visibility="visible"
                app:visibilityMode="ignore" />
        </Constraint>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@+id/animation_image"
            android:translationY="-300dp" />
    </ConstraintSet>
</MotionScene>

アニメーションをtriggerするコード

PropertySetでvisibilityを変更するのに加えて、アニメーションをtriggerする直前にコードでもvisibilityを変えてやらないとVISIBLEにならない。

findViewById<ImageView>(R.id.animation_image).visibility = View.VISIBLE
findViewById<MotionLayout>(R.id.motion_base).transitionToEnd()

言いたいことは以上です。

Scoped Storageでパス指定してファイルにアクセスできなくなった

はじめに

見て見ぬふりをしてきたScoped Storageにハマったのでメモ書きを残す。

Android Q (10) から導入されたScoped Storage。「端末内部のfileやらのアクセス権限周りや保存領域が変わったんだろうなー」くらいの雑な理解で見て見ぬふりを続けてきたが思わぬところでハマってしまった。

Glideで画像を表示できなくなった

以前は動いていたコードが動かなくなった。具体的にはContentResolver経由で MediaStore.Video.Media.DATA のStringを取ってきて、Glideのinto()に渡してもpermissionがないというerrorが出て画像が表示されない。

val PROJECTION = listOf(
    MediaStore.Video.Media.DATA,
)

val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(),
        null, null, MediaStore.Video.Media.DATE_MODIFIED)

if (cursor.moveToLast()) {
    do {
        val data = cursor.getString(cursor.getColumnIndex(PROJECTION[0]))
    } while (cursor.moveToPrevious())
}
cursor.close()
Glide.with(context.applicationContext)
    .load(data)
    .into(imageView) // 表示されない

AndroidManifestに android:requestLegacyExternalStorage="true" をつけてやると上記のコードでも動くので、Scoped Storage周りで問題が起きているのは間違いない。ちなみに、android:requestLegacyExternalStorage="true"でとりあえずこの問題は回避できるが、

警告: 来年度のメジャー プラットフォーム リリースでは、ターゲット SDK レベルに関係なく、すべてのアプリで対象範囲別ストレージが必須となります。

とあるので早めに対処することをお勧めする。

developer.android.com

^ 上記ページはなぜか言語設定を英語にすると別のページに転送される。同じ内容の英語リソースが見つけられなかった。

対処法

Scoped Storageが導入に伴い、MediaStore.Video.Media.DATAがdeprecatedになった。代わりにBaseColumns._IDを取得し、ContentUris.withAppendedId()を使ってUriを取得すれば行ける。

参考

medium.com

以下コード例

val PROJECTION = listOf(
        BaseColumns._ID,
)

val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(),
        null, null, MediaStore.Video.Media.DATE_MODIFIED)

if (cursor.moveToLast()) {
    do {
        val idColumnIndex = cursor.getColumnIndex(PROJECTION[0])
        val contentUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                cursor.getLong(idColumnIndex)
        )
    } while (cursor.moveToPrevious())
}
cursor.close()
Glide.with(context.applicationContext)
    .load(contentUri.toString) // 同じString型を渡しても今後は表示される
    .into(imageView)

何が起きているのか

せっかくなのでGlideの内部実装を追ってみた。(version: 4.11.0)

into()がコールされた後に画像のロードが走るが、その際にload()で渡されたstringのパターンを見ている。stringが '/'で始まるかどうかで処理が分岐する。

public class StringLoader<Data> implements ModelLoader<String, Data> {

  @Nullable
  private static Uri parseUri(String model) {
    Uri uri;
    if (TextUtils.isEmpty(model)) {
      return null;
      // See https://pmd.github.io/pmd-6.0.0/pmd_rules_java_performance.html#simplifystartswith
    } else if (model.charAt(0) == '/') {
      uri = toFileUri(model);
    } else {
      uri = Uri.parse(model);
      String scheme = uri.getScheme();
      if (scheme == null) {
        uri = toFileUri(model);
      }
    }
    return uri;
  }

  private static Uri toFileUri(String path) {
    return Uri.fromFile(new File(path));
  }

MediaStore.Video.Media.DATA でStringを取ってくる場合は/で始まるパスになっている。
例:

/storage/emulated/0/DCIM/Camera/VID_20200821_00000001.mp4

一方、BaseColumns._IDからUriを取得する場合は(当たり前だが)パスではなくUriになっている。
例:

content://media/external/video/media/12345

ここでの処理の分岐で画像が出たり出なかったりする。

前者はパスを指定してFileオブジェクトを生成しようとするが、ここでpermissionがないとしてerrorになる。下記リンクは先ほどと同じページだが、パス経由でファイルにアクセスできないと明記してある。繰り返しになるが、この情報は英語リソースで見つけることができない。誰か見つけたらリンク教えてください…

注: 対象範囲別ストレージするアプリは、「/sdcard/DCIM/IMG1024.JPG」のようなパスに対する直接カーネル アクセス権限を有していません。アプリがこのようなファイルにアクセスするには、MediaStore を使用して、openFile() などのメソッドを呼び出す必要があります。

developer.android.com