長いタイトルが全てを表していて、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
の要素として扱うしかないんじゃないかな。
なんでこんなことになるのか、せっかくなのでなのでNestedScrollView
とRecyclerView
周りのコードを読んでみた。
RecyclerView
がどうやって要素をlayoutしていくかはこちらの資料が詳しいので色々省略
www.slideshare.net
問題はRecyclerViewの中をどんどん埋めていくこちらのメソッド
/** * 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についてはこちらが詳しいです。
親が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); } } }
ここでheightMode
はMeasureSpec.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してみる。
UNSPECIFIED
をEXACTLY
に変えて見たらallocateされる個数が30個から14個まで減った。どういうロジックで14個になるかまではわからず…
というわけでまとめると、
NestedScrollView#onMeasure()
がコールされるNestedScrollView#measureChildWithMargins()
がコールされるが、ここでchildHeightMeasureSpec
が0 (UNSPECIFIED
)としてchild.measure()
がコールされる- childである
RecyclerView
もHeightのMeasureSpecがUNSPECIFIED
になるので、layoutState.mInfinite
フラグがtrueになり要素分全てがlayoutされる
という挙動でした。