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

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

Kotlin Bytecodeを読みたいけど、何から勉強したらいいかわからないあなたへ。ASMのドキュメントを読みましょう。

はじめに

このblog postは半年以上下書き放置していて自分の中では旬がすぎた話題ではありますが、最近Android系日本語技術podcastであるdex.fmのep.66でbytecodeが話題になったので、勿体無い精神で公開します :pray: それではどうぞ。

Kotlin書いているとたまに「これ、JavaのBytecodeだとどう変換されて動いているんだろう?」と思う時がしばしばある。

例えばこれとか

muumuutech.hatenablog.com

で、Android Studioの "Show Kotlin Bytecode" 機能を使ってbytecodeをのぞいてみるんだけど、完全に雰囲気で読んでいたので、わかるようなわからないようなゆるふわな感じだった😇これの意味は?とか聞かれても説明するなんて無理〜〜と言う程度の理解レベル。

何がつらいかっていうと、ググってもいい感じのリファレンスがないというか、まず何を読むべきかが見つけられないという状況😩😩😩 しかし軽い気持ちで呟いた結果、神々が降臨した。圧倒的感謝 :pray: 1

というわけで、Bytecodeに対してゆるふわな理解しかない人が勉強して行った記録を残していく。間違ったこと書いてたら教えてくださいmm

手取り早くbytecodeを理解した人へ

ASM User GuideのAppendixにBytecode instructionsがまとまっているので、ここを読みましょう。

ASMとは?

まずは公式の定義から。

ASM

ASM is an all purpose Java bytecode manipulation and analysis framework. It can be used to modify existing classes or to dynamically generate classes, directly in binary form. ASM provides some common bytecode transformations and analysis algorithms from which custom complex transformations and code analysis tools can be built. ASM offers similar functionality as other Java bytecode frameworks, but is focused on performance. Because it was designed and implemented to be as small and as fast as possible, it is well suited for use in dynamic systems (but can of course be used in a static way too, e.g. in compilers).

KotlinのコンバイラはASMライブラリを使ってbytecodeを生成している。なのでKotlinで生成されたBytecodeを読みたいだけだったら、ASMが何をしてどういう生成物を吐き出すか知っていれば良いっぽい。

余談だけどASMはOSSで、しかもGithubではなくGitlabでhostingされている。

gitlab.ow2.org

User Guide

まずは使う側としてUser Guide読むことにする。めちゃめちゃ長いPDFになっている。

https://asm.ow2.io/asm4-guide.pdf

読んでて面白かった部分を書いていく。なんかたまにルー大柴っぽくなった気がする。気になる人は原文読んでください…

1. Introduction

  • ASMはできるだけ早くて小さくて堅牢というのを目指している
  • ASMライブラリの目的はbyte arrayで表現されるコンパイルずみのJava classesを生成、変換、分析すること
    • class loading processは対象外
  • ASMという名前に意味はない。Cの__asm__ keyword からとっただけ
  • core APIはevent basedなクラス表現
  • tree APIはobject basedなクラス表現
    • この辺 見ると、Kotlin compilerはtreeの方使ってるのかな?という感じがする

Tree API

2. Classes 3. Methods 4. Metadata 5. Backward compatibility の章はCore APIについてだったのでスキップ)

6. Classes

  • tree APIを使ってclassを生成、変換する場合、core APIに比べて約3割多くの時間がかかり、メモリも多く使う
  • そのかわりどんな順序でもclass elementsが生成できるので便利

7. Method

  • methodの中身はInsnListでinstructionが記述されていく
    • こいつがgetOpCode()とかを持っている
    • getNext() があるからjumpが簡単にできる
  • Label, frame, line numberもinstructionではないけどAbstractInsnNodeのサブクラスとして表現される
    • これにより実instructionsの前とかにLabelとかをinsertすることができる

8. Method Analysis

  • ASMで使われるcode analysis techniqueにはdata flow analysiscontrol flow analysisがある
  • data flow analysis
    • methodのexecution frameのstateを計算する。
    • forward analysisとbackward analysisがある
    • stackから値をpopして計算して結果をstackにpush
    • interpreterJVMのように見えるが、違いは可能性がある全てのpath, argumentについてsimulateする
      • manipulated valueは可能性がある値の集合になるのでめっちゃ大きくなる。
      • 例)intergerをP="positive or null", N="negative or null, A="all integers" として表すとIADD instructionはoperandがどちらもPだったらPを返し、どちらもNだったらNを返し、そのほかのケースは全てAを返す
  • control flow analysis
    • methodのcontrol data flow graphを計算し、このgraphを解析する
    • graphはbasic blockにdecompileされる。それぞれのbasic blockは(最初のblockを除き)jumpの対象となりうる。
  • Interface and components
    • stackからのpopとかpushはframeworkで、valueをcombineしたり集合の計算とかはInterpreterとかValueと行ったuserが定義したsubclassで行われる
  • Basic Data Flow Analysis
