在Android的知识体系中,View扮演着很重要的的角色,简单来理解,View就是Android在视觉上的呈现。在界面上Android提供了一套GUI库,里面有很多控件,但很多时候系统提供的控件都不能很好的满足我们的需求,这时候就需要自定义View了,但仅仅了解基本控件的使用是无法做出复杂的自定义控件的。为所有了更好的自定义View,就需要掌握View的底层工作原理,比如View的测量、布局以及绘制流程,掌握这几个流程后,基本上就可以做出一个比较完善的自定义View了。
1、初始ViewRoot和DecorView
ViewRoot对应ViewRootImpl,它是连接WindowManager(实现类是WindowManagerImpl)和DecorView的纽带,View绘制的三大流程均是通过ViewRoot来完成的。那么一个activity是何时开始绘制的尼?当创建activity成功并且onResume方法调用后,就会将DecorView添加进WindowManager中。代码如下:
final void handleResumeActivity(IBinder token,
boolean clearHide, boolean isForward, boolean reallyResume, int seq, String reason) {
...
// TODO Push resumeArgs into the activity for consideration
//回调activity的onResume方法
r = performResumeActivity(token, clearHide, reason);
if (r != null) {
...
if (r.window == null && !a.mFinished && willBeVisible) {
//拿到activity对应的PhoneWindow
r.window = r.activity.getWindow();
//拿到activity的根View->decorView
View decor = r.window.getDecorView();
//隐藏decorView
decor.setVisibility(View.INVISIBLE);
//拿到WindowManager->WindowManagerImpl
ViewManager wm = a.getWindowManager();
...
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
a.mWindowAdded = true;
//将根布局添加到WindowManager中
wm.addView(decor, l);
} else {
...
}
}
...
// The window is now visible if it has been added, we are not
// simply finishing, and we are not starting another activity.
if (!r.activity.mFinished && willBeVisible
&& r.activity.mDecor != null && !r.hideForNow) {
...
WindowManager.LayoutParams l = r.window.getAttributes();
if ((l.softInputMode
& WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION)
!= forwardBit) {
...
if (r.activity.mVisibleFromClient) {
ViewManager wm = a.getWindowManager();
View decor = r.window.getDecorView();
//更新Window,重新测量、摆放、绘制界面
wm.updateViewLayout(decor, l);
}
}
...
}
...
} else {
//出错则关闭当前activity
try {
ActivityManager.getService()
.finishActivity(token, Activity.RESULT_CANCELED, null,
Activity.DONT_FINISH_TASK_WITH_ACTIVITY);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
}
在调用wm.addView(decor, l);
中,就会去创建ViewRootImpl,然后在ViewRootImpl中进行绘制。代码如下:
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
...
root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
// do this last because it fires off messages to start doing things
try {
//交给ViewRootImpl继续执行
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
if (index >= 0) {
removeViewLocked(index, true);
}
throw e;
}
}
}
在ViewRootImpl中,在正式向WMS添加Window之前,系统会调用requestLayout();
来对UI进行绘制,通过查看requestLayout();
可以发现系统最终调用的是performTraversals()
这个方法,在这个方法里调用了View的measure、layout、draw方法。代码如下:
private void performTraversals() {
...
if (mFirst || windowShouldResize || insetsChanged ||
viewVisibilityChanged || params != null || mForceNextWindowRelayout) {
...
if (!mStopped || mReportNextDraw) {
boolean focusChangedDueToTouchMode = ensureTouchModeLocally(
(relayoutResult&WindowManagerGlobal.RELAYOUT_RES_IN_TOUCH_MODE) != 0);
if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth()
|| mHeight != host.getMeasuredHeight() || contentInsetsChanged ||
updatedConfiguration) {
int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
// Implementation of weights from WindowManager.LayoutParams
// We just grow the dimensions as needed and re-measure if
// needs be
int width = host.getMeasuredWidth();
int height = host.getMeasuredHeight();
boolean measureAgain = false;
if (lp.horizontalWeight > 0.0f) {
width += (int) ((mWidth - width) * lp.horizontalWeight);
childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width,
MeasureSpec.EXACTLY);
measureAgain = true;
}
if (lp.verticalWeight > 0.0f) {
height += (int) ((mHeight - height) * lp.verticalWeight);
childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height,
MeasureSpec.EXACTLY);
measureAgain = true;
}
if (measureAgain) {
if (DEBUG_LAYOUT) Log.v(mTag,
"And hey let's measure once more: width=" + width
+ " height=" + height);
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
}
layoutRequested = true;
}
}
} else {
...
}
final boolean didLayout = layoutRequested && (!mStopped || mReportNextDraw);
boolean triggerGlobalLayoutListener = didLayout
|| mAttachInfo.mRecomputeGlobalAttributes;
if (didLayout) {
performLayout(lp, mWidth, mHeight);
}
...
if (!cancelDraw && !newSurface) {
if (mPendingTransitions != null && mPendingTransitions.size() > 0) {
for (int i = 0; i < mPendingTransitions.size(); ++i) {
mPendingTransitions.get(i).startChangingAnimations();
}
mPendingTransitions.clear();
}
performDraw();
} else {
...
}
mIsInTraversal = false;
}
对于代码中performMeasure
、performLayout
、performDraw
这三个方法有没有一点熟悉?,没错,它们就对应着View的measure、layout、draw,到这里就开始真正的绘制UI了。嗯,先来梳理一下从activity的onResume到开始绘制UI的流程,如下:
2、理解MeasureSpec
在测量过程中,MeasureSpec非常重要,它代表一个32位的int值,高2位代表代表SpecMode,低30位代表SpecSize,SpecMode是指测量模式,而SpecSize则是指在某种测量模式下的大小。
SpecMode有三类,每一类都代表不同的含义,如下:
- UNSPECIFIED:父容器不对View有任何限制,要多大给多大。用的比较少,一般见于ScrollView,ListView(大小不确定,同时大小还是变的。会通过多次测量才能真正决定好宽高)等系统控件。
- EXACTLY:父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所指定的值。它对应LayoutParams中的match_parent和具体数值这两种模式。
- AT_MOST:父容器指定了一个可用大小即SpecSize,view的大小不能大于这个值,具体是什么要看不同View的具体实现。它对应LayoutParams的wrap_content。
MeasureSpec通过将SpecMode与SpecSize打包成一个int值来避免过多的内存对象分配,为了方便操作,提供了打包和解包的操作。
//解包
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
//打包
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(widthSize,childWidthMode);
嗯,来举个例子。当ScrollView嵌套ListView时,ListView只能显示一个item,这时候的解决方案基本上都是将所有item展示出来。如下:
public void onMeasure(){
//MeasureSpec打包操作
int expandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec, expandSpec);
}
那为什么这么写就能够展开所有item尼?因为在ListView的onMeasure中,当heightMode为MeasureSpec.AT_MOST时就会将所有的item高度相加。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// Sets up mListPadding
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int childWidth = 0;
int childHeight = 0;
int childState = 0;
mItemCount = mAdapter == null ? 0 : mAdapter.getCount();
if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED
|| heightMode == MeasureSpec.UNSPECIFIED)) {
//拿到第一个item的View
final View child = obtainView(0, mIsScrap);
// Lay out child directly against the parent measure spec so that
// we can obtain exected minimum width and height.
measureScrapChild(child, 0, widthMeasureSpec, heightSize);
//拿到第一个item的宽
childWidth = child.getMeasuredWidth();
//拿到第一个item的高
childHeight = child.getMeasuredHeight();
childState = combineMeasuredStates(childState, child.getMeasuredState());
if (recycleOnMeasure() && mRecycler.shouldRecycleViewType(
((LayoutParams) child.getLayoutParams()).viewType)) {
mRecycler.addScrapView(child, 0);
}
}
...
//当mode为MeasureSpec.UNSPECIFIED时高度则为第一个item的高度,而ScrollView、ListView等滑动组件在测量子View时,传入的类型就是MeasureSpec.UNSPECIFIED
if (heightMode == MeasureSpec.UNSPECIFIED) {
heightSize = mListPadding.top + mListPadding.bottom + childHeight +
getVerticalFadingEdgeLength() * 2;
}
//当传入类型为heightMode时则计算全部item高度,所有需要重写ListView的onMeasure并且传入类型为MeasureSpec.AT_MOST
if (heightMode == MeasureSpec.AT_MOST) {
// TODO: after first layout we should maybe start at the first visible position, not 0
heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
}
//传入测量出来的宽高
setMeasuredDimension(widthSize, heightSize);
mWidthMeasureSpec = widthMeasureSpec;
}
但是前面size时为什么是Integer.MAX_VALUE >> 2尼?按理说值Integer.MAX_VALUE就可以了。这是因为MeasureSpec代表一个32位的int值,高两位代表mode,如果直接与MeasureSpec.AT_MOST一起打包,mode就可能会变成其他类型。而Integer.MAX_VALUE >> 2后,高两位就变为00了,这样在跟MeasureSpec.AT_MOST一起打包,mode就不会变了,是MeasureSpec.AT_MOST。
//示例:
int 32位:010111100011100将这个数向右位移2位,则变成000101111000111
然后将000101111000111与MeasureSpec.AT_MOST打包这样在listView的onMeasure里拿到的mode就是MeasureSpec.AT_MOST类型了。
3、measure过程
在performTraversals()
这个方法中,首先调用了View的measure方法,在此方法里就完成了对自己的测量,如果是一个ViewGroup的话,除了完成自己的测量,还会在onMeasure里对子控件进行测量。
3.1、View的measure过程
View的测量过程由measure方法实现,这个方法是一个final类型的方法,意味着子类不能重写此方法,在此方法里调用了onMeasure方法,这个是我们自定义控件时重写的方法并在此方法里根据子控件的宽高来给控件设置宽高。
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
...
if (forceLayout || needsLayout) {
// first clears the measured dimension flag
mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;
resolveRtlPropertiesIfNeeded();
int cacheIndex = forceLayout ? -1 : mMeasureCache.indexOfKey(key);
//如果缓存不存在或者忽略缓存则调用onMeasure方法
if (cacheIndex < 0 || sIgnoreMeasureCache) {
// measure ourselves, this should set the measured dimension flag back
onMeasure(widthMeasureSpec, heightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
} else {
//直接从缓存里拿值
long value = mMeasureCache.valueAt(cacheIndex);
// Casting a long to int drops the high 32 bits, no mask needed
setMeasuredDimensionRaw((int) (value >> 32), (int) value);
mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
// flag not set, setMeasuredDimension() was not invoked, we raise
// an exception to warn the developer
if ((mPrivateFlags & PFLAG_MEASURED_DIMENSION_SET) != PFLAG_MEASURED_DIMENSION_SET) {
throw new IllegalStateException("View with id " + getId() + ": "
+ getClass().getName() + "#onMeasure() did not set the"
+ " measured dimension by calling"
+ " setMeasuredDimension()");
}
mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
}
mOldWidthMeasureSpec = widthMeasureSpec;
mOldHeightMeasureSpec = heightMeasureSpec;
//添加缓存
mMeasureCache.put(key, ((long) mMeasuredWidth) << 32 |
(long) mMeasuredHeight & 0xffffffffL); // suppress sign extension
}
在VIew的默认onMeasure里调用的是setMeasuredDimension方法,setMeasuredDimension就是给mMeasuredWidth与mMeasuredHeight赋值,一般情况下mMeasuredWidth与mMeasuredHeight的值就是控件真正的宽高了。
protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
boolean optical = isLayoutModeOptical(this);
if (optical != isLayoutModeOptical(mParent)) {
Insets insets = getOpticalInsets();
int opticalWidth = insets.left + insets.right;
int opticalHeight = insets.top + insets.bottom;
measuredWidth += optical ? opticalWidth : -opticalWidth;
measuredHeight += optical ? opticalHeight : -opticalHeight;
}
setMeasuredDimensionRaw(measuredWidth, measuredHeight);
}
private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
mMeasuredWidth = measuredWidth;
mMeasuredHeight = measuredHeight;
mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}
3.2、ViewGroup的measure过程
对于ViewGroup,它没有重写View的onMeasure方法,但是我们要基于ViewGroup做自定义控件时,一般都会重写onMeasure方法,否则可能会导致这个控件的wrap_content无法使用。在此方法里会去遍历所有子控件,然后对每个子控件进行测量。在测量子控件时必须调用子控件的measure方法,否则测量无效,最后根据Mode来判断是否将得到的宽高给这个控件。ViewGroup提供一个measureChild
方法,当然我们也可以自己来实现。
protected void measureChild(View child, int parentWidthMeasureSpec,
int parentHeightMeasureSpec) {
final LayoutParams lp = child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom, lp.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
补充一点,在测量过程中一般用的比较多的都是EXACTLY与AT_MOST这两种测量模式,那么UNSPECIFIED在那里有应用尼?系统控件里,那在那些系统控件中啊?嗯,那就是ListView与ScrollView中,在这两个控件测量子控件的过程中,都传递了UNSPECIFIED这个类型,首先来看ListView的测量子控件的代码。
private void measureScrapChild(View child, int position, int widthMeasureSpec, int heightHint) {
LayoutParams p = (LayoutParams) child.getLayoutParams();
if (p == null) {
p = (AbsListView.LayoutParams) generateDefaultLayoutParams();
child.setLayoutParams(p);
}
p.viewType = mAdapter.getItemViewType(position);
p.isEnabled = mAdapter.isEnabled(position);
p.forceAdd = true;
final int childWidthSpec = ViewGroup.getChildMeasureSpec(widthMeasureSpec,
mListPadding.left + mListPadding.right, p.width);
final int lpHeight = p.height;
final int childHeightSpec;
//当子控件的高设置为match_parent或者wrap_content时,拿到高度lpHeight是小于0的,所以会走else
if (lpHeight > 0) {
childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
} else {
childHeightSpec = MeasureSpec.makeSafeMeasureSpec(heightHint, MeasureSpec.UNSPECIFIED);
}
child.measure(childWidthSpec, childHeightSpec);
...
}
在ScrollView中,重写了measureChildWithMargins
这个方法,在此方法里对子控件进行测量,并且对子控件传递的高度都是UNSPECIFIED类型的。
@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,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int usedTotal = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin +
heightUsed;
//高度的mode是MeasureSpec.UNSPECIFIED
final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
MeasureSpec.UNSPECIFIED);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
在ListView中,当mode为MeasureSpec.UNSPECIFIED时,计算的就是第一个item的高度,这也就是当ListView或者ScrollView嵌套ListView时,只会显示一个item的原因。
到此,测量过程就梳理完毕了,嗯,当在自定义ViewGroup时,最后一定要将测量出来的宽高传递给setMeasuredDimension,否则该控件不会显示。
4、layout过程
layout是来确定控件的位置,在前面将控件的宽高测量完毕后,会将控件的left、top、right、bottom的的位置传给layout,如果是一个ViewGroup的话,则会在onLayout方法里对所有子控件进行布局,在onLayout方法里调用child.layout
方法。
public void layout(int l, int t, int r, int b) {
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
//在View里,onLayout是空实现,一般在ViewGroup里都是重写onlayout
onLayout(changed, l, t, r, b);
if (shouldDrawRoundScrollbar()) {
if(mRoundScrollbarRenderer == null) {
mRoundScrollbarRenderer = new RoundScrollbarRenderer(this);
}
} else {
mRoundScrollbarRenderer = null;
}
mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnLayoutChangeListeners != null) {
ArrayList<OnLayoutChangeListener> listenersCopy =
(ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
int numListeners = listenersCopy.size();
for (int i = 0; i < numListeners; ++i) {
listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
}
}
}
mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
if ((mPrivateFlags3 & PFLAG3_NOTIFY_AUTOFILL_ENTER_ON_LAYOUT) != 0) {
mPrivateFlags3 &= ~PFLAG3_NOTIFY_AUTOFILL_ENTER_ON_LAYOUT;
notifyEnterOrExitForAutoFillIfNeeded(true);
}
}
left、top、right、bottom这几个参数非常重要,因为这四个值一旦确定了,控件在父容器中的位置也就确定了,且该控件的宽就是right-left
,高就是bottom-top
。
5、draw过程
draw就比较简单了,它的作用就是将View绘制到屏幕上面。View的绘制过程遵循如下几步:
- 绘制背景
drawBackground(canvas);
- 绘制自己
onDraw(canvas);
- 绘制children
dispatchDraw(canvas);
- 绘制装饰
onDrawForeground(canvas);
代码如下:
public void draw(Canvas canvas) {
final int privateFlags = mPrivateFlags;
final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
(mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;
/*
* Draw traversal performs several drawing steps which must be executed
* in the appropriate order:
*
* 1. Draw the background
* 2. If necessary, save the canvas' layers to prepare for fading
* 3. Draw view's content
* 4. Draw children
* 5. If necessary, draw the fading edges and restore layers
* 6. Draw decorations (scrollbars for instance)
*/
// Step 1, draw the background, if needed
int saveCount;
if (!dirtyOpaque) {
drawBackground(canvas);
}
// skip step 2 & 5 if possible (common case)
final int viewFlags = mViewFlags;
boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
if (!verticalEdges && !horizontalEdges) {
// Step 3, draw the content
if (!dirtyOpaque) onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
drawAutofilledHighlight(canvas);
// Overlay is part of the content and draws beneath Foreground
if (mOverlay != null && !mOverlay.isEmpty()) {
mOverlay.getOverlayView().dispatchDraw(canvas);
}
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas);
// Step 7, draw the default focus highlight
drawDefaultFocusHighlight(canvas);
if (debugDraw()) {
debugDrawFocus(canvas);
}
// we're done...
return;
}
...
}
上面就是View的绘制流程了,比较简单。关于如何自己调用onDraw来绘制控件可以阅读Android自定义控件三部曲文章索引这一系列文章,这一系列关于自定义控件写的非常详细。补充一点,在View里有一个特殊的方法setWillNotDraw
方法,它主要是设置优化标志位的。如果一个View不需要绘制任何内容,那么就会将这个标志设为true,系统会进行相应的优化,在ViewGroup里会默认启动这个优化标志位。这个标志位的对实际开发的意义是:当我们的自定义控件继承于ViewGroup并且本身不具有绘制功能时,就可以开启这个标记位从而便于系统进行后续的优化。当然,当明确知道一个ViewGroup需要通过onDraw来绘制内容时,可以显示的关闭WILL_NOT_DRAW
这个标记。
/**
* If this view doesn't do any drawing on its own, set this flag to
* allow further optimizations. By default, this flag is not set on
* View, but could be set on some View subclasses such as ViewGroup.
*
* Typically, if you override {@link #onDraw(android.graphics.Canvas)}
* you should clear this flag.
*
* @param willNotDraw whether or not this View draw on its own
*/
public void setWillNotDraw(boolean willNotDraw) {
setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
}
View的绘制流程到这就梳理完毕了,看到这里基本上就对View的绘制流程有一定的了解了,最后感谢《Android艺术探索》这本书。