musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

OpenGL(描画)の高速化

今回は音符を描画するために使用しているライブラリOpenGLの高速化の話。

描画の高速化(コマ落ち解消)



はじめに

musicLineでは、コミュニティでユーザーが投稿した曲を再生できるようになっており、再生している曲のイメージを可視化できるソングビジュアライゼーションという機能があります。

ソングビジュアライゼーション


Twitter#createbymusiclineタグから引っ張ってきたツイートからソングビジュアライゼーションのテーマを紹介します。
(すみません勝手に使ってます。)

ピアノロール
スペクトラム
バブル
サークル
波形

そして、やばい曲の到来

すみません。やばいという表現は少し失礼ですね。
音符をこれでもかと敷き詰めた物凄い曲を再生する時、ソングビジュアライゼーションの機能の動作がとても重くなる状態です。

音符を敷き詰めた物凄い曲
https://3musicline.com/community/149961 (アプリリンク)


通常の曲であれば、30FPS(1秒間に30フレーム描画)程度で動作しますが、この曲で音符が物凄くあるところは3FPSになります。
3FPSだと1秒間に3フレームしか描画しないので、とてもカクカク動きます。(最低でも10FPSは欲しいところ。。。)

音符が多くなるとカクカクした動きに


今回はソングビジュアライゼーションのテーマの中でもピアノロールを高速化ができそうだったのでしてみました。



描画の高速化

OpenGLでは様々な工程がありますが、その処理をGPUで行うことになります。

OpenGLでの描画の流れについての参考ページ


そのため、アプリ側のCPUから描画する頂点情報(位置、色等)をGPUに送ることになるのですが、この転送時間をいかに減らせるかが高速化の1つのポイントとなります。


VBOへ頂点情報を一気に送る

VBO(Vertex Buffer Object)はGPU側の領域で頂点情報を格納しておく場所です。


現在は1フレーム分の描画に必要な頂点情報収集して、毎回配列でGPUへ転送しています。

毎フレームで1フレーム分の頂点情報を転送


しかし、毎フレームで情報収集とGPU転送の時間が掛かるため、非効率です。特にピアノロールのような画面が左から右へ移り変わるような単純なアニメーションであれば、予め数フレーム分の頂点情報を一気に送ることが有効です。数フレーム分の頂点情報をストックしておいて、あとは毎フレームでスクロール位置のみGPUへ転送すれば、描画するフレーム部分をGPU側で計算できます。

数フレーム分の頂点情報を一気に転送
描画部分をGPU側で計算


つまり、毎フレームCPU側で計算した頂点情報を転送するのではなく、数フレーム分の頂点情報を一気に送ってGPU側で描画部分を計算することで、転送回数を削減することができます。


IBOで重複する頂点情報を省略

IBO(Index Buffer Object)はGPU側の領域で頂点インデックス情報を格納しておく場所です。


例えば音符1つを描画するためには、三角形ポリゴンを2枚使います。頂点が各々3点なので合計6点となります。

三角形ポリゴン2枚で6頂点

しかし、2点は共有する頂点なので2点分の頂点情報が重複します。
通常は頂点配列を転送すると、前から3点ずつをグルーピングして三角形ポリゴンを描画しますが、三角形ポリゴンの頂点を指定することができます。
三角形ポリゴンの構成する頂点インデックス(頂点配列の前からの番号)を指定することで、重複する情報を省略して三角形ポリゴンを描画することができます。

頂点インデックスの使用

重複した無駄な情報を省くことで転送データ量が減り、高速化に繋がります。



実装

AndroidOpenGLのVBOとIBOを使用して、効率良く描画をしてみます。

参考ページ

処理の流れは

  1. バッファを作成
  2. 頂点情報をバッファへ転送
  3. 頂点情報をLocationにバインド
  4. 頂点インデックスを指定して描画

となります。


1. バッファを作成

val bufferIds: IntArray // VBO, IBO の確保領域のIDリスト

bufferIds = IntArray(6).also {
    GLES20.glGenBuffers(6, it, 0)
}

この例では、6個のバッファ(VBO x 5 + IBO)を確保しています。

VBO

  • 座標
  • UV位置
  • 角丸幅
  • 音符のX範囲

IBO

  • 頂点インデックス


ちなみに、使用後必要がなくなったバッファは削除することでメモリを節約します。

GLES20.glDeleteBuffers(6, bufferIds, 0)


2. 頂点情報をバッファへ転送

val vertexBuffer: FloatBuffer = ... // 音符の4頂点座標を集めてFloatBufferに変換する

val floatByte = 4  // floatは4byte
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0]) // バッファIDの指定

vertexBuffer.position(0) // バッファのポインターを先頭へ
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, vertexBuffer.capacity() * floatByte, vertexBuffer, GLES20.GL_STATIC_DRAW) // 頂点を転送

GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0) // バッファIDの解除

この例では、頂点座標を転送しています。
glBindBufferの第一引数にGLES20.GL_ARRAY_BUFFERと指定することで、VBOへ転送します。
glBindBufferの第二引数に確保したバッファIDを指定します。


また、頂点インデックスはGLES20.GL_ELEMENT_ARRAY_BUFFERと指定して、IBOへ転送します。

val indexBuffer: IntBuffer = ... // 頂点インデックスを計算してIntBufferに変換する

val intByte = 4
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5]) // バッファIDの指定

indexBuffer.position(0)
GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.capacity() * intByte, indexBuffer, GLES20.GL_STATIC_DRAW) // 頂点インデックスを転送

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0) // バッファIDの解除


3. 頂点情報をLocationにバインド

GLES20.glEnableVertexAttribArray(noteVtPosLoc) // Location有効
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0]) // バッファIDの指定