definition means
UNINITIALIZED_VALUE all possible values
INT_VALUE all int, short, byte, boolean or char values
FLOAT_VALUE all float values
LONG_VALUE “all long values”
DOUBLE_VALUE “all double values”
REFERENCE_VALUE “all object and array values”
RETURNADDRESS_VALUE is used for subroutines (see Appendix A.2)
  • Appendix
    • 上記にも書いたが、ここにinstructionsとその操作例がまとまっているのでここを見ればだいたいbytecodeが読めるようになっている

Developer Guide読んでいく

こっちの方が短いけど難しい。3割くらいしか理解できていない気がするのでさらっとメモだけ。 ASM - Developer Guide

Main Algorithms

  • class loader
    • constant poolと(constructorの中の)bootstrap methodをparseする
    • classをparseする
    • class attributeをparseする
    • class attributeに対応したvisit methodをコールする
    • fieldについて上記と同じようなことをする
    • methodについて同上
      • ただしattributeはparseした後local variableにstore
      • labelを探してstore
      • instructionsをparse
  • 3.4.3 An example
    • 要約するのがむずいのでこの実例を見てくれたらなんとなくわかる





以上、わかるようなわからないような状態から一歩踏み出すためのリファレンス集でした。


  1. こう言う時本当に投げ銭で感謝のお気持ちを表明したいのでみんなKyashとかのリンクをTwitter bioとかに貼っておいてほしい…

TabItemをxml layout fileに記述してもview hierarchyからfindViewById()できない理由

Tweetにリンクを貼っている通り、公式ドキュメントによると

TabItem is a special 'view' which allows you to declare tab items for a TabLayout within a layout. This view is not actually added to TabLayout, it is just a dummy which allows setting of a tab items's text, icon and custom layout.

This view (Layout fileに定義されたTabItem)はただのダミーで、text, icon, それからcustom layoutの設定だけができるとある。

どうやって実装しているのか気になったので、内部実装を調べたのでメモ。

知りたいこと

こんな感じでTabItemをTabLayoutのchild viewとして記述しても、

<android.support.design.widget.TabLayout
    android:id="@+id/tab"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent">
    
    <android.support.design.widget.TabItem
        android:id="@+id/tabItem"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</android.support.design.widget.TabLayout>

コードからアクセスしようとしたら(ちなみにIDEはR.idを認識してくれる) f:id:muumuumuumuu:20190211155020p:plain

NPEが発生する。

java.lang.NullPointerException: Attempt to read from field 'android.graphics.drawable.Drawable android.support.design.widget.TabItem.icon' on a null object reference

ViewHierarchyに存在する(?)のにidからviewが引けない仕組みを調べる。

TabLayoutの実装

例によってAndroidXではなくsupport libraryのコードを読んだ結果ですごめんなさい 😇

まず前提としてTabLayoutHorizontalScrollViewをextendsしたviewである。でHorizontalScrollViewViewGroupなので、addView()メソッドをコールされることでchild viewを持つことができる。(LayoutInflatorがxmlを読み込んでview hierarchyを構成するときにparent viewに対してこのaddView()が呼ばれる。)

というわけでTabLayout#addView()を見て行く。

    public void addView(View child) {
        this.addViewInternal(child);
    }

    public void addView(View child, int index) {
        this.addViewInternal(child);
    }

    public void addView(View child, android.view.ViewGroup.LayoutParams params) {
        this.addViewInternal(child);
    }

    public void addView(View child, int index, android.view.ViewGroup.LayoutParams params) {
        this.addViewInternal(child);
    }

こんな感じでただaddViewInternal() をコールしているだけ。このaddViewInternal()は何をしているかというと、

    private void addViewInternal(View child) {
        if (child instanceof TabItem) {
            this.addTabFromItemView((TabItem)child);
        } else {
            throw new IllegalArgumentException("Only TabItem instances can be added to TabLayout");
        }
    }

