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

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

selectableItemBackgroundはBackgroundにセットしないと意図した挙動にならない話

タイトルが意味わからないことになっている😇

はじめに

AndroidAPI Level 21 (Android 5.0 / Lollipop) からRipple Effectがサポートされ、これによりより良いタッチフィードバックをユーザに提供できることができる。ボタンなど、「もともとタップが想定されているView」については、タップした際にデフォルトでRipple Effectが表示されている。ただのViewだったりTextViewなど「デフォルトでタップが想定されていないView」についてもシステムが用意したdrawable resourceを設定することで簡単に実現できる。

システムが用意したdrawable resourceは(自分の知る限り)2種類ある。

  • ?android:attr/selectableItemBackgroundBorderless
  • ?android:attr/selectableItemBackground

これをViewのbackground/foregroundにセットするかでまた挙動が変わるという面白い現象を見つけたので調べてみた。

組み合わせでどう変わる?

まずは2種類のresourceの説明をそれぞれしていこう。Ripple Effectのcolorやdurationは変わらないのだが、selectableItemBackgroundBorderlessの方はその名の通りBorderless、つまりViewのboundaryを超えてRipple Effectを表示することができる。幅・高さが小さいViewに対してタッチフィードバックをつけるのに大変便利だ。ただし、ViewのBackgroundにセットした場合のみで、Foregroundにセットした時にはselectableItemBackgroundと同じ挙動になる。

f:id:muumuumuumuu:20181014155223g:plain:w300

(上記のサンプルコードはリンクを参照)
さて、それぞれどうして差が出るのかコードを追っていこう。

それぞれ指定されたResourceは何をやっているのか?

Borderless

selectableItemBackgroundBorderlessを指定した場合、最終的にこのxmlが読み込まれる。

     17 <ripple xmlns:android="http://schemas.android.com/apk/res/android"
     18     android:color="?attr/colorControlHighlight" />

Borderlessじゃない方

selectableItemBackgroundを指定した場合、最終的にこのxmlが読み込まれる。

     17 <ripple xmlns:android="http://schemas.android.com/apk/res/android"
     18     android:color="?attr/colorControlHighlight">
     19     <item android:id="@id/mask">
     20         <color android:color="@color/white" />
     21     </item>
     22 </ripple>

これらのrippleタグは最終的にRippleDrawableに変換される。

BackgroundとForegroundで挙動が異なる

RippleDrawableクラスの公式ドキュメントを見ると下記のような記載がある。

If no child layers or mask is specified and the ripple is set as a View background, the ripple will be drawn atop the first available parent background within the View's hierarchy. In this case, the drawing region may extend outside of the Drawable bounds.

わざわざ "as a View background" と書いてあるように、backgroundに指定した場合のみViewのhierarchyを辿ってparentのbackground内まで描画することができるようだ。

せっかくなのでコードを読んでみよう