GLES20.glVertexAttribPointer(noteVtPosLoc, 2, GLES20.GL_FLOAT, false, 0, 0) // 頂点情報をLocationにバインド

GLES20.glDisableVertexAttribArray(noteVtPosLoc) // Location解除
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0) // バッファIDの解除

2. と同様にglBindBufferでバッファIDを指定し、glVertexAttribPointerでShaderで使用する変数のLocationにバインドします。


4. 頂点インデックスを指定して描画

var faceIndexesBufferCount = // 頂点インデックスの数

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5]) // バッファIDの指定

GLES20.glDrawElements(GLES20.GL_TRIANGLES, faceIndexesBufferCount, GLES20.GL_UNSIGNED_INT, 0) // 描画

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0) // バッファIDの解除

2. と同様にglBindBufferでバッファIDを指定し、glDrawElementsの第四引数を0に設定することで頂点インデックスを指定して描画します。



全コードを表示

class MidiNotesShader() {

    // region Property

    // Location
    private val noteVtPosLoc: Int // 頂点位置
    private val noteVtUvLoc: Int // UV位置
    private val noteColorLoc: Int // 頂点色
    private val noteRoundWidthLoc: Int // 角丸幅
    private val noteRangeLoc: Int // 音符のX範囲
    private val scrollLoc: Int  // スクロール
    private val barPosLoc: Int  // 再生バー位置
    // endregion

    private val bufferIds: IntArray // VBO, IBO の確保領域のIDリスト

    private var vertexBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var uvBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var colorBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var roundWidthBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var rangeBuffer: FloatBuffer = toFloatBufferf(listOf())

    private var faceIndexes = listOf<Int>()
    private var faceIndexesBufferCount = 0

    private val shaderProgramId: Int = GLES20.glCreateProgram()
    private val program = Program()
    private var vertexShader: Int? = null
    private var fragmentShader: Int? = null
    // endregion

    // region Initializer
    init {
        loadProgram()

        // VBO, IBO のバッファ領域確保
        bufferIds = IntArray(6).also {
            GLES20.glGenBuffers(6, it, 0)
        }

        // 頂点情報
        noteVtPosLoc = GLES20.glGetAttribLocation(shaderProgramId, program.vtPosAttr)
        noteColorLoc = GLES20.glGetAttribLocation(shaderProgramId, program.colorAttr)
        noteRoundWidthLoc = GLES20.glGetAttribLocation(shaderProgramId, program.roundWithAttr)
        noteRangeLoc = GLES20.glGetAttribLocation(shaderProgramId, program.rangeAttr)
        noteVtUvLoc = GLES20.glGetAttribLocation(shaderProgramId, program.vtUvAttr)

        // Uniform
        scrollLoc = GLES20.glGetUniformLocation(shaderProgramId, program.scrollUni)
        barPosLoc = GLES20.glGetUniformLocation(shaderProgramId, program.barPosUni)
    }

    fun dispose() {
        GLES20.glDeleteBuffers(6, bufferIds, 0)
        deleteProgram()
    }
    // endregion

    // region Method

    // 数フレーム分必要な音符の情報を計算する
    fun calcBufferData(frameCount: Int) {

        // 数フレーム分の音符を取得する
        val screenNotes = getNotesInScreen(frameCount)

        // 音符の頂点を集める
        vertexBuffer = screenNotes.getNotePointsBuffer()

        // 音符のUV座標を集める
        uvBuffer = screenNotes.getUVsBuffer()

        // 音符の色を集める
        colorBuffer = screenNotes.getColorsBuffer()

        // 音符の角丸の幅を集める
        roundWidthBuffer = screenNotes.getRoundWidthsBuffer()

        // 音符のX範囲を集める
        rangeBuffer = screenNotes.getNoteRangesBuffer()

        // 頂点インデックスを計算する
        val indexes = (0 until screenNotes.size * 4).groupBy { it / 4 }.values.flatMap { (a, b, c, d) ->
            listOf(a, b, c, d, c, b)
        }
        faceIndexes = indexes
    }

    // BufferDataをVBO, IBOへ転送する
    fun sendBufferData() {

        val floatByte = 4

        // 音符の頂点を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0])
        vertexBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, vertexBuffer.capacity() * floatByte, vertexBuffer, GLES20.GL_STATIC_DRAW)

