在Android開發中RecyclerView是咱們高頻使用的一個組件,用來展現大量的數據。咱們不只要熟練使用它,還要對它的實現有一個認知。本片文章介紹RecyclerView的繪製流程,也就是onMeasure
、onLayout
、onDraw
這三個方法中主要作了些什麼工做,let's go!java
OnMeasure
咱們知道該方法是測量當前View及子View的寬高,可是查看RecyclerView的源碼發現代碼很長,它不只僅作了測量的工做,還作了一些其餘的工做,咱們一塊兒來看一下緩存
if (mLayout == null) {
//第一種狀況
}
if (mLayout.isAutoMeasureEnabled()) {
//第二種狀況,一般會進入到這種狀況
}else{
// 第三種狀況
}
複製代碼
根據條件分支將onMeasure
方法分紅了三種狀況,咱們挨個來討論一下bash
mLayout == null
if (mLayout == null) {
defaultOnMeasure(widthSpec, heightSpec);
return;
}
void defaultOnMeasure(int widthSpec, int heightSpec) {
final int width = LayoutManager.chooseSize(widthSpec,getPaddingLeft() + getPaddingRight(),ViewCompat.getMinimumWidth(this));
final int height = LayoutManager.chooseSize(heightSpec,getPaddingTop() + getPaddingBottom(),ViewCompat.getMinimumHeight(this));
setMeasuredDimension(width, height);
}
public static int chooseSize(int spec, int desired, int min) {
final int mode = View.MeasureSpec.getMode(spec);
final int size = View.MeasureSpec.getSize(spec);
switch (mode) {
case View.MeasureSpec.EXACTLY:
return size;
case View.MeasureSpec.AT_MOST:
return Math.min(size, Math.max(desired, min));
case View.MeasureSpec.UNSPECIFIED:
default:
return Math.max(desired, min);
}
}
複製代碼
mLayout == null
時,從傳入的參數能夠看出來RecyclerView
沒有考慮子View就決定了本身的大小,是一個比較粗糙的測量,具體的大小還須要根據以後屢次測量的結果來定app
mLayout.isAutoMeasureEnabled() == true
mLayout.isAutoMeasureEnabled()
爲true
時,將調用LayoutManager.onLayoutChildren(Recycler, State)
來計算子View們所須要的大小,RecyclerView.LayoutManager
的實現類LinearLayoutManager
、StaggeredGridLayoutManager
都重載了該方法並返回true,因此一般都會走入這個分支(列出了部分代碼)ide
if (mLayout.isAutoMeasureEnabled()) {
//將測量交給LaytouManager
mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
final boolean measureSpecModeIsExactly =
widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY;
//若是width和height都已是精確值,那麼就不用再根據內容進行測量,後面步驟再也不處理
if (measureSpecModeIsExactly || mAdapter == null) {
return;
}
if (mState.mLayoutStep == State.STEP_START) {
dispatchLayoutStep1();
}
dispatchLayoutStep2();
//若是須要二次測量的話
if (mLayout.shouldMeasureTwice()) {
dispatchLayoutStep2();
}
}
複製代碼
第一步調用了LayoutManager.onMeasure()
方法源碼分析
public void onMeasure(@NonNull Recycler recycler, @NonNull State state, int widthSpec,int heightSpec) {
mRecyclerView.defaultOnMeasure(widthSpec, heightSpec);
}
複製代碼
該方法直接調用了RecyclerView
的默認測量方法,就是咱們以前分析的第一種狀況。閱讀該方法的註釋咱們能夠發現,LayoutoManager
強烈推薦開啓自動測量,而且若是開啓了自動測量就不要重寫該方法,LayoutoManager
的三個默認實現也確實沒重寫該方法。還介紹了下測量的策略,若是寬高測量模式爲UNSPECIFIED. AT_MOST則指定爲EXACTLY而且RecyclerView
佔用可用的最大空間。佈局
第二步 若是寬高的測量模式都爲MeasureSpec.EXACTLY
或者沒有設置Adapter
直接返回。post
接下來咱們繼續看,mState.mLayoutStep
的默認值就是State.STEP_START
,進入條件語句執行dispatchLayoutStep1()
,而後執行dispatchLayoutStep2()
,若是須要執行二次測量的話在執行一次dispatchLayoutStep2()
。動畫
咱們重點看dispatchLayoutStep1()
和dispatchLayoutStep2()
,與這兩個方法息息相關的一個變量是mState.mLayoutStep
,該變量得意義是決定了dispatchLayoutStep1()
、dispatchLayoutStep1()
、dispatchLayoutStep2()
這三個方法該執行哪一步了,它的取值有三個ui
mLayoutStep | 描述 |
---|---|
STEP_START | 默認值或者dispatchLayoutStep3() 已經執行了 |
STEP_LAYOUT | dispatchLayoutStep1() 已經執行了 |
STEP_ANIMATIONS | dispatchLayoutStep2() 已經執行了 |
對這三個方法的具體分析,咱們放到onLayout
中處理,先說一下結論dispatchLayoutStep1()
處理了Adapter的數據更新以及準備動畫前的數據;dispatchLayoutStep2()
進行itemView的佈局
狀況三: mLayout.isAutoMeasureEnabled() == false
咱們一般使用的LayoutManager
都返回true
,除非咱們自定義,因此暫不分析這種狀況
總結一下
onMeasure
所作的工做,假設LayoutManager
爲LinearLayoutManager
- 測量一下本身,可能須要屢次測量
- 若是寬高不都爲
MeasureSpec.EXACTLY
模式則執行
dispatchLayoutStep1()
,處理Adapter
更新以及準備動畫前的數據dispatchLayoutStep2()
進行itemView的佈局
OnLayout
protected void onLayout(boolean changed, int l, int t, int r, int b) {
TraceCompat.beginSection(TRACE_ON_LAYOUT_TAG);
dispatchLayout();
TraceCompat.endSection();
mFirstLayoutComplete = true;
}
複製代碼
就是執行了dispatchLayout
void dispatchLayout() {
if (mAdapter == null) {
Log.e(TAG, "No adapter attached; skipping layout");
return;
}
if (mLayout == null) {
Log.e(TAG, "No layout manager attached; skipping layout");
return;
}
mState.mIsMeasuring = false;
if (mState.mLayoutStep == State.STEP_START) {
dispatchLayoutStep1();
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth()
|| mLayout.getHeight() != getHeight()) {
// First 2 steps are done in onMeasure but looks like we have to run again due to
// changed size.
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else {
// always make sure we sync them (to ensure mode is exact)
mLayout.setExactMeasureSpecsFrom(this);
}
dispatchLayoutStep3();
}
複製代碼
流程也很清晰
Adapter
和LayoutManager
沒設置就不進行佈局,RecyclerView
也就只能顯示一片空白onMeasure
中執行了dispatchLayoutStep1()
和dispatchLayoutStep2()
則再也不執行這兩個方法,不過dispatchLayoutStep2()
可能須要被再次調用dispatchLayoutStep3()
如今咱們來查看下dispatchLayoutStep
系列方法到底幹了些什麼
dispatchLayoutStep1()
/** * The first step of a layout where we; * - process adapter updates * - decide which animation should run * - save information about current views * - If necessary, run predictive layout and save its information */
private void dispatchLayoutStep1() {
processAdapterUpdatesAndSetAnimationFlags();
if (mState.mRunSimpleAnimations){
//...
}
if (mState.mRunPredictiveAnimations){
//...
}
mState.mLayoutStep = State.STEP_LAYOUT;
}
複製代碼
以上代碼縮減了不少,從該方法的註釋能夠看出來,dispatchLayoutStep1()
主要處理了Adapter
更新以及準備動畫所需數據,而processAdapterUpdatesAndSetAnimationFlags()
就是用來處理Adapter
更新和動畫的Flag
處理,咱們看一下這個方法裏面
private void processAdapterUpdatesAndSetAnimationFlags() {
if (mDataSetHasChangedAfterLayout) {
// Processing these items have no value since data set changed unexpectedly.
// Instead, we just reset it.
mAdapterHelper.reset();
if (mDispatchItemsChangedEvent) {
mLayout.onItemsChanged(this);
}
}
爲動畫的flag進行賦值
mState.mRunSimpleAnimations = ...
mState.mRunPredictiveAnimations = ...
}
複製代碼
首先處理Adapter
的更新(Adapter.notifyDataSetChanged()
或者RecyclerView.swapAdapter(Adapter, boolean)
表明Adapter有更新),就是簡單的把以前記錄的每個item的操做重置一下,由於數據集的更改致使以前存的信息都沒有意義了,下邊的代碼是爲動畫的標誌位賦值,咱們調用RecyclerView.setAdapter
和Adapter.notifyDataSetChanged()
是不會觸發動畫的,因此咱們先不考慮動畫相關的東西。
咱們繼續來看dispatchLayoutStep1()
的內容,下面是兩個if
條件,涉及兩個變量mState.mRunSimpleAnimations
和mState.mRunPredictiveAnimations
這兩個變量在要執行動畫時才爲true
,因此先不考慮裏面的內容。 最後執行mState.mLayoutStep = State.STEP_LAYOUT
,表明dispatchLayoutStep1()
已經執行完畢了
總結
dispatchLayoutStep1()
處理了Adapter更新以及準備動畫所需數據
private void dispatchLayoutStep2() {
mLayout.onLayoutChildren(mRecycler, mState);
mState.mLayoutStep = State.STEP_ANIMATIONS;
}
複製代碼
方法很簡潔,調用了LayoutManager.onLayoutChildren(Recycler recycler, State state)
,該方法就是進行子View佈局的實質方法,不過是一個空實現,子類必須去實現這個方法,聲明以下
public void onLayoutChildren(Recycler recycler, State state) {
Log.e(TAG, "You must override onLayoutChildren(Recycler recycler, Statestate) ");
}
複製代碼
最後mState.mLayoutStep = State.STEP_ANIMATIONS
表明dispatchLayoutStep2()
已經執行完畢了
總結
dispatchLayoutStep2()
調用LayoutManager.onLayoutChildren
來進行子View的佈局
private void dispatchLayoutStep3() {
mState.mLayoutStep = State.STEP_START;
if (mState.mRunSimpleAnimations) {
//記錄layout以後View的信息,並觸發動畫
//...
}
//...一些清理工做
}
複製代碼
首先將mState.mLayoutStep = State.STEP_START
,標誌dispatchLayoutStep3()
已經執行了,而後mState.mRunSimpleAnimations
這個變量表示是否執行動畫,第一次佈局的時候是不須要動畫的因此不會進入這個分支,動畫咱們以後在講,最後作一些清理的工做。
總結
dispatchLayoutStep3()
觸發動畫
總結一下
onLayout
所作的工做,大致流程以下
dispatchLayoutStep1()
處理了Adapter更新以及準備動畫所需數據dispatchLayoutStep2()
調用LayoutManager.onLayoutChildren來進行子View的佈局dispatchLayoutStep3()
觸發動畫
draw
public void draw(Canvas c) {
super.draw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}
//...處理clipToPadding="false"的狀況
}
複製代碼
onDraw
@Override
public void onDraw(Canvas c) {
super.onDraw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDraw(c, this, mState);
}
}
複製代碼
總結:
draw
和onDraw
對RecyclerView
的分割線進行了繪製,固然分割線須要咱們本身去實現具體的繪製內容,同時咱們也知道了ItemDecoration
中onDraw
和onDrawOver
的區別
至此咱們已經完成了RecyclerView
三大流程的源碼分析,上面列出的代碼大多都通過了精簡,省去了不少細節,不過剛開始閱讀源碼時,咱們只要把握總體的流程就好,拋開細節來看,以上的總體流程並不難理解。可是有一個很重要的方法沒有細講,就是LayoutManager.onLayoutChildren()
,該方法纔是佈局子View的核心,咱們對該方法進行單獨的一波分析,以LinearLayoutManager
(只考慮豎直方向)爲例來看一下
LinearLayoutManager.onLayoutChildren()
在進行子View的佈局中利用了一些幫助類來幫助佈局,咱們須要先了解一下這些幫助類
屬性 | 解釋 |
---|---|
mOffset |
偏移多少個像素點以後開始佈局 |
mAvailable |
當前佈局方向上可用的空間 |
mCurrentPosition |
要佈局子View在Adapter中的表明的位置 |
mInfinite |
佈局的View數量沒有限制 |
屬性 | 解釋 |
---|---|
mPosition |
錨點位置 |
mCoordinate |
錨點座標信息 |
mValid |
是否可用 |
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// layout algorithm:
// 1) by checking children and other variables, find an anchor coordinate and an anchor
// item position.
// 2) fill towards start, stacking from bottom
// 3) fill towards end, stacking from top
// 4) scroll to fulfill requirements like stack from bottom.
//新建一個LayoutState
ensureLayoutState();
if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION || mPendingSavedState != null) {
mAnchorInfo.reset();
//更新錨點信息
updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
mAnchorInfo.mValid = true;
}
//...計算LinearLayoutManager所需的額外空間
//錨點信息準備好了
onAnchorReady(recycler, state, mAnchorInfo, firstLayoutDirection);
//將現有的子View都緩存起來
detachAndScrapAttachedViews(recycler);
if (mAnchorInfo.mLayoutFromEnd){
//...
}else{
// fill towards end
//將錨點的信息保存到mLayoutState中
updateLayoutStateToFillEnd(mAnchorInfo);
fill(recycler, mLayoutState, state, false);
// fill towards start
updateLayoutStateToFillEnd(mAnchorInfo);
fill(recycler, mLayoutState, state, false);
if (mLayoutState.mAvailable > 0) {
fill(recycler, mLayoutState, state, false);
}
}
}
複製代碼
以上代碼省略了不少有用信息,包括對LayoutState內部一些有用屬性的賦值等。由代碼剛開始的註釋可瞭解到該方法內部執行邏輯
關鍵是錨點,對於LinearLayoutManager
來講,它不必定是從最高點或者最低點開始佈局,有多是從中間某個點開始佈局的,如圖所示
updateAnchorInfoForLayout(recycler, state, mAnchorInfo)
更新錨點信息private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state, AnchorInfo anchorInfo) {
// 第一種計算方式
if (updateAnchorFromPendingData(state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from pending information");
}
return;
}
// 第二種計算方式
if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from existing children");
}
return;
}
if (DEBUG) {
Log.d(TAG, "deciding anchor info for fresh state");
}
// 第三種計算方式
anchorInfo.assignCoordinateFromPadding();
anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
}
複製代碼
能夠看出來有三種計算錨點信息的方法,每一個方法裏的代碼雖然多卻不難理解
private boolean updateAnchorFromPendingData(RecyclerView.State state, AnchorInfo anchorInfo) {
if (state.isPreLayout() || mPendingScrollPosition == RecyclerView.NO_POSITION) {
return false;
}
//...
}
複製代碼
mPendingScrollPosition == RecyclerView.NO_POSITION
這個條件判斷默認狀況是true的,只有在調用scrollToPosition
或者在onRestoreInstanceState
恢復以前記錄的mPendingScrollPosition
時纔會有其餘值(上面省略代碼就是計算mPendingScrollPosition
不爲默認值的錨點信息,本文沒有分析),因此默認狀況該方法沒有計算錨點信息,往下走
updateAnchorFromChildren
這個方法根據子View來肯定錨點信息
false
,表示沒有計算出錨點信息anchorInfo.mCoordinate
被賦值爲1號子View上面的Decor的頂部位置該方法的詳細分析能夠看這篇文章RecyclerView源碼解析
最後兜底的方法,直接將anchorInfo.mCoordinate
賦值爲padding
,若是沒有設置padding
,則anchorInfo.mCoordinate = 0
,anchorInfo.mPosition = 0
(mStackFromEnd == false
的狀況,該值默認是false)
錨點信息的計算主要是爲
mPosition
、mCoordinate
這兩個變量賦值,這樣咱們就知道了從哪一個點開始填充子View和子View對應的數據在Adapter
中的位置
更新錨點信息以後,源碼中有一長段代碼用來計算LinearLayoutManager
須要的「額外空間」,這段代碼我也沒懂,就不分析了,不過並不影響咱們把握總體佈局流程。錨點信息都準備好以後,updateLayoutStateToFillEnd()
將錨點信息保存到mLayoutState
中,而後調用fill()
方法開始填充子View了,mAnchorInfo.mLayoutFromEnd
將填充分爲兩種狀況
true
: 從Adapter
最後一項開始,從下往上佈局false
: 從Adapter
第一項開始,從上往下佈局(默認狀況) 如圖所示(虛線表示屏幕外邊的ItemView
)
默認狀況爲false
,從上往下開始佈局,而後進入關鍵的fill()
方法
int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
//可用空間
final int start = layoutState.mAvailable;
int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
//保存了每一子View消耗的空間
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
//循環佈局子View
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
//核心方法
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (layoutChunkResult.mFinished) {
break;
}
layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
if (!layoutChunkResult.mIgnoreConsumed || mLayoutState.mScrapList != null
|| !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
// we keep a separate remaining space because mAvailable is important for recycling
//消費子View佔據的空間
remainingSpace -= layoutChunkResult.mConsumed;
}
//...
}
return start - layoutState.mAvailable;
}
複製代碼
fill
的核心思路就是在一個循環裏不斷地進行子View佈局,結束條件是沒有可用空間或者數據源沒有數據了,layoutChunk
負責填充,每填充一個子View,剩餘空間就減相對應View佔據的空間(豎直方向上來講就是高度),而後填充下一個,最後返回的是本次佈局所填充的區域大小。
咱們進入layoutChunk
來看具體的實現
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result) {
//獲取一個子View
View view = layoutState.next(recycler);
if (view == null) {
if (DEBUG && layoutState.mScrapList == null) {
throw new RuntimeException("received null view when unexpected");
}
// if we are laying out views in scrap, this may return null which means there is
// no more items to layout.
result.mFinished = true;
return;
}
//將View添加到RecyclerView中
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
if (layoutState.mScrapList == null) {
if (mShouldReverseLayout == (layoutState.mLayoutDirection
== LayoutState.LAYOUT_START)) {
addView(view);
} else {
addView(view, 0);
}
} else {
if (mShouldReverseLayout == (layoutState.mLayoutDirection
== LayoutState.LAYOUT_START)) {
addDisappearingView(view);
} else {
addDisappearingView(view, 0);
}
}
//測量子View的大小包括margins和decorations
measureChildWithMargins(view, 0, 0);
//將佔據的空間保存到LayoutChunkResult之中,供外層循環使用
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
int left, top, right, bottom;
if (mOrientation == VERTICAL) {
if (isLayoutRTL()) {
right = getWidth() - getPaddingRight();
left = right - mOrientationHelper.getDecoratedMeasurementInOther(view);
} else {
left = getPaddingLeft();
right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
}
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
bottom = layoutState.mOffset;
top = layoutState.mOffset - result.mConsumed;
} else {
top = layoutState.mOffset;
bottom = layoutState.mOffset + result.mConsumed;
}
} else {
top = getPaddingTop();
bottom = top + mOrientationHelper.getDecoratedMeasurementInOther(view);
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
right = layoutState.mOffset;
left = layoutState.mOffset - result.mConsumed;
} else {
left = layoutState.mOffset;
right = layoutState.mOffset + result.mConsumed;
}
}
// We calculate everything with View's bounding box (which includes decor and margins)
// To calculate correct layout position, we subtract margins.
//將子View放到合適的位置
layoutDecoratedWithMargins(view, left, top, right, bottom);
if (DEBUG) {
Log.d(TAG, "laid out child at position " + getPosition(view) + ", with l:"
+ (left + params.leftMargin) + ", t:" + (top + params.topMargin) + ", r:"
+ (right - params.rightMargin) + ", b:" + (bottom - params.bottomMargin));
}
// Consume the available space if the view is not removed OR changed
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
result.mFocusable = view.hasFocusable();
}
複製代碼
View view = layoutState.next(recycler)
獲取一個itemView
,這裏涉及到RecyclerView
的緩存機制,咱們後邊的篇章在討論。itemView
添加到RecyclerView
中,又分爲兩種狀況
addView
很好理解,方法內部作了一些位置正確性、避免重複添加等邏輯判斷,而後調用ViewGroup
的addView
來實現。addDisappearingView
表明該View即將從屏幕上消失,好比劃出屏幕或者調用Adapter.notifyItemRemoved
,該方法和上面的addView
都會調用內部的addViewInt(View child, int index, boolean disappearing)
,只不過是最後一個參數不同而已。itemView
的大小,measureChildWithMargins(view, 0, 0)
這個方法內部除了自身大小以外,還須要考慮margin
和decorations
(咱們常說的分割線)的大小。測量以後把消耗的空間保存到LayoutChunkResult
之中,供外層循環使用。itemView
放到合適的位置,計算位置時layoutState.mOffset
跟咱們以前算的錨點座標息息相關,若是是第一個itemView
,則layoutState.mOffset
和錨點座標是同樣的,你們能夠經過調試來觀察數據對應關係。固然佈局時還了考慮margin
和decorations
(咱們常說的分割線)以上將fill()
方法分析完成以後,LinearLayoutManager.onLayoutChildren
的核心 已經分析完畢了,最後還有一個layoutForPredictiveAnimations
,從該方法的註釋來看,是爲了動畫作一些佈局,也不是必須執行的,就再也不分析了,若是有讀者清楚這塊內容的,但願能告知我一下。
至此,LinearLayoutManager.onLayoutChildren
分析完畢,可是該方法註釋的最後一條,貼一下原文 4) scroll to fulfill requirements like stack from bottom.
我並無看到它體如今哪,多是上文省略的一些細節中包含,總之這一點我並不明白,若是有讀者清楚,但願能告知我一下。
RecyclerView
的繪製流程咱們分析完了,總結一下
onMeasure
跟LayoutManager
是否開啓自動測量是有關係的,若是支持自動測量的話,可能會進行預佈局,默認實現的三個LayoutManager
都是支持自動測量的,若是自定義LayoutManager
的話要注意這一點onLayout
中主要是dispatchLayoutStep1()
、dispatchLayoutStep1()
、dispatchLayoutStep1()
這三個方法按順序調用,第一個和第三個主要處理了動畫相關,第二個將佈局的任務交給LayoutManager
draw
和onDraw
調用了ItemDecoration
中的方法,咱們實現這些方法來自定義分割線
最後,因爲做者水平有限,若是以上分析有出錯的地方,歡迎提出,我及時進行改正,以避免誤導其餘人
相關資料