childの型を調べてTabItemだったらaddTabFromItemView()を呼んでいる。

    private void addTabFromItemView(@NonNull TabItem item) {
        TabLayout.Tab tab = this.newTab();
        if (item.text != null) {
            tab.setText(item.text);
        }

        if (item.icon != null) {
            tab.setIcon(item.icon);
        }

        if (item.customLayout != 0) {
            tab.setCustomView(item.customLayout);
        }

        if (!TextUtils.isEmpty(item.getContentDescription())) {
            tab.setContentDescription(item.getContentDescription());
        }

        this.addTab(tab);
    }

ここで面白いのはTabItemはViewをextendsしたclassなのに、ただのdataを保持するだけのクラスのように扱われている。xmlからattributeSetを引回す必要があるのでViewである必要があるのだろうけど。

public class TabItem extends View {
    public final CharSequence text;
    public final Drawable icon;
    public final int customLayout;

    public TabItem(Context context) {
        this(context, (AttributeSet)null);
    }

    public TabItem(Context context, AttributeSet attrs) {
        super(context, attrs);
        TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs, styleable.TabItem);
        this.text = a.getText(styleable.TabItem_android_text);
        this.icon = a.getDrawable(styleable.TabItem_android_icon);
        this.customLayout = a.getResourceId(styleable.TabItem_android_layout, 0);
        a.recycle();
    }
}

生成したTabLayout.Tabに対して保持していたtext, icon, customLayout, contentDescriptionの4種類の情報のみをセットしてaddTab()している。 ここでidが捨てられているので、実際にview hierarchyに追加されたtabをidからfindできない仕組みになっていたのだ。

以下は余談になるが、この辺りは読んでいて色々面白い。