        // 音符のUV座標を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[1])
        uvBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, uvBuffer.capacity() * floatByte, uvBuffer, GLES20.GL_STATIC_DRAW)

        // 音符の色を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[2])
        colorBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, colorBuffer.capacity() * floatByte, colorBuffer, GLES20.GL_STATIC_DRAW)

        // 音符の角丸の幅を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[3])
        roundWidthBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, roundWidthBuffer.capacity() * floatByte, roundWidthBuffer, GLES20.GL_STATIC_DRAW)

        // 音符のX範囲を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[4])
        rangeBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, rangeBuffer.capacity() * floatByte, rangeBuffer, GLES20.GL_STATIC_DRAW)

        // 頂点インデックスを転送
        val indexBuffer = toIntBuffers(faceIndexes)
        val intByte = 4
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5])
        indexBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.capacity() * intByte, indexBuffer, GLES20.GL_STATIC_DRAW)
        faceIndexesBufferCount = faceIndexes.size

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0)
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
    }

    fun draw() {

        // GLSL設定
        GLES20.glUseProgram(shaderProgramId)

        // 機能有効
        GLES20.glEnable(GLES20.GL_BLEND)
        GLES20.glDisable(GLES20.GL_DEPTH_TEST)

        // 機能設定
        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_DST_COLOR)

        // 変数有効
        GLES20.glEnableVertexAttribArray(noteVtPosLoc)
        GLES20.glEnableVertexAttribArray(noteVtUvLoc)
        GLES20.glEnableVertexAttribArray(noteColorLoc)
        GLES20.glEnableVertexAttribArray(noteRoundWidthLoc)
        GLES20.glEnableVertexAttribArray(noteRangeLoc)

        // 変数設定
        val crossTime = 6_000 // 6秒で画面を横切る
        val playBarPosition = 0.25 // 0(左端) ~ 1(右端)
        val barPos = playBarPosition * 2f - 1f // -1 ~ 1
        GLES20.glUniform1f(scrollLoc, time / crossTime * 2f - barPos)

        GLES20.glUniform1f(barPosLoc, barPos)

        // 音符の頂点をnoteVtPosLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0])
        GLES20.glVertexAttribPointer(noteVtPosLoc, 2, GLES20.GL_FLOAT, false, 0, 0)

        // 音符のUVをnoteVtUvLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[1])
        GLES20.glVertexAttribPointer(noteVtUvLoc, 2, GLES20.GL_FLOAT, false, 0, 0)

        // 音符の色をnoteColorLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[2])
        GLES20.glVertexAttribPointer(noteColorLoc, 4, GLES20.GL_FLOAT, false, 0, 0)

        // 音符の角丸幅をnoteRoundWidthLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[3])
        GLES20.glVertexAttribPointer(noteRoundWidthLoc, 1, GLES20.GL_FLOAT, false, 0, 0)

        // 音符のX範囲をnoteRangeLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[4])
        GLES20.glVertexAttribPointer(noteRangeLoc, 2, GLES20.GL_FLOAT, false, 0, 0)

        // 頂点インデックスをバインド
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5])
        // 描画
        GLES20.glDrawElements(GLES20.GL_TRIANGLES, faceIndexesBufferCount, GLES20.GL_UNSIGNED_INT, 0)


        // 変数解除
        GLES20.glDisableVertexAttribArray(noteVtPosLoc)
        GLES20.glDisableVertexAttribArray(noteVtUvLoc)
        GLES20.glDisableVertexAttribArray(noteColorLoc)
        GLES20.glDisableVertexAttribArray(noteRoundWidthLoc)
        GLES20.glDisableVertexAttribArray(noteRangeLoc)

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0)
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)

        // 機能解除
        GLES20.glDisable(GLES20.GL_BLEND)
    }



    // Shaderプログラムを読み込む
    private fun loadProgram() {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, program.vertexCode).also {
            vertexShader = it
        }
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, program.fragmentCode).also {
            fragmentShader = it
        }

        GLES20.glAttachShader(shaderProgramId, vertexShader)
        GLES20.glAttachShader(shaderProgramId, fragmentShader)
        GLES20.glLinkProgram(shaderProgramId)
    }

    // Shaderプログラムを削除する
    private fun deleteProgram() {
        GLES20.glDeleteProgram(shaderProgramId)
        vertexShader?.let { GLES20.glDetachShader(shaderProgramId, it) }
        fragmentShader?.let { GLES20.glDetachShader(shaderProgramId, it) }

        GLES20.glDeleteShader(GLES20.GL_VERTEX_SHADER)
        GLES20.glDeleteShader(GLES20.GL_FRAGMENT_SHADER)
    }

    // endregion

    // region InnerClass
    class Program {

        // region Property

        // uniform
        val scrollUni = "u_scroll"
        val barPosUni = "u_barPos"

        // attribute
        val vtPosAttr = "v_pos"
        val colorAttr = "v_color"
        val roundWithAttr = "v_round_width"
        val vtUvAttr = "v_uv"
        val rangeAttr = "v_range"

        // varying
        private val colorVary = "f_color"
        private val roundWithVary = "f_round"
        private val vtUvVary = "f_uv"
        private val bright = "f_bright"
        // endregion

        // region Code
        // uniform: プリミティブごとの情報(描画呼び出し全体で一定)
        // attribute: 頂点毎の情報(通常:位置、法線、色、UVなど)
        // varying: フラグメント(ピクセル)ごとの情報。頂点間で値が補完される(Vertexの入力→Fragmentで補間された出力: 1頂点=>多ピクセル)
        val vertexCode = """
            attribute vec2 $vtPosAttr;
            attribute vec4 $colorAttr;
            attribute float $roundWithAttr;
            attribute vec2 $vtUvAttr;
            attribute vec2 $rangeAttr;
            
            varying vec4 $colorVary;
            varying vec2 $vtUvVary;
            varying float $roundWithVary;
            varying float $bright;
            
            uniform float $scrollUni;
            uniform float $barPosUni;
            
            void main() {
                gl_Position = vec4($vtPosAttr - vec2($scrollUni, 0.), 0.0, 1.0);
                
                $colorVary = $colorAttr;
                $roundWithVary = $roundWithAttr;
                $vtUvVary = $vtUvAttr;
                
                float start = $rangeAttr.x;
                float end = $rangeAttr.y;
                // noteの左がスクロールバーの左の時(鳴り終わり)
                float barPos = $barPosUni + $scrollUni;
                if (end < barPos) {
                    float lengthTime = end - start; // endからの経過時間
                    float power = clamp((1.3 - lengthTime) / 1.3, 0., 1.); //1~0 4秒で0
                    float endBright = power * 0.7;

                    float elapsedScroll = barPos - end; // endからの経過時間
                    float decay = clamp(elapsedScroll / 0.2, 0., 1.); //0=1 0.5秒で1 減衰
                    
                    $bright = clamp(endBright - decay, 0., 1.);
                 } else if(barPos < start) {
                    // まだ鳴ってない
                    $bright = 0.;
                 } else {
                    // 鳴っている
                    float elapsedScroll = barPos - start;
                    float power = clamp((1.3 - elapsedScroll) / 1.3, 0., 1.); //1~0 4秒で0
                    $bright = power * 0.7;
                 }
            }
        """.trimIndent()

        val fragmentCode = """
            precision mediump float;
            varying vec4 $colorVary;
            varying float $roundWithVary;
            varying vec2 $vtUvVary;
            varying float $bright;
            
            void main() {

                vec2 pos = $vtUvVary-vec2(0.5);
                float s = -0.5 + $roundWithVary;
                float e = 0.5 - $roundWithVary;
                
                // 丸角にする
                float dis0 = length(vec2((pos.x - s) * (0.5 / $roundWithVary), pos.y));
                float dis1 = length(vec2((pos.x - e) * (0.5 / $roundWithVary), pos.y));
                if((0.5 < dis0 && pos.x < s) || (0.5 < dis1 && e < pos.x)) discard;
                      
                gl_FragColor = vec4(mix($colorVary.rgb, vec3(1.), $bright), $colorVary.a);
            }
        """.trimIndent()

        // endregion
    }
    // endregion
}



