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

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

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される

という挙動でした。