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

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

kotlin.concurrent.threadは新規threadが作成されるけどcurrent threadのgroupで動く

[2018.7.15 ご指摘いただき記事の最後に追記しました]

あらまし

先日おもしろいTweetを見つけたのでちょっと調べてみたメモ。

残念ながら手元で上記の現象は再現しなくて、MashmallowでもCrashが発生しなかったんだけど、

Kotlinのthreadってmain threadじゃない別threadで動くんじゃなかったっけ…?🤔

と思ったので調査。
ちなみにMashmallowの端末が手元になかったのでEmulator環境でやってます。

まずは調査用の簡単なコードを準備。Activityの onResume() でUI操作を行う。(textという名前のTextViewに"hogehoge"というtextをセットしています。)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_thread)
        Timber.plant(Timber.DebugTree())
        // main threadのログ出力結果を確認するためにログを入れる
        Timber.d("main thread: ${Looper.getMainLooper().thread}")
    }

    override fun onResume() {
        super.onResume()
        thread {
            Timber.d("current thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }
    }

ThreadのtoString()で表示しているのは下記の三つ。 thread nameprioritygroup name

    public String toString() {
        ThreadGroup group = getThreadGroup();
        if (group != null) {
            return "Thread[" + getName() + "," + getPriority() + "," +
                           group.getName() + "]";
        } else {
            return "Thread[" + getName() + "," + getPriority() + "," +
                            "" + "]";
        }
    }

ログとるときの操作は、単純に画面を表示させるだけ。結果はこんな感じ。

07-14 08:31:01.772 5497-5497/? D/ThreadActivity: main thread: Thread[main,5,main]
07-14 08:31:01.777 5497-5513/? D/ThreadActivity$onResume: current thread: Thread[Thread-266,5,main]

thread blockの中でログを出力した方は確かにthread nameを見ると別のthreadになっている。しかし3つ目のgroup nameはmainになっている。

kotlin.concurrent.threadを読んでみよう

Kotlinのthreadは気軽にサンプルコードのように書いている人が多いと思うが、じつはたくさんのdefault引数が用意されている

public fun thread(start: Boolean = true, isDaemon: Boolean = false, contextClassLoader: ClassLoader? = null, name: String? = null, priority: Int = -1, block: () -> Unit): Thread {
    val thread = object : Thread() {
        public override fun run() {
            block()
        }
    }
    if (isDaemon)
        thread.isDaemon = true
    if (priority > 0)
        thread.priority = priority
    if (name != null)
        thread.name = name
    if (contextClassLoader != null)
        thread.contextClassLoader = contextClassLoader
    if (start)
        thread.start()
    return thread
}

で、そんなdefault引数を置いておいて、メソッドの頭でJavajava.lang.Threadをnewしているんだけど、このときcurrent threadをparentにとるようになっているので、main threadからthread()を呼び出すとmain threadのgroupで新規threadが作られるのだ。

    private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
        Thread parent = currentThread();
        if (g == null) {
            g = parent.getThreadGroup();
        }

        g.addUnstarted();
        this.group = g;

UI操作できるのは一つのthreadからだけ

ちなみに上記のサンプルコードで、一度backgroundに行って再びonResume()に帰ってくるとアプリがcrashする。

    android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

backgroundから復帰した時にonResume()でもう一度threadから新規threadを作成しているので最初にさわったthreadではない別のthreadからUIを触ろうとしてcrashする。 ログを見てもthread nameが変わっていることがわかる

// Activity起動
07-14 08:31:01.772 5497-5497/? D/ThreadActivity: main thread: Thread[main,5,main]
07-14 08:31:01.777 5497-5513/? D/ThreadActivity$onResume: current thread: Thread[Thread-266,5,main]

// Backgroundから復帰(2回目のonResume)
07-14 08:31:06.983 5497-5518/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[Thread-269,5,main]

ここでthreadを新規作成するのではなくmain threadに固定することでこのcrashは避けられる。

    override fun onResume() {
        super.onResume()
// この部分を
//        thread {
//            Timber.d("current thread: ${Thread.currentThread()}")
//            text.text = "hogehoge"
//        }

// こう変える
        runOnUiThread {
            Timber.d("current thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }
    }

ログを見たら全てmain threadで固定されていることがわかる。

// Activity起動
07-14 08:07:18.328 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity: main thread: Thread[main,5,main]
07-14 08:07:18.329 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[main,5,main]

// Backgroundから復帰(2回目のonResume)
07-14 08:07:27.811 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[main,5,main]

ちなみにもう一つおもしろい挙動を見つけたのでメモ。 こんな感じでthreadとrunOnUiThread両方書いてやるとCrashは発生しない。

    override fun onResume() {
        super.onResume()
        thread {
            Timber.d("current thread from thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }

        runOnUiThread {
            Timber.d("current thread from ui thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }
    }

ログを見ると別threadよりも先に runOnUiThread の中が実行されているのがわかる。先にmain threadでさわっているので以降main threadのgroupで動いている別threadでさわってもcrashしないということなのかな。 // この部分間違ってそうなので追記で捕捉しました(2018.7.15)

// 1回目のonResume()
07-14 08:40:11.749 5693-5693/? D/ThreadActivity$onResume: current thread from ui thread: Thread[main,5,main]
07-14 08:40:11.749 5693-5709/? D/ThreadActivity$onResume: current threa from thread: Thread[Thread-278,5,main]

// 2回目のonResume()
07-14 08:40:49.488 5693-5693/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread from ui thread: Thread[main,5,main]
07-14 08:40:49.489 5693-5717/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current threa from thread: Thread[Thread-280,5,main]

[2018.7.15ここから追記]

ブログ公開ごにhydrakecatさんにご指摘いただいたのでさらに追加調査。

確かに thread { } の中で Thread.sleep(1000)を入れるとcrashするようになった。 (emulator環境Mashmallow / 実機環境Oreo共にcrashすることを確認 )

    override fun onResume() {
        super.onResume()
        thread {
            text.text = "hogehoge" // sleep以前にtextをさわってもcrashしないが、
            Thread.sleep(1000) // 1000msecの遅延後
            text.text = "hogehoge" // textをさわるとcrashする
        }

例外を投げているのはViewRootImpl.javaのこの部分。

Cross Reference: /frameworks/base/core/java/android/view/ViewRootImpl.java

   7311     void checkThread() {
   7312         if (mThread != Thread.currentThread()) {
   7313             throw new CalledFromWrongThreadException(
   7314                     "Only the original thread that created a view hierarchy can touch its views.");
   7315         }
   7316     }

mThreadに何が入るのかとういうと、constructorでThread.currentThread()を代入している。mThreadはfinalなのでsleep前後でも変わらない。

    235     final Thread mThread;

    478     public ViewRootImpl(Context context, Display display) {
    479         mContext = context;
    480         mWindowSession = WindowManagerGlobal.getWindowSession();
    481         mDisplay = display;
    482         mBasePackageName = context.getBasePackageName();
    483         mThread = Thread.currentThread();

mThreadThread.currentThread() もsleep前後で変化しないはずなのでおかしいなぁと思っていたら

というわけでcheckThread()が呼ばれない可能性を考える。

ViewRootImplmHandlingLayoutInLayoutRequestというフラグを持っていて、Layout実行時にこのフラグがtrueになる。

   1158     @Override
   1159     public void requestLayout() {
   1160         if (!mHandlingLayoutInLayoutRequest) {
   1161             checkThread();
   1162             mLayoutRequested = true;
   1163             scheduleTraversals();
   1164         }
   1165     }

で、requestLayout()にこのフラグが立っていたら checkThread() が実行されない(というか何もしない)ので、threadのcheckも行われず例外も投げられないということのようだ。sleep後はlayoutが完了してフラグがfalseになっているのでthreadのcheckが行われて例外が発生している。

runOnUiThread()thread 両方書いたケースでも、runOnUiThread()の方でlayout要求が出ているのでmHandlingLayoutInLayoutRequestがtrueになり、そのタイミングでthreadでUIをさわったのでcheckThread()が呼ばれず例外が発生しなかっただけだと思われる。

ViewRootImplAndroid FrameworkのクラスなのでDebuggerが効かず本当にそうか?というのが確認できないので歯がゆいが、多分あってるんじゃ無いかなぁ。多分。

[2018.7.15 追記ここまで]

RxTextView#textChanges()でdistinctUntilChangedする時にeventが流れない時がある

みんな大好きRxBinding1 ですが、ちょっとハマることがあったのでメモ。

RxTextView#textChanges()EditText の入力イベントを監視する。その時に重複した入力をdistinctUntilChanged()で削ろうとしたら以降何もeventが流れない現象に遭遇した。

RxTextView.textChanges(editText)
        .distinctUntilChanged() //  ここでevnetが止められるから
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe {
            Log.d("SampleApp", it.toString()) // ここは実行されない
        }

textChanges()は内部ではTextWatcherを使っていて、これがTextの変更を検出するとCharSequenceを返してくれるのだが、こいつはmutableで毎度同じinstanceを返すのだ。同じinstanceなのでchangeしたと見なされずいつまでたっても次のeventが流れてこない。

なので、CharSequenceからStringを取り出してやれば想定していた挙動になる。

RxTextView.textChanges(editText)
        .map { it.toString() } //  ここでStringに変換すると
        .distinctUntilChanged() 
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe {
            Log.d("SampleApp", it.toString()) // ここが実行される
        }

そもそもなんで RxTextView#textChanges() で入力の重複を防ぎたかったかというと、機種依存でSoftware Keyboardのsearch buttonをtapした時にtextChangesのeventがemitされちゃう場合があって、これを防ぎたかったから。これはRxTextViewというよりは、内部で使われているTextWatcherでeventが発火しているからなんだけど、keyboardの機種依存周りつらすぎてなんか仕様とか作って統一して欲しい…


  1. AndroidでReactive Programmingする時に、Viewのイベントをstreamで扱えるのが最大の利点だと思ってしまいそうになるくらいに大好き

@JvmOverloadsなfunctionをmockできないケースがある

@JvmOverloadsを付与したKotlinのfunctionを、Javaから呼び出すコードを書いた。ここのテストを書こうと思ってmockito-kotlinでmockしようとしてできなかったときのメモ。ちなみにmockしたいクラスはDagger2でDIしている前提。

やりたかったこと

例えばこんなKotlinで書かれたfunctionがあったとして、

// Hoge.kt
class Hoge {
    @JvmOverloads
    fun equal(a: String, b: String = "0"): Boolean = a == b
}

それをJavaから呼び出しをするコードに対して

// Fuga.java
public class Fuga {

    @Inject
    Hoge hoge = new Hoge();

    @VisibleForTesting
    public Fuga(Hoge hoge) {
        this.hoge = hoge;
    }

    boolean callHogeEquals() {
        return hoge.equal("1");
    }
}

mockするようなテストコードを書こうと思ったらfailする

class FugaTest {

    @Mock
    lateinit var hoge: Hoge

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun testCallHogeEquals() {
        val fuga = Fuga(hoge)
        whenever(hoge.equal("1")).thenReturn(true)

        val result = fuga.callHogeEquals()

        Assert.assertEquals(true, result)
    }
}
junit.framework.AssertionFailedError: 
Expected :true
Actual   :false

mockできずに本体コード通っちゃったのかな?と思ったが、こういうコードに対して同じようなテストを書いたところ

    @JvmOverloads
    fun returnConcatString(a: String, b: String = "0") = a + b
    @Test
    fun testCallHogeConcatString() {
        val fuga = Fuga(hoge)
        whenever(hoge.returnConcatString("1")).thenReturn("hoge")

        val result = fuga.callHogeCancatString()

        Assert.assertEquals("hoge", result)
    }
junit.framework.ComparisonFailure: 
Expected :hoge
Actual   :null

という結果になったので、どうやら本体コードを通っているのではなく、その型のdefault値が返って来ているように見える。

どうやって回避する?

1. 全てKotlinで書く

今回のようにTest対象のクラスだけがJavaで、mock対象とTestコードがKotlinで書かれた場合にこの問題が発生する。Test対象のクラスもKotlinで書いた場合には再現しないので、Test対象のクラスをKotlinで書き直すと回避できる。

2. 全てのParameterを指定する

別の方法として、Javaで書かれたTest対象のクラスからmock対象のコードを呼び出す時に、全てのParameterを指定するように変更するとこの問題は回避できる。

先ほどの例でいうと、Test対象のJavaをこんな感じに変更して、

    boolean callHogeEquals() {
        return hoge.equal("1", "0"); // default parameterと同じものを2nd parameterに指定
    }

Testコードも2nd paramterをつけてやると

    @Test
    fun testCallHogeEquals() {
        val fuga = Fuga(hoge)
        whenever(hoge.equal("1", "0")).thenReturn(true)

        val result = fuga.callHogeEquals()

        Assert.assertEquals(true, result)
    }

無事にtestがpassする。ちなみにmock対象のコードは@JvmOverloads をつけたままで問題ない。

なぜmockできないのか?

@JvmOverloads がついたfunctionをJavaのbyte codeで見てみると、それぞれのシグネチャのパターン分のメソッドだけでなく、ブリッジメソッドも作られていた。

  // access flags 0x1049
  public static synthetic bridge equal$default(Lcom/example/muumuu/playgroundapp/Hoge;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z
  @Lkotlin/jvm/JvmOverloads;() // invisible
    ILOAD 3
    ICONST_2
    IAND
    IFEQ L0
   L1
    LINENUMBER 8 L1
    LDC "0"
    ASTORE 2
   L0
    ALOAD 0
    ALOAD 1
    ALOAD 2
    INVOKEVIRTUAL com/example/muumuu/playgroundapp/Hoge.equal (Ljava/lang/String;Ljava/lang/String;)Z
    IRETURN
    MAXSTACK = 3
    MAXLOCALS = 5

で、mockする箇所で引数一つで呼び出すとそのブリッジメソッドをINVOKESTATICしている。

   L3
    LDC "1"
    ACONST_NULL
    ICONST_2
    ACONST_NULL
    INVOKESTATIC com/example/muumuu/playgroundapp/Hoge.equal$default (Lcom/example/muumuu/playgroundapp/Hoge;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKESTATIC com/nhaarman/mockito_kotlin/MockitoKt.whenever (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    ICONST_1
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKEINTERFACE org/mockito/stubbing/OngoingStubbing.thenReturn (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    POP

自分の理解ではmockitoは@Mockを付与したインスタンスはdummy objectを生成して、when等で定めた振る舞いをstubとして生成している。そのため、今回のようにブリッジメソッドをstubとして生成しても実際にJavaのコードからコールされるのはこのブリッジメソッドではないのでstubが存在しないため戻り値のクラスのデフォルト値が返ってしまったようだ。

それでは上記回避策をとった場合は、どのようなbyte codeが生成されていて、なぜ問題が起きないのか見ていこう。

まず、全てKotlinで書かれた場合、mockする時だけでなく本体側も@JvmOverloadsなfunctionを同じシグネチャでコールするとブリッジメソッドが呼ばれるので、stubする対象が一致するので正常にmockできる。

次にstub生成時に引数をフルで指定する場合は、ブリッジメソッドではなく引数がフルに揃ったシグネチャのメソッドのスタブが作成されるため正常にmockできる。

   L3
    LDC "1"
    LDC "1"
    INVOKEVIRTUAL com/example/muumuu/playgroundapp/Hoge.equal (Ljava/lang/String;Ljava/lang/String;)Z
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKESTATIC com/nhaarman/mockito_kotlin/MockitoKt.whenever (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    ICONST_1
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKEINTERFACE org/mockito/stubbing/OngoingStubbing.thenReturn (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    POP




はー、フルKotlinな世界はよ〜〜〜〜〜

アメリカでUberにiPhone置き忘れて無事に回収した。SIMは通話付きを買おうな!

[2018/06/13 追記しました]

事の顛末

一週間くらい出張でUS(CA, Palo Alto)に来ている。Palo AltoはSan Fransiscoと異なり移動は車が基本になる。なので出勤はUberを利用しているのだが、車内にiPhoneを置き忘れる事件が発生。先日のGoogle I/OiPhoneを置き忘れた話を見かけたがまさか自分が同じ事になるとは思わなかった 😇

azihsoyn.hatenablog.com

私がラッキーだったのは、メインで使っていた端末が無くしたiPhoneではなく、別のAndroid端末だった事。日本から持って来た端末は2台だが、現地でSIMを1枚だけ買ってAndroidの方に挿して使っていた。つまり、iPhoneを無くしても常にネットワークに接続できるし、帰りのUberも呼べるしで緊急で困ることはなかった。あとiPhoneはプライベートで使っていただけだったので会社の情報とかも入っていなかったので一安心。とはいえまたiPhone買い直して$800とか払うのもバカバカしいので取り戻す事にした。ちなみにiPhoneはSIMさしてないとオフラインなので「iPhoneを探す」が使えない😇😇

Uberアプリは無くしたものを取り戻すのも便利。ただし電話番号とSMSが必須。

ドライバーとなんとか連絡をとりたくてUberアプリを色々さわる。乗車情報より「持ち物を紛失した」のメニューを発見。ここまではいいんだけど、Uberはプライバシーとかの観点からかドライバーと乗客が直接やりとりするのを極力減らす設計になっており、Uberを経由した電話で連絡を取る事になる。

どういうことかと言うと、こちらが連絡してほしい番号をUberにアプリ経由で送信し、Uberがドライバーに連絡するのだが、ドライバーはUberから連絡された番号にかけても直接乗客の番号に繋がるのではなくUberの番号に繋がり、そこから自動で通話が転送される。通話終了後、どちらかが掛け直しても相手に繋がらないような仕組みになっていた。

私の場合は電話番号を送信したらすぐにかかって来た。1 "Are you an Uber driver?"って聞いたらそうだって言われたので、「自分が今朝の乗客であること 」と「iPhoneを無くしたらしいこと」を伝えると、「今日は何人か乗せたから誰かわかんないけど、とりあえずiPhoneには気がつかなかった。でも調べてまた折り返し連絡するね」って言ってくれて通話終了。この時点では気がついてなかったけど、向こうから折り返すことがUberの仕組み上できなかった。

ずっと待ってても連絡が来ないのでどうしたもんかなぁと次の日の朝周りに相談したら「アメリカはグイグイpushしないと無くしたものは戻って来ない」と言われたのでPushすることにする。

もう一度同じメニューから連絡をとってみたけど今度は繋がらず。別の問い合わせ先を探していたら "I couldn't reach my driver about a lost item" 2 があったのでこちらからも連絡してみる事にする。先ほどと同様電話番号を送信する仕組みになっていた。今度はサポートからメッセージが来て、「ドライバーと連絡がついてiPhone見つかったらしい。向こうから連絡くると思うからちょっと待っててくれ」3 と言われたので待つ。

しばらくするとSMSがドライバーより届いて、「どこに住んでるの?」と聞かれて住所を教えると「$20でそこまで持っていくか、San JoseのUberのオフィスに預けるかどっちがいいか選んで」と言われたので持って来てもらう事にした。そこから時間とかのやりとりを全てSMS上で行い、その日の晩4にドライバーが来てくれて無事iPhoneを受け取った。彼曰く、「本当はもっと早く(最初の電話で約束した通り)掛け直してあげたかったんだけど繋がらなかったんだよねー」とのこと。ちなみに受け取る時に$20をキャッシュで支払ったのだが、どうやらUberを通じて返すと$15がアカウントに課金されるので$5で私はSan Joseにいく手間と時間を買って、ドライバーは$20をポケットに入れることができたっぽい。5

[2018/06/13 追記 ここから]
後日Uberのサポートから「落し物見つかったって聞いたから$15がライド料金に含まれるように調整しておいたよ、$15は全額ドライバーに支払われるよ」という連絡が来ました。
[2018/06/13 追記 ここまで]

タイムライン

  • 6/6 9:00
    • 出勤途中のUberiPhoneを置き忘れる。多分上記のブログと同じくポケットから落ちたのに気がつかないまま降りてしまったと思われる。
    • 出勤してiPhoneがない事に気がつくも、部屋に置き忘れたのかも?と思って特に何もせず
  • 6/6 18:30
    • 帰宅。iPhoneがないか部屋中探すが見つからず。Paring済みのBluetooth earphoneで端末を探すの結構便利だった。
  • 6/6 19:00
    • AndroidUberのアプリを立ち上げヘルプメニューになんかないか探し始める。乗車情報より「持ち物を紛失した」のメニューを発見
    • ドライバーと電話で話す。車内をチェックして折り返し連絡をくれることに
  • 6/7 9:00
    • 一晩明けてもドライバーから連絡がないので再度連絡を試みるが繋がらない
    • Uberサポートに連絡
    • ドライバーからSMSが来て$20で持って来てもらうことを約束する
  • 6/7 20:00
    • ドライバーからiPhoneを受け取る

雑感

  • 今回の出張でたまたまSIMは通話付きのもの6を買っていたのでとてもスムーズにいった。通話なしのSIMを買っていたら相当面倒だったと思う。
    • 日本だとSMSはMFAくらいにしか使っていないけど意外と他の国では使われるのだろうか。
  • Uberのドライバーと乗客を直接繋がないプライバシーに配慮したサービス設計は参考になった。
  • 今回ドライバーに私た$20が出張中支払った唯一のキャッシュだった。念のため空港で換金しておいてよかった


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


  1. 相手もどういう感じでかけてるかわからないので、"Hello?“ "Hello?"ってお互い何度か言う羽目になってしまった。コントか。

  2. この辺なぜか英語で表示されている。ローカライズ頑張れ!とどうしてもアプリ開発者目線でみてしまう。

  3. 実際はおきまりの「Sorry to hear about your phone」で始まる丁寧でながーい文章が送られて来た。

  4. 晩といってもサマータイムで20:00でも明るくてびっくりした。

  5. Uberが受け取る$15のうち、いくらドライバーに渡るかは不明。

  6. San FransiscoのAT&Tの店舗で通話付きSIMを買った。店員のお姉さんがすごいネイルで器用にSIMを扱っていたのが印象的だった。

TransitionのShowcaseアプリを作って公開しました

はじめに

先日droid girls meetupに参加し、Animationのハンズオンを楽しんできた。 GoogleのオフィスでGooglerの方(Support libraryを作っている方)が講師というなんとも贅沢な回だった。

droidgirls.connpass.com

そういえば業務でがっつりtransitionをさわる機会がないなぁと思っていたので折角なので色々試してみた。 ついでに、

そういえばこんなことをつぶやいていたのでShowcase的なアプリにして公開することにした。

Source code

コードはこちら。

github.com

サンプルの中にソースコードgithubページに飛べるボタンを置いておいたので、ここどうやって実装してるのかな?って思ったらすぐコードを確認できるようにしておいたのでよければみて見て下さい。

とはいうもののあまりまだサンプルを作れていなくて、作ったのは下記の3つ。

  • ObjectAnimator(Scale)
  • Arc Motion Transition
  • Shared element Activity Transition

それぞれについて気になったポイントとかハマったポイントを書き残しておく。

ObjectAnimator(Scale)

連続するanimationを記述するときに ktxのAnimatorクラスの拡張関数が便利だった。ただしsupport libraryのtransitionとかに対する拡張関数は生えてないようなので実はそんなに使い所は多くないかもしれない。

Arc Motion Transition

最初 ArcMotion() を一次元上での移動に対して設定しており、カーブを描くanimationにならずにハマった。ArcMotionのコードを読んでいるとどうやらベジエ曲線を軌跡とするため、一次元だと曲線にならないっぽい。(一次ベジエ曲線は単なる線分なので。)
余談だけどこれのおかげで TransitionManager 周りのコードを読んで`TransitionManager#beginDelayedTransition()`が何をやっているか理解できた。sceneChangeRunTransition() のなかで対象のViewGroupのViewTreeObserverを登録しておき、view parameterが変わったりしてそのViewGroupにlayoutが走った時とかに onPreDraw() 等を検知して登録したtransitionに対して playTransition() をコールする仕組みだった。へー。

Shared element Activity Transition

Shared element Activity Transition自体は思ったよりかなり簡単にできたが、全く関係のない別の箇所でハマった。 CoordinatorLayoutの中に入れるScrollできるViewはRecyclerViewNestedScrollVIewしかダメっぽい。普通のScrollViewを入れてもAppBarLayoutはcollapsedされないし、AppBarLayoutScrollViewが重なってしまう。これ毎回忘れて毎回ハマる気がする…




もっといろんなanimation作ってどんどん足して行きたいなー

Toolbarにセットしたheightが無視されるケース

まずはこちらのコード、どのような表示になるか考えながら読んでみてください。
(タイトルでネタバレしている気がするけど気にしないで!)

<android.support.v4.widget.DrawerLayout 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"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:openDrawer="start">

    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?android:actionBarSize"
        android:background="?android:colorPrimary" />

    <!-- 省略 -->

</android.support.v4.widget.DrawerLayout>

この場合、以下のような表示になります。
(確認環境:Pixel 2 API 27 Emulator)

f:id:muumuumuumuu:20180424220309p:plain:w200

Toolbarのheightを指定しているにも関わらず親のheightいっぱいに表示されてしまいます。 ちなみこんな感じで、

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="?android:actionBarSize">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?android:actionBarSize"
            android:background="?android:colorPrimary" />
    </LinearLayout>

高さを android:actionBarSize に指定したViewGroupで囲ってやると期待通りの表示になります。

f:id:muumuumuumuu:20180424221150p:plain:w200

これはなかなか興味深い挙動になっています。それではToolbarのコードを読んでどうしてこうなったのか見ていきましょう。

Toolbarのソースコードを読む

Support LibraryのToolbarのコードで、自分の高さを計算しているのはこの辺りです。

Cross Reference: /frameworks/support/v7/appcompat/src/android/support/v7/widget/Toolbar.java

読んでいくと、「NavigationViewは出すのか」「Menuは表示するのか」「ロゴは表示するのか」などで細かい計算ロジックが入っていて面白いです。
最後の最後にView.resolveSizeAndState() が呼ばれます。ここに来る時点でheightgetSuggestedMinimumHeight()も147なのですが、View.resolveSizeAndState()の結果measuredHeightは一気に1731になっていました。

    140 public class Toolbar extends ViewGroup {

   1567     @Override
   1568     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

// 省略

   1694         final int measuredHeight = View.resolveSizeAndState(
   1695                 Math.max(height, getSuggestedMinimumHeight()),
   1696                 heightMeasureSpec, childState << View.MEASURED_HEIGHT_STATE_SHIFT);
   1697 
   1698         setMeasuredDimension(measuredWidth, shouldCollapse() ? 0 : measuredHeight);
   1699     }

ここで渡っているheightMesureSpecですが、modeは EXACTLY, sizeは1731になっています。ViewGroupで囲んだときは、modeは 同様にEXACTLYですが、sizeは親のViewGroupの高さ( android:actionBarSizeを指定しているので147)になっていました。ViewGroupで囲まない場合はなぜこんなに大きなsizeになっているのか?

MesureSpecのsizeの計算ロジックはこちら。

   24587     public static class MeasureSpec {
   24588         private static final int MODE_SHIFT = 30;
   24589         private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

   24678         public static int getSize(int measureSpec) {
   24679             return (measureSpec & ~MODE_MASK);
   24680         }

びっくりするくらい特別なことはしていません。measureSpecからMODEを表現する部分をbit演算で削っているだけ。

肝心のresolveSizeAndState()のコードはこちら。

   22230     public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
   22231         final int specMode = MeasureSpec.getMode(measureSpec);
   22232         final int specSize = MeasureSpec.getSize(measureSpec);
   22233         final int result;
   22234         switch (specMode) {
   22235             case MeasureSpec.AT_MOST:
   22236                 if (specSize < size) {
   22237                     result = specSize | MEASURED_STATE_TOO_SMALL;
   22238                 } else {
   22239                     result = size;
   22240                 }
   22241                 break;
   22242             case MeasureSpec.EXACTLY:
   22243                 result = specSize;
   22244                 break;
   22245             case MeasureSpec.UNSPECIFIED:
   22246             default:
   22247                 result = size;
   22248         }
   22249         return result | (childMeasuredState & MEASURED_STATE_MASK);
   22250     }
   22251 

このように、modeがEXACTLYの場合はspecSizeが採用されます。mode(というかmeasureSpec)はparent viewから渡って来るものなので、Toolbarとしては特定のparentの中に入れられた場合に自分のheightの指定を無視してmeasureするというコードになっています。
したがって高さを指定したViewGroupで囲ってやるとその高さで表示されていたわけですね。おもしろい!

余談ですが今回親となったSupport LibraryのDrawerLayoutがchildに対してmeasureの要求を出すコードはこんな感じです。
sizeは自身の高さからmarginを引いたもの、modeはEXACTLYを指定したMeasureSpecをchildに渡してmeasure要求を出しています。

    968     @Override
    969     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

// 省略

   1054             if (isContentView(child)) {
   1055                 // Content views get measured at exactly the layout's size.
   1056                 final int contentWidthSpec = MeasureSpec.makeMeasureSpec(
   1057                         widthSize - lp.leftMargin - lp.rightMargin, MeasureSpec.EXACTLY);
   1058                 final int contentHeightSpec = MeasureSpec.makeMeasureSpec(
   1059                         heightSize - lp.topMargin - lp.bottomMargin, MeasureSpec.EXACTLY);
   1060                 child.measure(contentWidthSpec, contentHeightSpec);


自前でToolbarをセットしたくなるケースは少なくなってきていますが、まだまだToolbarを使わないとできないこともあるのでご注意ください。
そういえばDrawerLayoutでActionbarにハンバーガーアイコン置きたい場合もToolbarじゃないとダメだった気がする。

NestedScrollViewの中のRecyclerViewを配置すると要素全てメモリ上にallocateされて困った話

長いタイトルが全てを表していて、NestedScrollViewの中にRecyclerViewを配置した場合、要素全てメモリ上にallocateされて困った話。そのまんま。

こんな感じでNestedScrollViewの中にRecyclerViewを置いた場合

<NestedScrollView>
    <RelativeLayout>
        ....
        <RecyclerView />
    </RelativeLayout>
</NestedScrollView>

例えば30個RecyclerViewが表示すべきItemがあるとする。そのうち画面に表示されるのは6個だったとして、メモリ上に展開されるItemの個数は当然6個を期待するところだが、実際Android Profilerで見てみると30個allocateされる。30個だったらまだいいのだが、「スクロールしてbottomまで表示するとサーバに次の要素を問い合わせて永遠に表示していく」みたいなことをやりたいと困る。

ちなみにこんな感じで、NestedScrollViewの代わりにScrollViewにした場合だと期待通り6個分allocateされる。

<ScrollView>
    <RelativeLayout>
        ....
        <RecyclerView />
    </RelativeLayout>
</ScrollView>

じゃあどうする?ってなると思うけど、NestedScrollViewをやめる以外のいい解決策が今の所思いつかない。おそらくこういうデザインをしている場合、RecyclerView以外にもスクロースする要素を入れたいというケースだと思うので、それらもRecyclerViewの要素として扱うしかないんじゃないかな。




なんでこんなことになるのか、せっかくなのでなのでNestedScrollViewRecyclerView周りのコードを読んでみた。

RecyclerViewがどうやって要素をlayoutしていくかはこちらの資料が詳しいので色々省略

www.slideshare.net

問題はRecyclerViewの中をどんどん埋めていくこちらのメソッド

Cross Reference: /frameworks/support/v7/recyclerview/src/android/support/v7/widget/LinearLayoutManager.java

/**
 * The magic functions :). Fills the given layout, defined by the layoutState. This is fairly
 * independent from the rest of the {@link android.support.v7.widget.LinearLayoutManager}
 * and with little change, can be made publicly available as a helper class.
 *
 * @param recycler        Current recycler that is attached to RecyclerView
 * @param layoutState     Configuration on how we should fill out the available space.
 * @param state           Context passed by the RecyclerView to control scroll steps.
 * @param stopOnFocusable If true, filling stops in the first focusable new child
 * @return Number of pixels that it added. Useful for scroll functions.
 */
 int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
     RecyclerView.State state, boolean stopOnFocusable) {
     // max offset we should set is mFastScroll + available
     final int start = layoutState.mAvailable;
     if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
         // TODO ugly bug fix. should not happen
         if (layoutState.mAvailable < 0) {
             layoutState.mScrollingOffset += layoutState.mAvailable;
         }
         recycleByLayoutState(recycler, layoutState);
     }
     int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
     LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
     while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
         layoutChunkResult.resetInternal();
         if (VERBOSE_TRACING) {
             TraceCompat.beginSection("LLM LayoutChunk");
         }
         layoutChunk(recycler, state, layoutState, layoutChunkResult);
// 以降も続くが省略

The magic functions :)ってコメント可愛い
ここのwhile文でlayoutState.mInfiniteがtrueになっている。layoutState.hasMore(state)はまだ描画すべきItemが残っているかどうかのフラグなので、最後のlayoutChunkが走ってしまっている様子。 ではlayoutState.mInfiniteはどこから来るのかというと、同じくLinearLayoutManagerのここ。

    boolean resolveIsInfinite() {
        return mOrientationHelper.getMode() == View.MeasureSpec.UNSPECIFIED
                && mOrientationHelper.getEnd() == 0;
    }

うーん、MeasureSpec.UNSPECIFIEDになっている。MeasureSpecについてはこちらが詳しいです。

seto-hi.hatenablog.com

親がmeasureを呼ぶ時に適切なMeasureSpecを引数として渡していたらUNSPECIFIEDにならないのでは?と思いNestedScrollViewのコードをみにいく。

Cross Reference: /frameworks/support/core-ui/java/android/support/v4/widget/NestedScrollView.java

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        if (!mFillViewport) {
            return;
        }

        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (heightMode == MeasureSpec.UNSPECIFIED) {
            return;
        }

        if (getChildCount() > 0) {
            final View child = getChildAt(0);
            int height = getMeasuredHeight();
            if (child.getMeasuredHeight() < height) {
                final FrameLayout.LayoutParams lp = (LayoutParams) child.getLayoutParams();

                int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
                        getPaddingLeft() + getPaddingRight(), lp.width);
                height -= getPaddingTop();
                height -= getPaddingBottom();
                int childHeightMeasureSpec =
                        MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY);

                child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }
    }

ここでheightModeMeasureSpec.EXACTLYになっているのだが、

if (child.getMeasuredHeight() < height) {

ここの条件式でchildのheightの方が大きくなってしまっているため、if文の中のchildのmeasureが呼ばれていなかった。

じゃあどこでchildのmeasureを呼んでるのかな〜〜と思ってNestedScrollView#measureChildWithMargins()を見に行ったら高さはMeasureSpec.UNSPECIFIEDを指定していた。

Cross Reference: /frameworks/support/core-ui/java/android/support/v4/widget/NestedScrollView.java

    @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

ここでUNSPECIFIEDじゃないものを渡すとどうなるのかな?と思って実験。NestedScrollViewを拡張した独自クラスを作って、このメソッドをoverrideしてみる。 UNSPECIFIEDEXACTLYに変えて見たらallocateされる個数が30個から14個まで減った。どういうロジックで14個になるかまではわからず…

というわけでまとめると、

  1. NestedScrollView#onMeasure()がコールされる
  2. NestedScrollView#measureChildWithMargins() がコールされるが、ここでchildHeightMeasureSpecが0 (UNSPECIFIED)としてchild.measure()がコールされる
  3. childであるRecyclerViewもHeightのMeasureSpecがUNSPECIFIEDになるので、layoutState.mInfiniteフラグがtrueになり要素分全てがlayoutされる

という挙動でした。