RippleDrawableはLayerDrawableをextendsしているのでlayerを重ねることができる。これでmaskをかけている。

    189     public RippleDrawable(@NonNull ColorStateList color, @Nullable Drawable content,
    190             @Nullable Drawable mask) {
    191         this(new RippleState(null, null, null), null);


    201         if (mask != null) {
    202             addLayer(mask, null, android.R.id.mask, 0, 0, 0, 0);
    203         }

このmastが影響してくるのはdraw()の時

    688     @Override
    689     public void draw(@NonNull Canvas canvas) {
    690         pruneRipples();
    691 
    692         // Clip to the dirty bounds, which will be the drawable bounds if we
    693         // have a mask or content and the ripple bounds if we're projecting.
    694         final Rect bounds = getDirtyBounds();
    695         final int saveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
    696         canvas.clipRect(bounds);
    697 
    698         drawContent(canvas);
    699         drawBackgroundAndRipples(canvas);
    700 
    701         canvas.restoreToCount(saveCount);
    702     }

getDirtyBounds() はmaskがある時とないときで挙動が変わる

    922     @Override
    923     public Rect getDirtyBounds() {
    924         if (!isBounded()) {
    925             final Rect drawingBounds = mDrawingBounds;
    926             final Rect dirtyBounds = mDirtyBounds;
    927             dirtyBounds.set(drawingBounds);
    928             drawingBounds.setEmpty();
    929 
    930             final int cX = (int) mHotspotBounds.exactCenterX();
    931             final int cY = (int) mHotspotBounds.exactCenterY();
    932             final Rect rippleBounds = mTempRect;
    933 
    934             final RippleForeground[] activeRipples = mExitingRipples;
    935             final int N = mExitingRipplesCount;
    936             for (int i = 0; i < N; i++) {
    937                 activeRipples[i].getBounds(rippleBounds);
    938                 rippleBounds.offset(cX, cY);
    939                 drawingBounds.union(rippleBounds);
    940             }
    941 
    942             final RippleBackground background = mBackground;
    943             if (background != null) {
    944                 background.getBounds(rippleBounds);
    945                 rippleBounds.offset(cX, cY);
    946                 drawingBounds.union(rippleBounds);
    947             }
    948 
    949             dirtyBounds.union(drawingBounds);
    950             dirtyBounds.union(super.getDirtyBounds());
    951             return dirtyBounds;
    952         } else {
    953             return getBounds();
    954         }
    955     }

さて、ここまででselectableItemBackgroundBorderlessselectableItemBackgroundの違いがわかった。 次はBackgroundとForegroundで挙動が変わるところをみてみよう。

RippleDrawable#isProjected()というメソッドの中で、タップされた場所から円を描いて自分のサイズに収まるかどうかをみている。maskされたlayerが存在する場合もfalseを返している。

  344     @Override
    345     public boolean isProjected() {
    346         // If the layer is bounded, then we don't need to project.
    347         if (isBounded()) {
    348             return false;
    349         }
    350 
    351         // Otherwise, if the maximum radius is contained entirely within the
    352         // bounds then we don't need to project. This is sort of a hack to
    353         // prevent check box ripples from being projected across the edges of
    354         // scroll views. It does not impact rendering performance, and it can
    355         // be removed once we have better handling of projection in scrollable
    356         // views.
    357         final int radius = mState.mMaxRadius;
    358         final Rect drawableBounds = getBounds();
    359         final Rect hotspotBounds = mHotspotBounds;
    360         if (radius != RADIUS_AUTO
    361                 && radius <= hotspotBounds.width() / 2
    362                 && radius <= hotspotBounds.height() / 2
    363                 && (drawableBounds.equals(hotspotBounds)
    364                         || drawableBounds.contains(hotspotBounds))) {
    365             return false;
    366         }
    367 
    368         return true;
    369     }

このメソッドが呼ばれるのはViewクラスのgetDrawableRenderNode().

   19433     private RenderNode getDrawableRenderNode(Drawable drawable, RenderNode renderNode) {
   19434         if (renderNode == null) {
   19435             renderNode = RenderNode.create(drawable.getClass().getName(), this);
   19436         }

   19457         renderNode.setProjectBackwards(drawable.isProjected());

ここでsetしたProjectBackwardsが呼ばれるのはこの辺?(Nativeコード詳しくないマンなので間違ってたらごめんなさい)

    101 void RenderNodeDrawable::forceDraw(SkCanvas* canvas) {


    120     //pass this outline to the children that may clip backward projected nodes
    121     displayList->mProjectedOutline = displayList->containsProjectionReceiver()
    122             ? &properties.getOutline() : nullptr;
    123     if (!properties.getProjectBackwards()) {
    124         drawContent(canvas);
    125         if (mProjectedDisplayList) {
    126             acr.restore(); //draw projected children using parent matrix
    127             LOG_ALWAYS_FATAL_IF(!mProjectedDisplayList->mProjectedOutline);
    128             const bool shouldClip = mProjectedDisplayList->mProjectedOutline->getPath();
    129             SkAutoCanvasRestore acr2(canvas, shouldClip);
    130             canvas->setMatrix(mProjectedDisplayList->mProjectedReceiverParentMatrix);
    131             if (shouldClip) {
    132                 clipOutline(*mProjectedDisplayList->mProjectedOutline, canvas, nullptr);
    133             }
    134             drawBackwardsProjectedNodes(canvas, *mProjectedDisplayList);
    135         }
    136     }
    137     displayList->mProjectedOutline = nullptr;
    138 }

確かにProjectedされたoutlineをclipしているように見える。

で、話を戻してViewクラスのgetDrawableRenderNode()がいつ呼ばれるかというと、View#drawBackground()からのみコールされている。

   19375     private void drawBackground(Canvas canvas) {
   19376         final Drawable background = mBackground;
   19377         if (background == null) {
   19378             return;
   19379         }
   19380 
   19381         setBackgroundBounds();
   19382 
   19383         // Attempt to use a display list if requested.
   19384         if (canvas.isHardwareAccelerated() && mAttachInfo != null
   19385                 && mAttachInfo.mThreadedRenderer != null) {
   19386             mBackgroundRenderNode = getDrawableRenderNode(background, mBackgroundRenderNode);
   19387 
   19388             final RenderNode renderNode = mBackgroundRenderNode;
   19389             if (renderNode != null && renderNode.isValid()) {
   19390                 setBackgroundRenderNodeProperties(renderNode);
   19391                 ((DisplayListCanvas) canvas).drawRenderNode(renderNode);
   19392                 return;
   19393             }
   19394         }

というわけでbackgroundとforegruondで挙動に差分が出るのはここでした。解決!

Shared element transitionでリストの要素を並び替える

やりたいこと

Fragmentを切り替えてReordering(要素を並び替え)する shared element transition の記事を見て楽しそうだったからやってみた。 medium.com

全く同じことをやっても楽しくないので、Fragment <-> Activityでやってみる。

できた

いきなりだけど成果物

f:id:muumuumuumuu:20180909122945g:plain

コードはこの辺

github.com

やったこと

Activityで shared element transition するには startActivity() するときに第2引数にtransition animationのbundleを渡してやる必要がある。 transition animationを作るのに必要なのは下記のPair.

  • 移動元になるview
  • 上記viewに対して一意になるID

今回はリストの並び替えなので上記のpairを並び替えたいViewの数だけ持つ必要がある。 RecyclerView でリストを表示している場合、Adapterの onBindViewHolder で各Viewにアクセスできるのでこれを保持しておく。

class ReorderingAdapter(private val activity: Activity)
    : RecyclerView.Adapter<ReorderingViewHolder>() {

    val items = mutableListOf<ImageView>() // adapterのpropertyとしてmutableListを持つ

    override fun onBindViewHolder(holder: ReorderingViewHolder, position: Int) {
        holder.bind(position)
        if (!items.contains(holder.image)) {
            items.add(holder.image) // 画面に表示されたタイミングでlistに追加
        }
    }

}

実際に並び替えを実行するタイミングで保持していたviewのリストからtransition animation用のbundleを生成する。

fab.setOnClickListener {
    context?.let {
        val itemList = mutableListOf<Pair<View, String>>()
        (recycler.adapter as? ReorderingAdapter)?.items?.forEachIndexed { index, view ->
            itemList.add(Pair(view, IMAGE_TRANSITION_NAME + index))
        }

        val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
            activity as Activity, *itemList.toTypedArray()
        ).toBundle()
        ReorderingActivity.start(it, options)
    }
}

並び替え後のviewの方でも同じIDを設定する。

itemView.setTag(R.id.position, position)
ViewCompat.setTransitionName(image, IMAGE_TRANSITION_NAME + position)

あとは普通の Shared element transition のように postponeEnterTransition() したり見た目の微調整して終わり。

注意点として、画面に表示されていないものはanimationの対象にならない。 そもそも onBindViewHolder() を通らないとitemsのlistにaddできないが、仮にlistに存在していたとしても画面外だったらanimationされない。RecyclerViewを使っていたのですでにrecycleされてしまっている場合はviewがなくなっちゃってるからanimationできないのかな。

おまけ

transition animation用のbundleを生成する時に ActivityOptionsCompat#makeSceneTransitionAnimation() を使うが、このメソッドの第2引数が可変長引数になっている。

    @NonNull
    @SuppressWarnings("unchecked")
    public static ActivityOptionsCompat makeSceneTransitionAnimation(@NonNull Activity activity,
            Pair<View, String>... sharedElements) {
        if (Build.VERSION.SDK_INT >= 21) {
            android.util.Pair<View, String>[] pairs = null;
            if (sharedElements != null) {
                pairs = new android.util.Pair[sharedElements.length];
                for (int i = 0; i < sharedElements.length; i++) {
                    pairs[i] = android.util.Pair.create(
                            sharedElements[i].first, sharedElements[i].second);
                }
            }
            return createImpl(ActivityOptions.makeSceneTransitionAnimation(activity, pairs));
        }
        return new ActivityOptionsCompat();
    }

可変長引数にListを渡すときは一度Arrayに型変換してからspread operator(*)を使う。

*itemList.toTypedArray()

2018年前半を振り返る

はじめに

2018年前半に自分が何をしていたか後から振り返られるようにメモを残しておく。2017年のまとめと同じく自分のtweetを振り返ってペタペタしていく。本当は年末にまとめて一年分やりたかったがtweet数が多すぎて一気にやると辛いという前回の反省を生かして2018年は半分に区切っていく。

前回のまとめはこちら

muumuutech.hatenablog.com

1月

この時点で割と仕事に余裕があるときは隙あらば有休消化に励んでいた気がする。

普段アニメをあまりみないけどずっと気になっていた廻るピングドラムを見始めた。最初はサイコホラーなのかサスペンスなのかシュールコメディなのかわからなかったけど後半一気に止まらない感じがすごかった。
社のslackに「生存戦略」のスタンプがあってなんだろうと思っていたけど元ネタがわかってスッキリ。

長らく続けていたAndroidもくもく会、自分の退職に伴い最終回を迎えた。運営している自分もとっても楽しかったし本当にありがとうございました!

今年は(まだ半分しか終わってないけど)これと同じ内容5回くらいつぶやいている気がする。今でも絶対にフォローしているつもりだけど実はフォローしていない人いっぱいいそうだ。

DroidKaigi appにコントリビュートする実績解除した。

DroidKaigi preludeでgfxさんと二人でセッション解説をやった。会場にいた誰よりも楽しんでいた自信があるw

最終出社日、最後のPRで自分で自分を消すやつ、一度やってみたかったので記念スクショ撮ってた。

1月は送別会とかでたくさん美味しいもの食べたり、寄せ書きが嬉しすぎて号泣したり忙しかった。幸せなことだな〜〜〜

2月

2月から丸一ヶ月有休消化で寒すぎて引きこもりまくってた。

DroidKaigi、今年はオーディエンス参加だった。濃い二日間でした!

今年は初めて確定申告した。一度フローを理解するとそんなでもないはずなんだけど、初回はなかなかつらみがあった。

2月にこんなことをつぶやいているけど、2018年後半始まっても未だに毎月スターエンジニアが誰かしら転職している気がする。

Paymoの残高まだまだ余ってます。Paymo支払いの飲み会とかやりたい。ただしPaymoで支払う側として。

有休消化でのんびり南の島にバカンスに行った。一人で。

主にビーチサイドで本を読んでいたんだけど、そのとき読んだ本の記録はこちら。 muumuutech.hatenablog.com

日本に戻って来たら花粉が飛び始めていた。

3月

新しい職場。外国人の同僚も多いのでここから英語も併記したりし始めている。

花粉がマジで辛い

新しい職場でももくもく会をやっていくぞ!

次回の開催はこちら

mercari-android-mokumoku.connpass.com

Flutter盛り上がって来た

shibuya.apkがライブ配信されるようになった。DeployGateさんのオフィスで配信見ながら女子エンジニア会。美味しいお料理とお酒で最高だった

友人の家でバーフバリ鑑賞会をやった。ボリウッド映画多分初めてだったけど楽しかった!

4月

Android Dagashiデビューしてテンションが上がっていた

IWDのトークセッションに参加した。 kinukoさんkeynoteが素晴らしすぎて、この資料は定期的に見返したい



Flutter勉強会やった

勉強会の前に自分でも触ってみるか〜と思って試したらこのザマ

築地市場が移転する前に早朝のツアーに行ってみた。

ツアーは基本的に英語だし参加者もほとんど外国人でここどこだっけ?ってなった

この日は築地だけじゃなく色々日本っぽいところを巡るデートをしていた

5月

出張で福岡に来た

移動中の飛行機のお供で読んだOKR本よかった!

OKR(オーケーアール) シリコンバレー式で大胆な目標を達成する方法

OKR(オーケーアール) シリコンバレー式で大胆な目標を達成する方法

Kotlin愛好会で登壇した

6月

出張でアメリカのオフィスに一週間ほど行った。

海外オフィスでの雑な感想が今までで一番伸びた

UberiPhone置き忘れて大変だった

muumuutech.hatenablog.com

久しぶりに新卒で入った会社の人たちと飲んでいた。普段エンジニアの中でもweb系の本当に限られた人としかあってなかったんだなーと実感した

おまけ

夫シリーズ

Android Frameworkのコードにbreakpointを止めるメモ

Android開発をしていると、Frameworkがどういう挙動をしているか調べたくなる時がある。そういう時はFrameworkのコードにbreakpointを置くんだけど、止まってくれたり止まらなかったりすることがあるので困っていた。

このTweetに対して神リプライがついたので流れないようにメモしておく。

実際試したところ、Compile SDK versionと合わせたEmulatorを作ってそこで動かすというのが良さそう。

StateListAnimatorを使ってXMLだけでAnimationをつける

こちらの記事を読んで「<selector> の中にobject animator埋め込めるの知らなかった!!すげー!!!!」となったので遊んでみたメモ。

android.jlelse.eu

StateListAnimator

AndroidにはStateListAnimatorというクラスがあって、Viewのdrawable stateによってAnimationを書き分けることができる。何が最高かってこのAnimationはXMLでお手軽にかけるってところだ。1

ドキュメントはこちら

遊んでみた

アイコンに触っている間だけ大きくなるアニメーションを書いてみた。 xmlはこんな感じ

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <set>
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleX"
                android:valueTo="1.5" />
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleY"
                android:valueTo="1.5" />
            <objectAnimator
                android:duration="200"
                android:propertyName="transitionZ"
                android:valueTo="10dp" />
        </set>
    </item>
    <item>
        <set>
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleX"
                android:valueTo="1" />
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleY"
                android:valueTo="1" />
            <objectAnimator
                android:duration="200"
                android:propertyName="transitionZ"
                android:valueTo="0dp" />
        </set>
    </item>
</selector>

android:state_pressed="true" の時とそうでない場合でObjectAnimatorの振る舞いを変えることができる。上記公式ドキュメントのリンク先を参照するとpress以外のstateもたくさんある。

xmlobjectAnimatorタグで使えるattribute一覧はこちら

Animation resources  |  Android Developers

    <objectAnimator
        android:propertyName="string"
        android:duration="int"
        android:valueFrom="float | int | color"
        android:valueTo="float | int | color"
        android:startOffset="int"
        android:repeatCount="int"
        android:repeatMode=["repeat" | "reverse"]
        android:valueType=["intType" | "floatType"]/>

このanimationとviewを紐づける時はこんな感じでandroid:stateListAnimatoを使う。

android:stateListAnimator="@animator/fav_animator"

こんな感じでハートがドキドキする。

f:id:muumuumuumuu:20180718234247g:plain

サンプルコードはこちら。

github.com


  1. 個人的には最高だと思うけど、Viewの振る舞いをXMLに書くことに対して反対派の人はいると思うので、そこはもうお好みで :pray:

KotlinのRegexとDestructuredで文字列からdata classに変換する

Androidでアプリを書いていて、正規表現を扱うときにjava.util.regex.Matcherを使うこと多いが、あれは個人的には好きではない。もっとスッキリかけるんじゃないかなぁといつも思ってしまう。

例えば、URLからprotocolとdomainを正規表現を使って取り出すコードを書こうと思うとこんな感じになるかと思う。

    companion object {
        private const val REGEX: String = "(.*)://(.*)"
    }

    fun hoge() {
        val urlString = "http://www.com"
        val url = generateUrlFromString(urlString)
    }

    private fun generateUrlFromString(url: String): Url? {
        val matcher = Pattern.compile(REGEX).matcher(url)
        return if (matcher.find()) {
            val protocol = matcher.group(1)
            val domain = matcher.group(2)
            return Url(protocol, domain)
        } else {
            null
        }
    }

data class Url(val protocol: String, val domain: String)

再帰の場合に注意が必要で、上記の場合だとmatcher.group()が0ではなく1と2だったり何かとやらかしてしまったりする。(matcher.group(0)にはhttp://www.comが入ってくる)

そんなときに、この記事見て最高では????と思ったので自分でも試してみることにした。一行でまとめると Destructuredクラスが最高では?という話です。やっぱりKotlinは可愛い。

medium.com

上記のgenerateUrlFromString()Java正規表現からKotlinの正規表現に書き換えたコードがこちら。

    private fun generateUrlFromString(url: String): Url? =
        REGEX.toRegex().matchEntire(url)
            ?.destructured
            ?.let { (protocol, domain) ->
                Url(protocol, domain)
            }

これだけで十分可愛さが伝わる気がするが、蛇足ながら可愛いポイントを書いていく。

  1. 文字列REGEXtoRegex()Regexクラスに変換
  2. matchEntire()で引数url stringを渡すことによりMatchResultを取得
  3. 2で正規表現にマッチしなかった場合はnullになるので、?.destructuredを取得。これは正規表現にマッチしたgroupをDestructuredクラスで返してくれる
  4. 3ですでにnullの可能性があるので引き続き?.でletを呼ぶ。ポイントはここでラベルをつけることができるという点。サンプルコードだとprotocoldomainをすぐに使っているが、let blockのなかで複雑なことをしている場合にわざわざ名前をつけるために変数に代入しなくてもよくて可読性がグッと上がる。

これらをワインラインでスッキリかけるところがまた可愛い!

当然ながらKotlinのRegexJavaのMatcherとかをwrapしているので、めんどくさいところは全てやってくれていて最高。

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

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 追記ここまで]