TabLayout.Tabを生成するnewTab()`メソッドはpoolを利用してrecycleしている。

    private static final Pool<TabLayout.Tab> tabPool = new SynchronizedPool(16);

    private final Pool<TabLayout.TabView> tabViewPool;

    public TabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
...
        this.tabViewPool = new SimplePool(12);
...
    }

    @NonNull
    public TabLayout.Tab newTab() {
        TabLayout.Tab tab = this.createTabFromPool();
        tab.parent = this;
        tab.view = this.createTabView(tab);
        return tab;
    }

    protected TabLayout.Tab createTabFromPool() {
        TabLayout.Tab tab = (TabLayout.Tab)tabPool.acquire();
        if (tab == null) {
            tab = new TabLayout.Tab();
        }

        return tab;
    }

tabPooltabViewPoolのmaxPoolSizeが16と12で異なっているのはなんでなんだろう?

TabLayoutでいい感じにアイコンをつけたい時のメモ

ViewPagerとかと併用してTabLayoutを使う場合に、特定のタブにアイコンを表示する時にどうすればいいかなーという時のメモ。1
今のところは Tab#setCustomView() でcustome layout resourceをセットするのが大体のケースにおいて現実解となりそう。

また、こちらの調査はDesign Support Libraryのコードを呼んだもので、AndroidXでどうなっているかは保証できません。 (ざっと見た感じだとそんなに変わってなさそうだった。2

運よく setIcon() が使える場合

custom viewを作らなくても運よく setIcon() で済む場合がある。setIcon()はその名の通りTabにアイコンを設定してくれる。ただし、このAPIを使う場合だとアイコンの位置はテキストの上に固定されている。

f:id:muumuumuumuu:20190202125807p:plain

ちなみにここのImageViewの定義はこちら。

<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
           android:layout_width="24dp"
           android:layout_height="24dp"
           android:scaleType="centerInside"/>

chromium.googlesource.com

TextViewの定義はこちら。

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:ellipsize="end"
          android:gravity="center"
          android:maxLines="2"/>

chromium.googlesource.com

ちなみにTextViewの方はこちらのstyleが設定されている。

    <style name="TextAppearance.Design.Tab" parent="TextAppearance.AppCompat.Button">
        <item name="android:textSize">14sp</item>
        <item name="android:textColor">?android:textColorSecondary</item>
        <item name="textAllCaps">true</item>
    </style>

chromium.googlesource.com

で、このImageViewとTextViewがTabViewに addView()されるのだが、TabViewLinearLayout をextendsしているので上記のように縦に並んだデザインになる。3

大抵の場合はアイコンをテキスト横に配置したい

縦じゃなくてテキストの横にアイコンを並べたいケースが多いと思う。(こんな感じで↓)

f:id:muumuumuumuu:20190202132334p:plain

この場合だとlayout xmlを書いて setCustomView() に渡してやるとできる。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <ImageView
        android:id="@+id/icon"
        android:layout_width="8dp"
        android:layout_height="8dp"
        android:src="@drawable/circle"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@android:id/text1"/>

    <TextView
        android:id="@android:id/text1"
        style="@style/TabTextAppearance"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@id/icon"
        app:layout_constraintEnd_toEndOf="parent"
        tools:text="TAB"/>
</android.support.constraint.ConstraintLayout>
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val viewPager = findViewById<ViewPager>(R.id.view_pager)
        viewPager.adapter = ViewPagerAdapter(supportFragmentManager)

        val tab = findViewById<TabLayout>(R.id.tab)
        tab.setupWithViewPager(viewPager)
        tab.getTabAt(0)?.setCustomView(R.layout.view_custom_view)
    }

    private class ViewPagerAdapter(fragmentManager: FragmentManager) : FragmentPagerAdapter(fragmentManager) {
        override fun getCount() = 2
        override fun getItem(p0: Int) = MainFragment()
        override fun getPageTitle(position: Int) = when (position) {
            0 -> "one"
            1 -> "two"
            else -> "other"
        }
    }
}

ただし、注意すべきポイントがある。

TextViewのid

上記のxmlの通り、TextViewに設定するidは @android:id/text1 の必要がある。これを設定しないとPagerAdapterから渡されるtitleが設定されない。 TabView#update()の中を見るとわかる。

        final void update() {
            final Tab tab = mTab;
            final View custom = tab.getCustomView();
            if (custom != null) {
                final ViewParent customParent = custom.getParent();
                if (customParent != this) {
                    if (customParent != null) {
                        ((ViewGroup) customParent).removeView(custom);
                    }
                    addView(custom);
                }
                mCustomView = custom;
                if (mTextView != null) {
                    mTextView.setVisibility(GONE);
                }
                if (mIconView != null) {
                    mIconView.setVisibility(GONE);
                    mIconView.setImageDrawable(null);
                }
                mCustomTextView = (TextView) custom.findViewById(android.R.id.text1);
                mCustomIconView = (ImageView) custom.findViewById(android.R.id.icon);

アイコンをつけないViewと見た目を揃えたい

多分一番面倒なのがここ。全てのTabをcustomにするのであればいいけど、デフォルトのtabを一緒に表示する場合は違和感をなくすために見た目を揃えたくなると思う。その場合は上記のsetIcon()を使う場合で紹介したTextViewやstyleのxmlを参考にしてできる限り同じattributeを設定してやると違和感がなくせる。

ただしできないこともある

残念ながらcustom viewをセットする場合はstatus(selectedか否か)をTabViewが設定してくれない。TabViewはdefaultデザインの場合に表示されるmTextView/mIconViewと、custom viewを設定した場合のmCustomTextView/mCustomIconViewを分けて管理している。

        private TextView mTextView;
        private ImageView mIconView;

        private TextView mCustomTextView;
        private ImageView mCustomIconView;

で TabView#setSelected()の中を見ると、mTextView/mIconViewにしかsetSelected()を呼んでいない。

        @Override
        public void setSelected(boolean selected) {
            final boolean changed = (isSelected() != selected);
            super.setSelected(selected);
            if (changed && selected) {
                sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
                if (mTextView != null) {
                    mTextView.setSelected(selected);
                }
                if (mIconView != null) {
                    mIconView.setSelected(selected);
                }
            }
        }

android.googlesource.com

したがって、custom viewを設定した場合はタブを動かしてもfont colorなどが変わらない。

回避策としてTabSelectListerでtabの状態を検知してコードから状態を設定するのが良さそう。

こんな感じでselectorでcolorを定義して、

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true"
        android:color="?android:textColorPrimary" />
    <item android:color="?android:textColorSecondary" />
</selector>

CustomViewのTextViewのtextColorに指定する。

    <TextView
        android:id="@android:id/text1"
(省略)
        android:textColor="@color/tab_text_color"

最後にTabLayout#addOnTabSelectedListener()を設定してcustomViewの状態を設定する。

        val tab = findViewById<TabLayout>(R.id.tab)
        tab.setupWithViewPager(viewPager)
        tab.getTabAt(0)?.setCustomView(R.layout.view_custom_view)

        tab.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
            override fun onTabReselected(p0: TabLayout.Tab?) {}
            override fun onTabSelected(p0: TabLayout.Tab?) {
                p0?.customView?.isSelected = true
            }
            override fun onTabUnselected(p0: TabLayout.Tab?) {
                p0?.customView?.isSelected = false
            }
        })

これで無事custom Viewの方もstateによってtext colorを変えることができる。

余談

ViewPagerに連動してTabLayoutの方もselected stateが変わるしくみを調べたのでメモ。
ViewPagerとTabLayoutを紐づける時にコールするTabLayout#setupWithViewPager()の内部でViewPagerに対してTabLayoutOnPageChangeListenerをセットしている。

        // Now we'll add our page change listener to the ViewPager
        viewPager.addOnPageChangeListener(new TabLayoutOnPageChangeListener(this));

で、ViewPagerの方でSwipe動作でPageがscrollされた時に上で設定したlistenerのonPageScrolled()が呼ばれる。

    private void dispatchOnPageScrolled(int position, float offset, int offsetPixels) {
        if (this.mOnPageChangeListener != null) {
            this.mOnPageChangeListener.onPageScrolled(position, offset, offsetPixels);
        }

        if (this.mOnPageChangeListeners != null) {
            int i = 0;

            for(int z = this.mOnPageChangeListeners.size(); i < z; ++i) {
                ViewPager.OnPageChangeListener listener = (ViewPager.OnPageChangeListener)this.mOnPageChangeListeners.get(i);
                if (listener != null) {
                    listener.onPageScrolled(position, offset, offsetPixels); // ★ここ
                }
            }
        }

TabLayoutOnPageChangeListener#onPageScrolled()でTabLayout#setScrollPosition()が呼ばれる。

        @Override
        public void onPageScrolled(int position, float positionOffset,
                int positionOffsetPixels) {
            final TabLayout tabLayout = mTabLayoutRef.get();
            if (tabLayout != null) {
                // Update the scroll position, only update the text selection if we're being
                // dragged (or we're settling after a drag)
                final boolean updateText = (mScrollState == SCROLL_STATE_DRAGGING)
                        || (mScrollState == SCROLL_STATE_SETTLING
                        && mPreviousScrollState == SCROLL_STATE_DRAGGING);
                tabLayout.setScrollPosition(position, positionOffset, updateText); // ★ここ
            }
        }

さらにsetSelectedTabView()が呼ばれて、

    public void setScrollPosition(int position, float positionOffset, boolean updateSelectedText) {
        if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {
            return;
        }
        if (position < 0 || position >= mTabStrip.getChildCount()) {
            return;
        }
        // Set the indicator position and update the scroll to match
        mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset);
        scrollTo(calculateScrollXForTab(position, positionOffset), 0);
        // Update the 'selected state' view as we scroll
        if (updateSelectedText) {
            setSelectedTabView(Math.round(position + positionOffset)); // ★ここ
        }
    }

この中でTabView#setSelected()をコールし、

    private void setSelectedTabView(int position) {
        final int tabCount = mTabStrip.getChildCount();
        for (int i = 0; i < tabCount; i++) {
            final View child = mTabStrip.getChildAt(i);
            child.setSelected(i == position);  // ★ここ
        }
    }

それぞれのViewに対して状態を変えている。先ほども見た通り、custom viewの状態は変わらない。

        @Override
        public void setSelected(boolean selected) {
            final boolean changed = (isSelected() != selected);
            super.setSelected(selected);
            if (changed && selected) {
                sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
                if (mTextView != null) {
                    mTextView.setSelected(selected);
                }
                if (mIconView != null) {
                    mIconView.setSelected(selected);
                }
            }
        }

  1. バッジを出したいとかよく見るUIな気がする

  2. github.com

  3. orientationを指定していないのでdefaultのverticalが適用されて縦に並ぶ

便利なTextInputLayoutとその内部実装

はじめに

Material Designにいつの間にか入っていたTextInputLayoutが便利なのでメモを残す。

TextInputLayout とは

公式ドキュメント によると、TextInputLayout とは「テキストを入力する時にhintを隠す代わりにfloating labelを表示するEditTextをラップしたview」らしい。 これだけ読んでも謎だと思うので、Google Code LabのMaterial Design Componentsのコースをgit cloneしてきて動かしてみるとわかりやすいです。 あとは最近WebのGoogle Login画面でこのcomponentが使われている気がする。

MDC-104 Android: Material Advanced Components (Kotlin)

なんかgifがうまくアップロードできなくてanimation部分が見えない…

f:id:muumuumuumuu:20190104182847g:plain:w200

ちなみに上記はstyleにWidget.MaterialComponents.TextInputLayout.OutlineBoxが設定されている。

どうやってhintを動かしているのか?

せっかくなのでこのhintのanimationがどのように実装されているのかコードを読んでみた。 ちなみに上記のCode Labからgit cloneしてきたプロジェクトだとTextInputLayout はAndroidXではなく com.android.support:support-v4:28.0.0-alpha3 だったので、これを読んでいく。

animationしているのは下記のメソッド。このメソッドの引数のfloatには、collapseの場合は1.0F, expandの時は0.0Fが渡される。

    @VisibleForTesting
    void animateToExpansionFraction(float target) {
        if (this.collapsingTextHelper.getExpansionFraction() != target) {
            if (this.animator == null) {
                this.animator = new ValueAnimator();
                this.animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
                this.animator.setDuration(167L);
                this.animator.addUpdateListener(new AnimatorUpdateListener() {
                    public void onAnimationUpdate(ValueAnimator animator) {
                        TextInputLayout.this.collapsingTextHelper.setExpansionFraction((Float)animator.getAnimatedValue());
                    }
                });
            }

            this.animator.setFloatValues(new float[]{this.collapsingTextHelper.getExpansionFraction(), target});
            this.animator.start();
        }
    }

ValueAnimator を使ってanimationしている。Interpolatorを設定して167Lのdurationをセット。(なんで167に決まったんだろう?)
で現在のexpansion fractionをHelperクラスからとってきて、そこから引数のtargetまでをvalueの変更幅に指定する。

start()を読んでやるとlistenerに設定したcallback methodが呼ばれる。

実際にviewをanimationするのはまたもやhelperクラスの方に移譲している。

    public void setExpansionFraction(float fraction) {
        fraction = MathUtils.clamp(fraction, 0.0F, 1.0F);
        if (fraction != this.expandedFraction) {
            this.expandedFraction = fraction;
            this.calculateCurrentOffsets();
        }

    }
    private void calculateOffsets(float fraction) {
        this.interpolateBounds(fraction);
        this.currentDrawX = lerp(this.expandedDrawX, this.collapsedDrawX, fraction, this.positionInterpolator);
        this.currentDrawY = lerp(this.expandedDrawY, this.collapsedDrawY, fraction, this.positionInterpolator);
        this.setInterpolatedTextSize(lerp(this.expandedTextSize, this.collapsedTextSize, fraction, this.textSizeInterpolator));
        if (this.collapsedTextColor != this.expandedTextColor) {
            this.textPaint.setColor(blendColors(this.getCurrentExpandedTextColor(), this.getCurrentCollapsedTextColor(), fraction));
        } else {
            this.textPaint.setColor(this.getCurrentCollapsedTextColor());
        }

        this.textPaint.setShadowLayer(lerp(this.expandedShadowRadius, this.collapsedShadowRadius, fraction, (TimeInterpolator)null), lerp(this.expandedShadowDx, this.collapsedShadowDx, fraction, (TimeInterpolator)null), lerp(this.expandedShadowDy, this.collapsedShadowDy, fraction, (TimeInterpolator)null), blendColors(this.expandedShadowColor, this.collapsedShadowColor, fraction));
        ViewCompat.postInvalidateOnAnimation(this.view);
    }

currentDrawXcurrentDrawYそのほか諸々の計算を済ませておいてpostInvalidateOnAnimation()をコールして描画し直す時にその値を使う感じっぽい。 ViewCompat.postInvalidateOnAnimation() を辿っていくとView#invalidate() が呼ばれていることがわかる。

postInvalidateOnAnimation() の引数にはTextInputLayoutが渡されているので、このクラスのdraw()をみてみるとhelperクラスのdraw()が呼ばれている。

    public void draw(Canvas canvas) {
        if (this.boxBackground != null) {
            this.boxBackground.draw(canvas);
        }

        super.draw(canvas);
        if (this.hintEnabled) {
            this.collapsingTextHelper.draw(canvas);
        }

    }

この中でcalculateOffsets() で計算したx,yの値やsetInterpolatedTextSize()の内部で計算・設定されているscaleを使ってviewを動かしている。 動かすのはcanvasに対してscale()とかdrawBitmap()/drawText()とかをコールして実装している。
(あまり関係ないけどvar10000とかvar7の変数はなんのために作られたのかわからない…)

    public void draw(Canvas canvas) {
        int saveCount = canvas.save();
        if (this.textToDraw != null && this.drawTitle) {
            float x = this.currentDrawX;
            float y = this.currentDrawY;
            boolean drawTexture = this.useTexture && this.expandedTitleTexture != null;
            float ascent;
            if (drawTexture) {
                ascent = this.textureAscent * this.scale;
                float var10000 = this.textureDescent * this.scale;
            } else {
                ascent = this.textPaint.ascent() * this.scale;
                float var7 = this.textPaint.descent() * this.scale;
            }

            if (drawTexture) {
                y += ascent;
            }

            if (this.scale != 1.0F) {
                canvas.scale(this.scale, this.scale, x, y);
            }

            if (drawTexture) {
                canvas.drawBitmap(this.expandedTitleTexture, x, y, this.texturePaint);
            } else {
                canvas.drawText(this.textToDraw, 0, this.textToDraw.length(), x, y, this.textPaint);
            }
        }

        canvas.restoreToCount(saveCount);
    }

アニメーションが早すぎて見えないけどTextColorもアニメーションに合わせて徐々に変わるように設定されていて細かい。すごい。でも見えない。

    private static int blendColors(int color1, int color2, float ratio) {
        float inverseRatio = 1.0F - ratio;
        float a = (float)Color.alpha(color1) * inverseRatio + (float)Color.alpha(color2) * ratio;
        float r = (float)Color.red(color1) * inverseRatio + (float)Color.red(color2) * ratio;
        float g = (float)Color.green(color1) * inverseRatio + (float)Color.green(color2) * ratio;
        float b = (float)Color.blue(color1) * inverseRatio + (float)Color.blue(color2) * ratio;
        return Color.argb((int)a, (int)r, (int)g, (int)b);
    }

2018年後半を振り返る

はじめに

2018年後半に自分が何をしていたか後から振り返られるようにメモを残しておく。前回のまとめはこちら。

muumuutech.hatenablog.com

7月

今年はスパイス系の料理にハマっていた。特に六本木ヒルズ内のインド料理屋さんのビリヤニ美味しいのでたくさん食べた気がする。

Go Bold Dayというチームのハッカソンに参加して3位入賞した。ご褒美に高級すき焼きランチ美味しかった!

この頃はクライアント側で軽量に動くOCRに興味があったんだけど、なかなかいいやつに出会えない…クレカの文字認識むずかしすぎでは????

これ今年なんども言っている気がする。twitterとかblogとかでめっちゃ勉強になること教えてくれる人に投げ銭送りたいのでみんなtwitterのbioになんかそれ系サービスのリンク貼っててほしいお気持ち。

エウレカさんのもくもく会に参加した。

もくもく会、最近主催も参加もできてないので来年はもうちょっとやりたい。

Clova開発の中の人に「SDKはKotlin対応しないの?」って聞いてしばらくレスがなかった後に突然Kotlin対応がリリースされたのめっちゃいい話だった。

8月

大学時代の友人と会っていた。みんなママになってた!

バリウムデビューした。みんなこんな怖い思いするのか…?検査終わった後に渡される下剤の量を自分で調節しろとか正気か…?など色々学びがあった。世界は思ったよりも運用でカバーされている。

会社でKotlinの可愛さを布教しようとして失敗。(Kotlinの良さは理解してもらったのでよしとする)

社内LT大会を開催した。楽しかった!

9月

イヤリングを片方だけなくすのをたくさん繰り返した一年だった。最近は落とす前提で、落とした時に気がつけるように大きなイヤリングを買う作戦に切り替えている。

DroidKaigiのCfP募集が開始。今年はスタッフとして参加するので応募は見送りました。

30歳になりました。

10月

自社のTech Confで登壇した。USチームとしてブースも出していました。

技術書店で売り子をしました。

本当にルー大柴化が止まらない。英語勉強中の私と日本語勉強中の同僚と日々会話すると英語と日本語が混ざってくるのでマジでルー大柴みたいになる。

秋田の友人の結婚式に出席するため初めて東北新幹線に乗った。東海道新幹線のつもりで言ったら死ぬやつだった。

社の健康診断のオプションが充実いていたおかげで病気の可能性を早めに検出することができた。みなさんちゃんと検診受けましょう。

11月

今年の個人的ベストレストランは多分ここ。

あまりにダメダメだったのでオンライン朝もくもく会をはじめることにした。(この記事ももくもく会で書いています。) 1ヶ月以上ちゃんと続いているので習慣化したと言っても良いでしょう。 参加したい!って人はTwitterでDMください。

12月

前職の人たちとお疲れ様でした会を兼ねた忘年会。ずっと行きたかったお店に行けて最高だった。

来年に向けて

たくさん溜まっている下書きちゃんと仕上げてたくさん出していくぞ!

はい。

いい加減Pixel 3買うぞ!

温泉でゴロゴロするぞ

たまたまやってみたけど、これは積読消化のいいモチベーションになった。積読消化していくぞ!!

夫シリーズ

RecyclerView in RecyclerViewだとAppBarLayoutがスクロールしないことがある

Nested Scroll(RecyclerView in RecyclerView)とAppBarの組み合わせでうまくいかないことがあったのでメモします。

前提

RecyclerViewが入れ子になってNested Scrollする画面で、

  • Parent RecyclerViewはvertical scroll
  • Child RecyclerViewはhorizontal scroll

といった挙動をします。

ParentのRecyclerViewCoordinatorLayoutを親に持っていて、AppBarLayoutがParentのRecyclerViewのスクロールに応じてアニメーションするといった挙動を想定しています。

問題

ParentのRecyclerViewをスクロールさせてもAppBarLayoutは変化しません。 (水色のViewがAppBarLayoutに包まれています。)

f:id:muumuumuumuu:20181204090257g:plain:w200

(同じParentにスクロールしないchildを入れてやり、その上からスクロールを開始するとAppBarはちゃんと正常にアニメーションします。)

直し方

Child RecyclerViewNestedScrollingEnabled() にfalseを設定してやればうまくいきます。

コードからだとこんな感じ

childRecyclerView.isNestedScrollingEnabled = false

xmlからも指定できます

<android.support.v7.widget.RecyclerView 
    ...(省略)...
    android:nestedScrollingEnabled="false"

上記のようにChild `RecyclerViewがnested scrollしないことを明示的に指定するだけでAppBarがアニメーションするようになります。