おわりに

今回はVBOとIBOを用いてOpenGLの高速化を行いました。

改善後の動作

ご覧のように音符が物凄く増えても30FPSを維持し、スムーズな動作になりました。
ピアノロール以外のテーマでも高速化できそうであれば行うかもしれません。

ちなみに作曲画面で物凄く音符がある時は、ソロボタンや画面を拡大して音符の描画量を減らすことで遅延を少なく画面移動できます。
それでも、音符が物凄く多いと少し編集するだけでも動作が重いと思いますが。。。(すみません)

とにかく作者の執念を感じられる作品でした。



リンク先のページ情報を取得する

今回はリンク先のページ情報(タイトル、詳細、サムネイル)を取得する話。

ページ情報の表示



はじめに

musicLineではコミュニティでユーザー同士が会話するためのチャット機能があります。以前までそのチャットにYouTubeのURLを載せるとアプリ内で動画を再生できるように実装していましたが、そのライブラリのサポートが終了して機能しなくなりました。


今後はWeb技術のiFrameを使うことでYouTubeの埋め込みを実現する方法もあるようですが、これを機会にURLをコメントに送信したときの挙動を再検討しました。

そもそも今まではYouTube以外のURLを送っても、リンク先ページの情報がわからなく、URLテキストのみを表示する仕様になってました。

URL先ページ情報なし


しかし、この仕様ではリンク先のページに飛ばないと内容がわからなく、会話の中でリンク先を送ることに躊躇してしまうことがあると思います。
そこでYouTubeに限らずURLをコメントで送ることで、リンク先ページの情報を取得して、表示できるように改良しました。



ページ情報の取得

OGP (Open Graph Protcol)

WebページはHTMLで書かれていますが、特にリンクをシェアした時に使用してほしい文書や画像はOGP (Open Graph Protcol)に基づいて設定されます。

↑このリンクバーもOGPを元に情報を取得しているはず

具体的には、Webから以下のようなHTMLドキュメントを取得するので、ogメタタグの要素を確認すると情報を取得できます。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta property="og:title" content="タイトル" />
        <meta property="og:image" content="画像のURL" />
        <meta property="og:description" content="ページの説明" />
        ...

    </head>
    <body>
        ...
    </body>
</html>
プロパティ 内容
og:title タイトル
og:image 画像のURL
og:description ページの説明


実装

下記のページを元にAndroid+Kotlinでリンク先のページ情報を取得するように実装しました。


基本的にはJsoupライブラリでHTMLのURLを指定するだけで、要素の内容を取得できます。

val doc = Jsoup.connect(url).get()
val headEls = doc.head().children()
for (v in headEls) {
    val prop = v.attr("property")
    val content = v.attr("content")
    println("プロパティ:$prop、内容:$content")
}


ただ、メインスレッドでJsoup.connect(url)にてWebからHTMLをダウンロードすると例外が発生するため、ダウンロードする際はサブスレッドで行います。
ViewModelで処理を行っている場合は、viewModelScopeを使用するといいでしょう。

viewModelScope.launch(Dispatchers.Main) {
    
    // サブスレッドでHTMLを取得
    val doc = withContext(Dispatchers.IO) {
        try {
            Jsoup.connect(url).get()
        } catch (e: IOException) {
            e.printStackTrace()
            null
        }
    }
    ...
}


全コードを表示

viewModelScope.launch(Dispatchers.Main) {
    
    // サブスレッドでHTMLを取得
    val doc = withContext(Dispatchers.IO) {
        try {
            Jsoup.connect(url).get()
        } catch (e: IOException) {
            e.printStackTrace()
            null
        }
    }
    doc?.let {
        val headEls = it.head().children()
        for (v in headEls) {
            val prop = v.attr("property")
            val content = v.attr("content")
            when (prop) {
                "og:title" -> {
                    // Webページのタイトル
                }
                "og:description" -> {
                    // Webページの説明
                }
                "og:image" -> {
                    // WebページのサムネイルURL
                }
            }
        }
    }
}




おわりに

今回の実装でリンク先のイメージや詳細がわかるようになりました。

リンク先の詳細を表示


ちなみにYouTubeやニコニコのURLは検出して、再生マークを付けてます。
YouTubeのアプリ内再生は非対応となりましたが、タップでYouTubeに飛べるため、アプリ内で再生しなくても良いかと思います。

動画URLの再生マーク



includeタグでレイアウトを再利用する

Android開発で、同じようなレイアウトを部品化して再利用する話。
今回はmusicLineで実装中の機能「アクティビティログ」を例に解説します。

