Android角落 不妨再看LinearLayout
本文首發于github,是我所在的組LittleFriendsGroup的一個源碼分析項目哦,如果您感興趣,也可以去認領一篇文章寫寫你的觀點。
聲明.本項目源碼基于Api 23
1.談談LinearLayout
Android的常用布局里,LinearLayout屬于使用頻率很高的布局。RelativeLayout也是,但相比于RelativeLayout每個子控件都需要給上ID以供另一個相關控件擺放位置來說,LinearLayout兩個方向上的排列規則在明顯垂直/水平排列情況下使用更加方便。
同時,出于性能上來說,一般而言功能越復雜的布局,性能也是越低的(不考慮嵌套的情況下)。
相比于RelativeLayout無論如何都是兩次測量的情況下,LinearLayout只有子控件設置了weight屬性時,才會有二次測量,其余情況都是一次。
另外,LinearLayout的高級用法除了weight,還有divider,baselineAligned等用法,雖然用的不常見就是了。
以下是LinearLayout相比于其他布局所擁有的特性:
| orientation | int | 作為LinearLayout必須使用的屬性之一,支持縱向排布或者水平排布子控件 | |
| weightSum | float | 指定權重總和 | 缺省值為1.0 |
| baselineAligned | boolean | 基線對齊 | |
| baselineAlignedChildIndex | int | 該LinearLayout下的view以某個繼承TextView的View的基線對齊 | |
| measureWithLargestChild | boolean | 當值為true,所有帶權重屬性的View都會使用最大View的最小尺寸 | |
| divider(需要配合showDividers使用) | drawable in java/reference in xml | 如同您常在ListView使用一樣,為LinearLayout添加分割線 | [api>11] 同時如果是自己建立的drawable,請指定size |
【注意】divider附加屬性為showDividers(middle|end|beginning|none):
- middle 在每兩項之間添加分割線
- end 在整體的最后一項添加分割線
- beginning 在整體的首項添加分割線
- none 無
本篇主要針對LinearLayout垂直方向的測量、weight和divider進行分析,其余屬性因為比較冷門,因此不會詳說
2.使用方法
對于LinearLayout的使用,相信您閉著眼睛都能寫出來,因此這里就略過了。
3.源碼分析
源碼分析階段主要針對這幾個地方:
- measure流程
- weight的計算
后兩者的主要工作其實都是被包含在measure里面的,因此對于LinearLayout來說,最重要的,依然是measure.
3.1 measure
在LinearLayout的onMeasure()里面,所有的測量都根據mOrientation這個int值來進行水平或者垂直的測量計算。
我們都知道,java中int在初始化不分配值的時候,都是默認的0,因此如果我們不指定orientation,measure則會按照水平方向來測量【水平orientation=0/垂直orientation=1】
接下來我們主要看看measureVertical方法,了解了垂直方向的測量之后,水平方向的也就不難理解了,為了篇幅,我們主要分析垂直方向的測量。
measureVertical方法除去注釋,大概200多行,因此我們分段分析。
方法主要分為三大塊:
- 一大堆變量
- 一個主要的for循環來不斷測量子控件
- 其余參數影響以及根據是否有weight再次測量
3.1.1
一大堆變量
為何這里要說說變量,因為這些變量都會極大的影響到后面的測量,同時也是十分容易混淆的,所以這里需要貼一下。
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {// mTotalLength作為LinearLayout成員變量,其主要目的是在測量的時候通過累加得到所有子控件的高度和(Vertical)或者寬度和(Horizontal)mTotalLength = 0;// maxWidth用來記錄所有子控件中控件寬度最大的值。int maxWidth = 0;// 子控件的測量狀態,會在遍歷子控件測量的時候通過combineMeasuredStates來合并上一個子控件測量狀態與當前遍歷到的子控件的測量狀態,采取的是按位相或int childState = 0;/*** 以下兩個最大寬度跟上面的maxWidth最大的區別在于matchWidthLocally這個參數* 當matchWidthLocally為真,那么以下兩個變量只會跟當前子控件的左右margin和相比較取大值* 否則,則跟maxWidth的計算方法一樣*/// 子控件中layout_weight<=0的View的最大寬度int alternativeMaxWidth = 0;// 子控件中layout_weight>0的View的最大寬度int weightedMaxWidth = 0;// 是否子控件全是match_parent的標志位,用于判斷是否需要重新測量boolean allFillParent = true;// 所有子控件的weight之和float totalWeight = 0;// 如您所見,得到所有子控件的數量,準確的說,它得到的是所有同級子控件的數量// 在官方的注釋中也有著對應的例子// 比如TableRow,假如TableRow里面有N個控件,而LinearLayout(TableLayout也是繼承LinearLayout哦)下有M個TableRow,那么這里返回的是M,而非M*N// 但實際上,官方似乎也只是直接返回getChildCount(),起這個方法名的原因估計是為了讓人更加的明白,畢竟如果是getChildCount()可能會讓人誤認為為什么沒有返回所有(包括不同級)的子控件數量final int count = getVirtualChildCount();// 得到測量模式final int widthMode = MeasureSpec.getMode(widthMeasureSpec);final int heightMode = MeasureSpec.getMode(heightMeasureSpec);// 當子控件為match_parent的時候,該值為ture,同時判定的還有上面所說的matchWidthLocally,這個變量決定了子控件的測量是父控件干預還是填充父控件(剩余的空白位置)。boolean matchWidth = false;boolean skippedMeasure = false;final int baselineChildIndex = mBaselineAlignedChildIndex; final boolean useLargestChild = mUseLargestChild;int largestChildHeight = Integer.MIN_VALUE;} 復制代碼這里有很多變量和值,事實上,直到現在,我依然沒有完全弄明白這些值的意義。
在這一大堆變量里面,我們主要留意的是三個方面:
- mTotalLength:這個就是最終得到的整個LinearLayout的高度(子控件高度累加及自身padding)
- 三個跟width相關的變量
- weight相關的變量
3.1.2
測量
通過for循環不斷的得到子控件然后根據自己的定義進行賦值,這就是LinearLayout測量里面最重要的一步。
這里的代碼比較長,去掉注釋后有100行左右,因此這里采取重要地方注釋結合文字描述來分析。
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {// ...接上面的一大堆變量for (int i = 0; i < count; ++i) {final View child = getVirtualChildAt(i);if (child == null) {// 目前而言,measureNullChild()方法返回的永遠是0,估計是設計者留下來以后或許有補充的。mTotalLength += measureNullChild(i);continue;}if (child.getVisibility() == GONE) {// 同上,返回的都是0。// 事實上這里的意思應該是當前遍歷到的View為Gone的時候,就跳過這個View,下一句的continue關鍵字也正是這個意思。// 忽略當前的View,這也就是為什么Gone的控件不占用布局資源的原因。(畢竟根本沒有分配空間)i += getChildrenSkipCount(child, i);continue;}// 根據showDivider的值(before/middle/end)來決定遍歷到當前子控件時,高度是否需要加上divider的高度// 比如showDivider為before,那么只會在第0個子控件測量時加上divider高度,其余情況下都不加if (hasDividerBeforeChildAt(i)) {mTotalLength += mDividerWidth;}final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams)child.getLayoutParams();// 得到每個子控件的LayoutParams后,累加權重和,后面用于跟weightSum相比較totalWeight += lp.weight;// 我們都知道,測量模式有三種:// * UNSPECIFIED:父控件對子控件無約束// * Exactly:父控件對子控件強約束,子控件永遠在父控件邊界內,越界則裁剪。如果要記憶的話,可以記憶為有對應的具體數值或者是Match_parent// * AT_Most:子控件為wrap_content的時候,測量值為AT_MOST。// 下面的if/else分支都是跟weight相關if (heightMode == MeasureSpec.EXACTLY && lp.height == 0 && lp.weight > 0) {// 這個if里面需要滿足三個條件:// * LinearLayout的高度為match_parent(或者有具體值)// * 子控件的高度為0// * 子控件的weight>0// 這其實就是我們通常情況下用weight時的寫法// 測量到這里的時候,會給個標志位,稍后再處理。此時會計算總高度final int totalLength = mTotalLength;mTotalLength = Math.max(totalLength, totalLength + lp.topMargin + lp.bottomMargin);skippedMeasure = true;} else {// 到這個分支,則需要對不同的情況進行測量int oldHeight = Integer.MIN_VALUE;if (lp.height == 0 && lp.weight > 0) {// 滿足這兩個條件,意味著父類即LinearLayout是wrap_content,或者mode為UNSPECIFIED// 那么此時將當前子控件的高度置為wrap_content// 為何需要這么做,主要是因為當父類為wrap_content時,其大小實際上由子控件控制// 我們都知道,自定義控件的時候,通常我們會指定測量模式為wrap_content時的默認大小// 這里強制給定為wrap_content為的就是防止子控件高度為0.oldHeight = 0;lp.height = LayoutParams.WRAP_CONTENT;}/**【1】*/// 下面這句雖然最終調用的是ViewGroup通用的同名方法,但傳入的height值是跟平時不一樣的// 這里可以看到,傳入的height是跟weight有關,關于這里,稍后的文字描述會著重闡述measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec,totalWeight == 0 ? mTotalLength : 0);// 重置子控件高度,然后進行精確賦值if (oldHeight != Integer.MIN_VALUE) {lp.height = oldHeight;}final int childHeight = child.getMeasuredHeight();final int totalLength = mTotalLength;// getNextLocationOffset返回的永遠是0,因此這里實際上是比較child測量前后的總高度,取大值。mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin +lp.bottomMargin + getNextLocationOffset(child));if (useLargestChild) {largestChildHeight = Math.max(childHeight, largestChildHeight);}}if ((baselineChildIndex >= 0) && (baselineChildIndex == i + 1)) {mBaselineChildTop = mTotalLength;}if (i < baselineChildIndex && lp.weight > 0) {throw new RuntimeException("A child of LinearLayout with index "+ "less than mBaselineAlignedChildIndex has weight > 0, which "+ "won't work. Either remove the weight, or don't set "+ "mBaselineAlignedChildIndex.");}boolean matchWidthLocally = false;// 還記得我們變量里又說到過matchWidthLocally這個東東嗎// 當父類(LinearLayout)不是match_parent或者精確值的時候,但子控件卻是一個match_parent// 那么matchWidthLocally和matchWidth置為true// 意味著這個控件將會占據父類(水平方向)的所有空間if (widthMode != MeasureSpec.EXACTLY && lp.width == LayoutParams.MATCH_PARENT) {matchWidth = true;matchWidthLocally = true;}final int margin = lp.leftMargin + lp.rightMargin;final int measuredWidth = child.getMeasuredWidth() + margin;maxWidth = Math.max(maxWidth, measuredWidth);childState = combineMeasuredStates(childState, child.getMeasuredState());allFillParent = allFillParent && lp.width == LayoutParams.MATCH_PARENT;if (lp.weight > 0) {weightedMaxWidth = Math.max(weightedMaxWidth,matchWidthLocally ? margin : measuredWidth);} else {alternativeMaxWidth = Math.max(alternativeMaxWidth,matchWidthLocally ? margin : measuredWidth);}i += getChildrenSkipCount(child, i);}} 復制代碼在代碼中我注釋了一部分,其中最值得注意的是measureChildBeforeLayout()方法。這個方法將會決定子控件可用的剩余分配空間。
measureChildBeforeLayout()最終調用的實際上是ViewGroup的measureChildWithMargins(),不同的是,在傳入高度值的時候(垂直測量情況下),會對weight進行一下判定
假如當前子控件的weight加起來還是為0,則說明在當前子控件之前還沒有遇到有weight的子控件,那么LinearLayout將會進行正常的測量,若之前遇到過有weight的子控件,那么LinearLayout傳入0。
那么measureChildWithMargins()的最后一個參數,也就是LinearLayout在這里傳入的這個高度值是用來干嘛的呢?
如果我們追溯下去,就會發現,這個函數最終其實是為了結合父類的MeasureSpec以及child自身的LayoutParams來對子控件測量。而最后傳入的值,在子控件測量的時候被添加進去。
protected void measureChildWithMargins(View child,int parentWidthMeasureSpec, int widthUsed,int parentHeightMeasureSpec, int heightUsed) {final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin+ widthUsed, lp.width);final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin+ heightUsed, lp.height);child.measure(childWidthMeasureSpec, childHeightMeasureSpec);} 復制代碼在官方的注釋中,我們可以看到這么一句:
- @param heightUsed Extra space that has been used up by the parent vertically (possibly by other children of the parent)
事實上,我們在代碼中也可以很清晰的看到,在getChildMeasureSpec()中,子控件需要把父控件的padding,自身的margin以及一個可調節的量三者一起測量出自身的大小。
那么假如在測量某個子控件之前,weight一直都是0,那么該控件在測量時,需要考慮在本控件之前的總高度,來根據剩余控件分配自身大小。而如果有weight,那么就不考慮已經被占用的控件,因為有了weight,子控件的高度將會在后面重新賦值。
3.2 weight
3.2.1
weight的再次測量
在上面的代碼中,LinearLayout做了針對沒有weight的工作,在這里主要是確定自身的大小,然后再針對weight進行第二次測量來確定子控件的大小。
我們接著看下面的代碼:
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {//...接上面// 下面的這一段代碼主要是為useLargestChild屬性服務的,不在本文主要分析范圍,略過if (mTotalLength > 0 && hasDividerBeforeChildAt(count)) {mTotalLength += mDividerHeight;}if (useLargestChild &&(heightMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.UNSPECIFIED)) {mTotalLength = 0;for (int i = 0; i < count; ++i) {final View child = getVirtualChildAt(i);if (child == null) {mTotalLength += measureNullChild(i);continue;}if (child.getVisibility() == GONE) {i += getChildrenSkipCount(child, i);continue;}final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams)child.getLayoutParams();// Account for negative marginsfinal int totalLength = mTotalLength;mTotalLength = Math.max(totalLength, totalLength + largestChildHeight +lp.topMargin + lp.bottomMargin + getNextLocationOffset(child));}}// Add in our paddingmTotalLength += mPaddingTop + mPaddingBottom;int heightSize = mTotalLength;// Check against our minimum heightheightSize = Math.max(heightSize, getSuggestedMinimumHeight());// Reconcile our calculated size with the heightMeasureSpecint heightSizeAndState = resolveSizeAndState(heightSize, heightMeasureSpec, 0);heightSize = heightSizeAndState & MEASURED_SIZE_MASK;} 復制代碼上面這里是為weight情況做的預處理。
我們略過useLargestChild 的情況,主要看看if處理外的代碼。在這里,我沒有去掉官方的注釋,而是保留了下來。
從中我們不難看出heightSize做了兩次賦值,為何需要做兩次賦值。
因為我們的布局除了子控件,還有自己本身的background,因此這里需要比較當前的子控件的總高度和背景的高度取大值。
接下來就是判定大小,我們都知道測量的MeasureSpec實際上是一個32位的int,高兩位是測量模式,剩下的就是大小,因此heightSize = heightSizeAndState & MEASURED_SIZE_MASK;作用就是用來得到大小的精確值(不含測量模式)
接下來我們看這個方法里面第二占比最大的代碼:
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {//...接上面//算出剩余空間,假如之前是skipp的話,那么幾乎可以肯定是有剩余空間(同時有weight)的int delta = heightSize - mTotalLength;if (skippedMeasure || delta != 0 && totalWeight > 0.0f) {// 限定weight總和范圍,假如我們給過weighSum范圍,那么子控件的weight總和受此影響float weightSum = mWeightSum > 0.0f ? mWeightSum : totalWeight;mTotalLength = 0;for (int i = 0; i < count; ++i) {final View child = getVirtualChildAt(i);if (child.getVisibility() == View.GONE) {continue;}LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) child.getLayoutParams();float childExtra = lp.weight;if (childExtra > 0) {// 全篇最精華的一個地方。。。。擁有weight的時候計算方式,ps:執行到這里時,child依然還沒進行自身的measure// 公式 = 剩余高度*(子控件的weight/weightSum),也就是子控件的weight占比*剩余高度int share = (int) (childExtra * delta / weightSum);// weightSum計余weightSum -= childExtra;// 剩余高度delta -= share;final int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,mPaddingLeft + mPaddingRight +lp.leftMargin + lp.rightMargin, lp.width);if ((lp.height != 0) || (heightMode != MeasureSpec.EXACTLY)) {int childHeight = child.getMeasuredHeight() + share;if (childHeight < 0) {childHeight = 0;}child.measure(childWidthMeasureSpec,MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY));} else {child.measure(childWidthMeasureSpec,MeasureSpec.makeMeasureSpec(share > 0 ? share : 0,MeasureSpec.EXACTLY));}childState = combineMeasuredStates(childState, child.getMeasuredState()& (MEASURED_STATE_MASK>>MEASURED_HEIGHT_STATE_SHIFT));}final int margin = lp.leftMargin + lp.rightMargin;final int measuredWidth = child.getMeasuredWidth() + margin;maxWidth = Math.max(maxWidth, measuredWidth);boolean matchWidthLocally = widthMode != MeasureSpec.EXACTLY &&lp.width == LayoutParams.MATCH_PARENT;alternativeMaxWidth = Math.max(alternativeMaxWidth,matchWidthLocally ? margin : measuredWidth);allFillParent = allFillParent && lp.width == LayoutParams.MATCH_PARENT;final int totalLength = mTotalLength;mTotalLength = Math.max(totalLength, totalLength + child.getMeasuredHeight() +lp.topMargin + lp.bottomMargin + getNextLocationOffset(child));}mTotalLength += mPaddingTop + mPaddingBottom;} // 沒有weight的情況下,只看useLargestChild參數,如果都無相關,那就走layout流程了,因此這里忽略else {alternativeMaxWidth = Math.max(alternativeMaxWidth,weightedMaxWidth);if (useLargestChild && heightMode != MeasureSpec.EXACTLY) {for (int i = 0; i < count; i++) {final View child = getVirtualChildAt(i);if (child == null || child.getVisibility() == View.GONE) {continue;}final LinearLayout.LayoutParams lp =(LinearLayout.LayoutParams) child.getLayoutParams();float childExtra = lp.weight;if (childExtra > 0) {child.measure(MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(largestChildHeight,MeasureSpec.EXACTLY));}}}} } 復制代碼3.2.2
weight的兩種情況
這次我的注釋比較少,主要是因為需要有一大段的文字來描述。
在weight計算方面,我們可以清晰的看到,weight為何是針對剩余空間進行分配的原理了。 我們打個比方,假如現在我們的LinearLayout的weightSum=10,總高度100,有兩個子控件(他們的height=0dp),他們的weight分別為2:8。
那么在測量第一個子控件的時候,可用的剩余高度為100,第一個子控件的高度則是100*(2/10)=20,接下來可用的剩余高度為80
我們繼續第二個控件的測量,此時它的高度實質上是80*(8/8)=80
到目前為止,看起來似乎都是正確的,但關于weight我們一直有一個疑問:**就是我們為子控件給定height=0dp和height=match_parent時我們就會發現我們的子控件的高度比是不同的,前者是2:8而后者是調轉過來變成8:2 **
對于這個問題,我們不妨繼續看看代碼。
接下來我們會看到這么一個分支:
if ((lp.height != 0) || (heightMode != MeasureSpec.EXACTLY)) { } else {}
首先我們不管heightMode,也就是父類的測量模式,剩下一個判定條件就是lp.height,也就是子類的高度。
既然有針對這個進行判定,那就是意味著肯定在此之前對child進行過measure,事實上,在這里我們一早就對這個地方進行過描述,這個方法正是measureChildBeforeLayout()。
還記得我們的measureChildBeforeLayout()執行的先行條件嗎
YA,just u see,正是不滿足(LinearLayout的測量模式非EXACTLY/child.height==0/child.weight/child.weight>0)之中的child.height==0
因為除非我們指定height=0,否則match_parent是等于-1,wrap_content是等于-2.
在執行measureChildBeforeLayout(),由于我們的child的height=match_parent,因此此時可用空間實質上是整個LinearLayout,執行了measureChildBeforeLayout()后,此時的mTotalLength是整個LinearLayout的大小
回到我們的例子,假設我們的LinearLayout高度為100,兩個child的高度都是match_parent,那么執行了measureChildBeforeLayout()后,我們兩個子控件的高度都將會是這樣:
child_1.height=100
child_2.height=100
mTotalLength=100+100=200
在一系列的for之后,執行到我們剩余空間:
int delta = heightSize - mTotalLength;
(delta=100[linearlayout的實際高度]-200=-100)
沒錯,你看到的的確是一個負數。
接下來就是套用weight的計算公式:
share=(int) (childExtra * delta / weightSum)
即:share=-100(2/10)=-20;*
然后走到我們所說的if/else里面
if ((lp.height != 0) || (heightMode != MeasureSpec.EXACTLY)) {// child was measured once already above...// base new measurement on stored valuesint childHeight = child.getMeasuredHeight() + share;if (childHeight < 0) {childHeight = 0;}child.measure(childWidthMeasureSpec,MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY));} 復制代碼我們知道**child.getMeasuredHeight()=100**
接著這里有一條int childHeight = child.getMeasuredHeight() + share;
這意味著我們的**childHeight=100+(-20)=80;**
接下來就是走child.measure,并把childHeight傳進去,因此最終反饋到界面上,我們就會發現,在兩個match_parent的子控件中,weight的比是反轉的。
接下來沒什么分析的,剩下的就是走layout流程了,對于layout方面,要講的其實沒什么東西,畢竟基本都是模板化的寫法了。
4.小結
在這里,我們花費了大篇幅講解measureVertical()的流程,事實上對于LinearLayout來說,其最大的特性也正是兩個方向的排布以及weight的計算方式。
在這里我們不妨回過頭看一下,其實我們會發現在測量過程中,設計者總是有意分開含有weight和不含有weight的測量方式,同時利用height跟0比較來更加的細分每一種情況。
可能初看的時候覺得代碼太多,事實上一輪分析下來,方向還是很清晰的。畢竟有weight的地方前期都給個標志跳過,在測量完需要的數據(比如父控件的總高度什么的)后,再根據父控件的數據和weight再針對進行二次測量。
在文章的最后,我們小結一下對于測量這里的算法的不同情況下的區別以及原理:
-
父控件是match_parent(或者精確值),子控件擁有weight,并且高度給定為0:
- 子控件的高度比例將會跟我們分配的layout_weight一致,原因在于weight二次測量時走了else分支,傳入的是計算出來的share值
-
父控件是match_parent(或者精確值),子控件擁有weight,但高度給定為match_parent(或者精確值):
- 子控件高度比例將會跟我們分配的layout_weight相反,原因在于在此之前子控件測量過一次,同時子控件的測量高度為父控件的高度,在計算剩余空間的時候得出一個負值,加上自身的測量高度的時候反而更小
-
父控件是wrap_content,子控件擁有weight:
- 子控件的高度將會強行置為其wrap_content給的值并以wrap_content模式進行測量
-
父控件是wrap_content,子控件沒有weight:
- 子控件的高度跟其他的viewgroup一致
至此,LinearLayout針對measure的解析到此結束
感謝您的觀閱讀。
因為本人能力經驗有限,有些地方可能分析錯誤,如果您發現了,在下非常歡迎督促指正喲。
簡書:羽翼君
總結
以上是生活随笔為你收集整理的Android角落 不妨再看LinearLayout的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: mybatis+dubbo+ sprin
- 下一篇: .NET分布式缓存Redis从入门到实战