f:id:muumuumuumuu:20181204091114g:plain:w200

何が起こったか?

setNestedScrollingEnabled() を設定することによってどこが変わるかというと、NestedScrollingChildHelper#dispatchNestedPreScroll()がfalseを返すようになります。

RecyclerViewのdispatchNestedPreScroll() を呼んだ先でNestedScrollingChildHelper#dispatchNestedPreScroll() -> ViewParentCompat.onNestedPreScroll() -> CoordinatorLayout#onNestedPreScroll() -> AppBarLayout.BaseBehavior#onNestedPreScroll() と伝播していきます。

Scrollしない時はAppBarLayoutの onNestedPreScroll() のdyの値が正常に渡って来ません。

    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
        if (this.isNestedScrollingEnabled()) {

                ViewParentCompat.onNestedPreScroll(parent, this.mView, dx, dy, consumed, type);

        }

        return false;
    }

ここでisNestedScrollingEnabled() がfalseを返さないと本来子のRecyclerViewはVerticalなscrollをしないはずなのにonNestedPreScroll() のdyの値が変なまま伝播していってしまってうまくAppBarLayoutがアニメーションできないのかな?と予想しています。(RecyclerViewが巨大すぎて細かい部分まで追えていないのでもし違っていたらコメントで指摘してもらえると助かります。)