アクティビティログ


レイアウトを部品化

アクティビティログでは、6か所に同じようなレイアウトがあります。

同じようなレイアウト

各々のレイアウトの構成要素は4個あります。

レイアウトの構成要素

これら4個の要素を変更できるようにレイアウトファイルに変数を定義します。変数は@{ }でDataBindingすることで使用します。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!--変数の定義-->
    <data>
        <!--タイトル-->
        <variable
            name="title"
            type="String" />

        <!--数字-->
        <variable
            name="number"
            type="String" />

        <!--単位-->
        <variable
            name="digits"
            type="String" />

        <!--画像-->
        <variable
            name="imageSrc"
            type="android.graphics.drawable.Drawable" />
    </data>
    ...
        
        <!--変数の使用-->
        <TextView
            android:id="@+id/title_text_view"
            android:text="@{title}"
            tools:text="再生"
            ...
             />
    ...

</layout>

全コードを表示

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <!--タイトル-->
        <variable
            name="title"
            type="String" />

        <!--数字-->
        <variable
            name="number"
            type="String" />

        <!--単位-->
        <variable
            name="digits"
            type="String" />

        <!--画像-->
        <variable
            name="imageSrc"
            type="android.graphics.drawable.Drawable" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/count_layout"
        android:layout_width="80dp"
        android:layout_height="50dp"
        android:layout_gravity="center">

        <ImageView
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_gravity="center"
            android:scaleType="centerInside"
            android:src="@{imageSrc}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:src="@drawable/play" />

        <TextView
            android:id="@+id/title_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{title}"
            android:textSize="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            tools:text="再生" />

        <TextView
            android:id="@+id/count_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{number}"
            android:textSize="16dp"
            android:textStyle="bold|italic"
            app:layout_constraintBottom_toTopOf="@id/title_text_view"
            app:layout_constraintEnd_toStartOf="@id/zero_text_view"
            app:layout_constraintHorizontal_chainStyle="packed"
            app:layout_constraintStart_toStartOf="parent"
            tools:text="3.5" />

        <TextView
            android:id="@+id/zero_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="2dp"
            android:text="@{digits}"
            android:textSize="10dp"
            android:textStyle="bold|italic"
            app:layout_constraintBaseline_toBaselineOf="@id/count_text_view"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/count_text_view"
            tools:text="K" />
    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

こうすることで、レイアウトを再利用する時に文字や画像を変更でき、柔軟なレイアウト部品となります。
また、DataBindingだとデザイナーでプレビューした時に値が空のため、tools属性を使って、プレビュー時のみ使用する値を設定しておくことでデザインがしやすくなります。


部品化したレイアウトを張り付け

部品化したレイアウトを張り付ける時は、<include>タグを使用します。

<include
    layout="@layout/view_activity_log"
    android:layout_width="80dp"
    android:layout_height="50dp"
    app:title="@{`再生`}"
    app:number="@{viewModel.number}"
    app:digits="@{viewModel.digits}"
    app:imageSrc="@{@drawable/play}" />

こちらも値を代入する時は@{ }でDataBindingすることで使用します。テキストをDataBindingする時は``で囲む必要があります。

DialogFragmentでNavigationを使用する

皆様はAndroid開発において、画面推移をNavigation Componentで実装していますか。
今回はDialogFragmentでNavigationを使用したらハマった話。


musicLineでは、新しく作成する「データを移行」ダイアログにNavigationを使用しました。全体としては従来通りの方法で画面推移するけど、一部のダイアログ内ではNavigationを使用して画面推移したい場合に、実装事例とつまずきポイントを紹介します。


実装事例

musicLineでは作曲データを移行する時、以下の流れでデータ移行を操作します。

移行の流れ

移行項目の選択で

  • 一部のデータを移行
  • すべてのデータを移行

によって、異なる画面へ推移します。

一部のデータを移行
移行する作曲データを選択
すべてのデータを移行
移行する作曲データの数を確認

ナビゲーショングラフを作成して、操作によって4つの画面へ推移するように制御しました。

ナビゲーショングラフ

Navigation コードを表示

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/passing_song_data_navigation"
    app:startDestination="@id/dataPassingUserSelectorFragment"
    xmlns:tools="http://schemas.android.com/tools">

    <!--移行ユーザーの選択-->
    <fragment
        android:id="@+id/dataPassingUserSelectorFragment"
        android:name="...DataPassingUserSelectorFragment"
        android:label="DataPassingUserSelectorFragment"
        tools:layout="@layout/fragment_data_passing_user_selector">
        <action
            android:id="@+id/action_dataPassingUserSelectorFragment_to_dataPassingItemSelectorFragment"
            app:destination="@id/dataPassingItemSelectorFragment" />
    </fragment>

    <!--移行項目の選択-->
    <fragment
        android:id="@+id/dataPassingItemSelectorFragment"
        android:name="...DataPassingItemSelectorFragment"
        android:label="DataPassingItemSelectorFragment"
        tools:layout="@layout/dialog_list">
        <action
            android:id="@+id/action_dataPassingItemSelectorFragment_to_mySongsListUpFragment"
            app:destination="@id/mySongsListUpFragment" />
        <action
            android:id="@+id/action_dataPassingItemSelectorFragment_to_dataPassingConfirmationFragment"
            app:destination="@id/dataPassingConfirmationFragment" />
    </fragment>

    <!--移行データの選択-->
    <fragment
        android:id="@+id/mySongsListUpFragment"
        android:name="...MySongsListUpFragment"
        android:label="MySongsListUpFragment"
        tools:layout="@layout/fragment_list_my_songs_list_up"/>

    <!--移行データの確認-->
    <fragment
        android:id="@+id/dataPassingConfirmationFragment"
        android:name="...DataPassingConfirmationFragment"
        android:label="DataPassingConfirmationFragment"
        tools:layout="@layout/fragment_data_passing_confirmation">

        <argument
            android:name="dataPassingItem"
            android:defaultValue="SelectedSongs"
            app:argType="...DataPassingItemSelectorFragment$DataPassingItem" />
    </fragment>

</navigation>

移行ユーザーの選択
移行項目の選択
移行データの選択
移行データの確認
移行する4つの画面


従来の方法

musicLineリリース当初(2014年)はNavigation Componentがなく、FragmentTransactionを使った従来からの方法で実装していました。

でもこの従来の方法では、どのように画面推移するかがぱっと見でわかりずらいという欠点があります。
というのも、画面推移する方向を各コントローラー(ActivityやFragment)で記述するので、画面推移が複雑だと複数のファイルを辿っていかないと推移の流れがわからないからです。

Navigationを使用すると、画面がどう推移するかの関係性のみをXmlファイルに記述できるため、そのファイルのみで画面推移方向がわかります。

そのため、今回はNavigationの使用を試みたのですが、Navigationは基本的にActivity全体で使用することが想定されている?ようで、一部のダイアログのみにNavigationを使用することで注意点があります。


実装

まずは以下のページに沿ってNavigationを実装してみました。

  1. gradleに依存関係を追記
  2. ナビゲーショングラフのファイルを追加
  3. ナビゲーショングラフに推移関係を記述
  4. 使用するレイアウトにNavHostFragmentを追加
  5. 画面推移の実装


つまずきポイント

ダイアログを作成する時に例外発生

ダイアログでNavigationを使用するため、レイアウトファイルにNavHostFragment(Navigationを制御するFragment)を張り付けます。Fragmentの張り付けはFragmentContainerViewを使用しました。

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

    <!--Fragmentを張り付け-->
    <androidx.fragment.app.FragmentContainerView
        ...
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/data_passing_navigation"
        />
    ...

</layout>


しかし、ダイアログの作成でレイアウトファイルをinflateする時にランタイムエラーが発生!

class PassingDataDialogFragment : DialogFragment() {
    ...

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // 例外発生!!
        binding = DataBindingUtil.inflate(LayoutInflater.from(requireActivity()), R.layout.dialog_data_passing, null, false)
        
        ...
    }
}
android.view.InflateException: Binary XML file line #21: Binary XML file line #21: Error inflating class androidx.fragment.app.FragmentContainerView
Caused by: android.view.InflateException: Binary XML file line #21: Error inflating class androidx.fragment.app.FragmentContainerView
Caused by: java.lang.IllegalStateException: FragmentManager is already executing transactions

FragmentManagerのトランザクション実行中(FragmentManagerを操作している状態)に他のトランザクションを実行しようとして例外が発生しています。
つまり、ダイアログを表示する操作でFragmentManagerがトランザクション実行中となり、そのトランザクションが終了しないうちに、さらにFragment追加要求をしたことが原因のようでした。

トランザクション実行中にFragment追加要求


管理するFragmentManagerを見直す

例外が発生しないように、DialogFragmentのFragmentManagerでNavHostFragmentを管理することで解決しました。特に指定せずにDialogFragmentでFragmentContainerViewを使用すると、ActivityのFragmentManagerでFragmentが管理されるようです。
スコープの観点からも、ダイアログを閉じた時にNavHostFragmentを削除したいため、DialogFragmentのFragmentManagerで管理することが正しいFragmentの持ち方だと思います。

DialogFragmentのFragmentManagerで管理

なお、xmlのFragmentContainerViewからFragmentManagerを指定してFragmentを追加することはできなさそうでした。そのため、静的に宣言することを諦めて、ダイアログ作成時に動的にFragmentContainerViewにFragmentを設定することで解決しました。

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

   ...
    <!--空のContainerを配置して、ダイアログ作成時にFragmentを設定-->
    <androidx.fragment.app.FragmentContainerView />
    ...

</layout>
class PassingDataDialogFragment : DialogFragment() {
    ...

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        binding = DataBindingUtil.inflate(LayoutInflater.from(requireActivity()), R.layout.dialog_data_passing, null, false)

        if (savedInstanceState == null) {

            // DialogFragmentのFragmentManagerでNavHostFragmentを管理し、ContainerにFragmentを設定
            childFragmentManager.commitNow {
                setReorderingAllowed(true)
                val fragment = NavHostFragment.create(R.navigation.data_passing_navigation)
                replace(R.id.passing_song_navigation, fragment)
            }
        }
        
        ...
    }

}


Fragmentの管理状態を確認

DialogFragmentはActivityのFragmentManagerで管理され、NavHostFragmentはDialogFragmentのFragmentManagerで管理する構造になりました。
さらに、画面推移に必要なFragmentはNavHostFragmentのFragmentManagerで管理されているため、適切な親子関係となりました。

FragmentManagerの親子関係と管理Fragment

これで、ダイアログを閉じるときはDialogFragmentのFragmentManagerで管理されているNavHostFragmentやその下の管理のFragmentを削除するようにできました。
FragmentManagerの階層を適切な親子関係に構築しておかないと、ダイアログを再度開いた時に画面推移の状態が維持されていたり、予期せぬ動作となるため注意が必要です。

解決できなかったボツ実装を表示

Fragmentを挟む

ダイアログの表示とNavHostFragmentの追加が同じFragmentManagerで衝突することが例外の原因になるため、ActivityとDialogFragmentの間にFragmentを挟んでFragmentManagerを分けてみました。
FragmentからchildFragmentManagerにより、FragmentのFragmentManagerでDialogFragmentを表示・管理します。これにより、ダイアログの表示とNavHostFragmentの追加を要求するFragmentManagerを別々にして解決しました。