AppBarLayout.BaseBehavior#onNestedPreScroll()scroll()が呼ばれます。

        public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, T child, View target, int dx, int dy, int[] consumed, int type) {
            if (dy != 0) {
                int min;
                int max;
                if (dy < 0) {
                    min = -child.getTotalScrollRange();
                    max = min + child.getDownNestedPreScrollRange();
                } else {
                    min = -child.getUpNestedPreScrollRange();
                    max = 0;
                }

                if (min != max) {
                    consumed[1] = this.scroll(coordinatorLayout, child, dy, min, max);
                    this.stopNestedScrollIfNeeded(dy, child, target, type);
                }
            }

        }

この先HeaderBehavior#scroll()setHeaderTopBottomOffset()などを呼んで最終的にView#offsetTopAndBottom()が呼ばれます。 ここで不正に渡って来たdyの値によって、スクロールしていなかったことになっているんじゃないかなぁ。

ちなみにsetNestedScrollingEnabled()はデフォルトでtrueを返すの?と思った人がいるかもしれませんが、このフラグはRecyclerViewのコンストラクタでtrueが設定されます。

    public RecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {

        boolean nestedScrollingEnabled = true; // defaultでtrue
        if (attrs != null) {

            if (VERSION.SDK_INT >= 21) {
                a = context.obtainStyledAttributes(attrs, NESTED_SCROLLING_ATTRS, defStyle, defStyleRes);
                nestedScrollingEnabled = a.getBoolean(0, true); // xmlで指定されていれば反映する
                a.recycle();
            }

        this.setNestedScrollingEnabled(nestedScrollingEnabled);
    }

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で挙動に差分が出るのはここでした。解決!