別のFragmentManagerに追加要求

この方法でも、ダイアログ作成時の例外を解決できましたが、FragmentManagerの階層が不適切な親子関係なため、特定の操作で問題が出ました。


再度ダイアログを開くときに状態が初期化されない

ダイアログを閉じて、再度開くときにNavigationの状態が初期化されずに、前回の状態を維持していました。NavHostFragmentはActivityのFragmentManagerで管理しているため、ダイアログを破棄してもNavHostFragmentは残り続けます。

ダイアログを閉じた時、NavHostFragmentが残る

これは、ダイアログが閉じるときに、NavHostFragmentからparentFragmentManagerを辿り、手動でFragmentを削除することやNavigationの状態を戻すことで解決できました。

手動でフラグメントを削除

    override fun onDestroy() {
        super.onDestroy()
        // NavHostFragmentを手動で削除
        val fragmentManager = binding.dataPassingNavigation.getFragment<NavHostFragment>().parentFragmentManager
        fragmentManager.findFragmentById(R.id.data_passing_navigation)?.let {
            fragmentManager.beginTransaction().remove(it).commit()
        }
    }

この問題は解決できました。
しかし、この方法では次の問題が解決できませんでした。


画面を回転した時にダイアログの中身が空になる

ダイアログを開いている時に、画面を回転させると一旦すべての画面を閉じて再作成されますが、その時にNavHostFragmentで管理しているFragmentの画面が空になり、真っ白に表示されました。

画面回転で中身が空に

そもそも例外が発生してました。
onDestoryでトランザクション実行することはできないようです。
この例外はonPauseで回転時のみ(isChangingConfigurations)実行する等の無理やり回避することはできましたが、中身が空になる不具合は結局解決できず。。

java.lang.RuntimeException: Unable to destroy activity : java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState

問題の解決方法を見直してFragmentManagerの階層を適切な親子関係で構築する方向に切り替えました。


おわりに

DialogFragmentにNavigationを使用して画面推移を実装する話でした。
通常は全ての画面推移をNavigationで管理すると思うので、ダイアログからNavigation管理は特殊なパターンだと思います。
今回はNavigationでハマったというよりは、FragmentContainerViewとFragmentManagerをよく理解しないまま使っていたことによるものでした。FragmentContainerViewの挙動とFragmentManagerの知識がちゃんとあれば、そこまで苦戦することではなかったかもしれません。

でも同じようなパターンで悩んでる人がいればご参考に。


参考

ナンバーピッカーで数字入力を良い感じに

数字入力をダイヤル式のナンバーピッカーに変えて使いやすくした話。

ナンバーピッカー


例えば

musicLineでは、拍子の指定にテキスト入力を使っていました。

テキスト入力

でも、テキスト入力だと

  • 範囲がわからない
  • キーボードが押しづらい
  • 今の値を削除する必要がある

と色々不満があります。
このテキスト入力をナンバーピッカーに変えることで不満を解消しました。

ナンバーピッカー

テキスト入力は自由度が高いことが利点ですが、入力できる範囲を明示的に示したい場合などはナンバーピッカーを使用することで使いやすくなります。


ナンバーピッカーの欠点

ナンバーピッカーは範囲がある時は有効だと書きましたが、範囲が広すぎる場合はスワイプする回数が増えるため使いづらくなります。
その場合はテキスト入力やスライダーを使用した方がいいでしょう。

musicLineでは曲のテンポを指定する方法を、スライダーとテキスト入力で併用しています。
スライダーで直感的に操作できますが、直接指定したい時はテキスト入力で行います。また、テンポは一般的には300までの範囲ですが、それ以上を超えたい特殊の場合はテキスト入力で指定できるようにしています。

スライダーとテキスト入力併用


実装

Android標準のNumberPickerをポップアップでダイアログ表示できるようにラッパークラスを作成しました。

developer.android.com

全コードを表示

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data/>

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center">

        <NumberPicker
            android:id="@+id/number_picker"
            android:layout_width="80dp"
            android:layout_height="150dp"
            android:layout_gravity="center"
            android:layout_margin="30dp"
            android:gravity="center" />
    </FrameLayout>
</layout>
class NumberPickerDialogFragment : BaseDialogFragment() {

    companion object {
        private const val BUNDLE_KEY_VALUE = "value"
        private const val BUNDLE_KEY_MIN = "min"
        private const val BUNDLE_KEY_MAX = "max"

        // 引数:初期値、最小値、最大値
        fun createInstance(value: Int, min: Int, max: Int)
        = NumberPickerDialogFragment().also { fragment ->
            fragment.arguments = Bundle().apply {
                putInt(BUNDLE_KEY_VALUE, value)
                putInt(BUNDLE_KEY_MIN, min)
                putInt(BUNDLE_KEY_MAX, max)
            }
        }
    }

    var onResult: (Int) -> Unit = {}
    private lateinit var binding: DialogNumberPickerBinding

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        binding = DataBindingUtil.inflate(
                             LayoutInflater.from(requireActivity()), 
                             R.layout.dialog_number_picker, null, false)

        binding.numberPicker.run {
            maxValue = requireArguments().getInt(BUNDLE_KEY_MAX)
            minValue = requireArguments().getInt(BUNDLE_KEY_MIN)
            value = requireArguments().getInt(BUNDLE_KEY_VALUE)

            wrapSelectorWheel = false // ループをOFFにする
        }

        return AlertDialog.Builder(requireActivity(), R.style.CustomSlimAlertDialog)
            .setView(binding.root).create()
    }

    override fun onPause() {
        super.onPause()

        // 実際、コールバックはsetResultListenerを使うべき
        onResult(binding.numberPicker.value)
    }
}
<resources>
    <style name="CustomSlimAlertDialog" parent="Theme.AppCompat.Light.Dialog.Alert">
        <item name="android:windowMinWidthMajor">0%</item>
        <item name="android:windowMinWidthMinor">0%</item>
        <item name="dialogCornerRadius">8dp</item>
    </style>
</resources>
    val numberPickerDialogFragment = NumberPickerDialogFragment
    .createInstance(4, 2, 8).apply {
        onResult = { number ->
            // ピックした数字を使う
        }
    }
    numberPickerDialogFragment.show(parentFragmentManager, "beat_picker")

ポイント

  • NumberPickerをレイアウトに貼り付け
<NumberPicker
    android:id="@+id/number_picker"
    android:layout_width="80dp"
    android:layout_height="150dp"
    ...
  • NumberPickerに範囲の最大・最小と初期値を設定
binding.numberPicker.run {
    maxValue = requireArguments().getInt(BUNDLE_KEY_MAX)
    minValue = requireArguments().getInt(BUNDLE_KEY_MIN)
    value = requireArguments().getInt(BUNDLE_KEY_VALUE)
    ...
  • ダイアログ閉じるときに数字を取得
onResult = { number ->
    // ピックした数字を使う
}


ちなみに

NumberPickerのdisplayedValuesを設定すると、選択肢に数字以外を含めることもできます。

表示を数字以外にする

ViewModelのスコープを意識する

ViewModelは画面の状態を保持する役割があります。
今回は画面の状態をいつまで保持するかについての話。

ViewModelの概要

ViewModelは紐づけたFragmentが破棄されるまで生き続けます。
この生存期間のことをスコープといいます。
ViewModelを生成する時は、適切なスコープに設定しないと意図しない動作となりハマります。


例えば

musicLineでは自分の曲をアルバムにまとめる機能があります。
アルバムを作成する際、アルバムに収録する曲を複数曲選択します。この時にコミュニティに投稿した自分の曲が一覧で表示されます。スコープを間違えると曲一覧表示する場面でよくない挙動になります。

アルバム作成画面で曲を選択する
スコープが適切な場合

想定する挙動

アルバム作成画面で「+マイソングから曲を追加」ボタンを押したときに、自分の曲リストをサーバーに問い合わせて曲一覧画面に表示します。サーバーで問い合わせた曲リストは曲一覧画面のViewModelにキャッシュし、再度開いた時にキャッシュした曲リストを表示します。

アルバム作成画面
曲一覧画面

おかしい挙動

ViewModelで曲リストをキャッシュしているため、曲一覧画面を最初に開くときのみサーバーに問い合わせれば、その後問い合わせる必要はないはずですが。。
ViewModelのスコープを間違えると、曲一覧画面を開くたびにサーバーに問い合わせることになります。これは、曲一覧画面のFragmentが破棄された時にViewModelも一緒に破棄されるからです。
この状態だとアルバム曲を10曲選択するとしたら、10回サーバーに曲リストを問い合わせるのでとても無駄です。また、Viewの状態を保持できていないため、曲一覧画面を開くたびにスクロールが一番上に戻ってしまうので使い勝手も悪いです。

アルバム作成画面で曲を選択する
スコープが不適切な場合
画面を開くたびに曲を問い合わせる


スコープを設定する基準

現在のFragmentがスコープ

今回の場合は、曲一覧画面が破棄されても状態を保持したいので、ViewModelのスコープを親のアルバム作成画面に設定してスコープを広くすることで解決します。

親のFragmentがスコープ


なお、スコープが広ければ良いわけではなく、適切なスコープに設定しないと予期せぬ不具合等につながる可能性もあります。
たとえば、スコープを一番上のユーザーページに設定する場合を考えてみます。

一番上のActivityがスコープ

スコープが広がることで、スコープ内のFragmentからViewModelのインスタンスを共有できるという利点がありますが、ユーザーページが閉じられるまでキャッシュが残り続けるという欠点があります。
具体的には、ユーザーページでは自分の曲を削除することができますが、自分の曲を削除した時に不具合が起こります。曲を削除してもViewModelのキャッシュは残っているため、削除したはずの曲が曲一覧に表示されます。

スコープ設定の基準としては最初から広いスコープに設定するのではなく、

  • データをキャッシュしたい場合
  • 親や他のFragment間でデータを共有したい場合

にスコープを広げる検討をしよう。

なるべく小さいスコープを心がけて状況に応じてスコープを広げることが大事です。


ViewModelの生成方法

ViewModelのインスタンス生成方法を3種類紹介します。
詳細はドキュメントを参照。
developer.android.com

1.現在のFragmentのみのスコープ
val myViewModel1 by viewModels<MyViewModel>()

Fragmentが破棄される時にViewModelも破棄されます。

2.親のFragmentがスコープ
val myViewModel2 by viewModels<MyViewModel>({requireParentFragment()})

親のFragmentが破棄される時にViewModelも破棄されます。
また、既に親のFragmentや他のFragmentでViewModelが作成されている時は、そのインスタンスを使います。

3.Activityがスコープ
val myViewModel3 by activityViewModels<MyViewModel>()

Activityが破棄される時にViewModelも破棄されます。こちらも既にViewModelが作成されている時は、そのインスタンスを使います。


プロパティ監視のスコープ(おまけ)

ViewModelのスコープといえば、プロパティ監視する時のスコープも気を付けるべき。
observeの第一引数でスコープ設定できるが、FragmentではthisじゃなくてviewLifecycleOwnerを使うようにしよう。
そうじゃないと、重複してプロパティ監視が登録されることになります。