View繪制流程說簡單也簡單,僅僅是三個(gè)步驟,但是說難也是很難,看來無數(shù)的書和文章都不能完整的理解,最后還是親手來總結(jié)一番,雖然學(xué)習(xí)Android有一段時(shí)間了,但對于原理層面的問題還從來沒有認(rèn)真的去寫過,這是第一次嘗試,就當(dāng)作小白吧,共同進(jìn)步。
0. 本篇目錄
- 對View的初步認(rèn)識(shí)
- 對UI架構(gòu)圖的分析
- DecorView與Window的聯(lián)系
- 初探View繪制
1.初次見面
本部分內(nèi)容需要了解以下幾個(gè)知識(shí)點(diǎn),這是學(xué)習(xí)View的入門,也是面試基礎(chǔ)問題。
Q1:View是什么,ViewGroup又是什么,他們是以什么結(jié)構(gòu)組織的?
Q2:UI界面架構(gòu)圖是什么樣子的?或者說Window,Activity,和View都是什么?
下面來探索一下這幾個(gè)問題。
View是所有UI控件的一個(gè)基類,我們平時(shí)用的Button,TextView先不管內(nèi)部是個(gè)什么繼承邏輯,但歸根結(jié)底都有一個(gè)共同的父類就是View。
而ViewGroup按照字面意思理解,就是一個(gè)View集合,里面可以包括眾多View,而ViewGroup是以樹來維護(hù)這些View的,如下圖。

而其實(shí)ViewGroup也是繼承自View的,由這個(gè)樹形結(jié)構(gòu)我們可以明白一些事情,首先根節(jié)點(diǎn)或者父節(jié)點(diǎn)繪制好了才會(huì)去繪制分支節(jié)點(diǎn),或者說事件首先要經(jīng)過父節(jié)點(diǎn)才能到達(dá)根節(jié)點(diǎn),這里面就有一些 測量繪制和事件分發(fā)的知識(shí)了,具體在后面說。
更準(zhǔn)確的說,圖中藍(lán)色的根節(jié)點(diǎn),又叫做ViewParent,由它來控制整個(gè)事件或者整個(gè)測量繪制流程。
于是findViewById() 這個(gè)很熟悉的方法,就是通過遍歷這棵樹來找到目標(biāo)View的。
下面第二個(gè)問題,我們看一下手機(jī)屏幕上顯示的界面是怎么個(gè)組織方式。

上面的圖是一個(gè)標(biāo)準(zhǔn)的界面組織方式,最外層是一個(gè)Activity,每個(gè)Activity都會(huì)擁有自己的一個(gè)Window,而在Android種Window一般是由PhoneWindow實(shí)現(xiàn)。

可以看到Window是一個(gè)抽象類,而上面的doc已經(jīng)提到了,PhoneWindow是這個(gè)類唯一的實(shí)現(xiàn),稍后會(huì)驗(yàn)證Activty和PhoneWindow的綁定,我們繼續(xù)往下看。
圖中PhoneWindow會(huì)有一個(gè)DecorView,它是這個(gè)界面的根容器,但是本質(zhì)上是一個(gè)FrameLayout,而DecorView內(nèi)部是一個(gè)垂直的LinearLayout,這個(gè)LinearLayout包含兩部分,TitleView和ContentView,其中TitleView有時(shí)我們常見的ActionBar部分的容器,而ContentView就是我們自己創(chuàng)建的界面,它本身是一個(gè)FrameLayout,我們平常用的setContentView就是設(shè)置它的子View。
梳理一下:
DecorView(FrameLayout) 包含一個(gè) LinearLayout
而這個(gè)LinearLayout又包含 TitleView 和 ContentView(FrameLayout)
ContentView 內(nèi)部才是我們自定義的布局
2. UI架構(gòu)探索?
大部分人都會(huì)選擇從setContentView() 方法談起,因?yàn)檫@個(gè)方法是最常見的,用法極其簡單的,但又是有很大的說頭的。
下面是一段代碼(為了清楚,我把自己寫的代碼和源碼綜合到一起了,并且省略一些不太重要的代碼)
// 1.以下代碼來自 一個(gè)新建的Activity :DemoActivity
public class DemoActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_demo); // to 2
}
}
// 2.以下代碼是Activity的setContentView方法
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
……
}
首先要知道如果不調(diào)用setContentView,是無法顯示出我們的界面的,然后調(diào)用了setContentView方法之后,Activity內(nèi)部是調(diào)用了getWindow方法的setContentView方法。
那么getWindow方法是什么?
// 以下代碼是Activity的getWindow方法
public Window getWindow() {
return mWindow;
}
返回了一個(gè)mWindow,而根據(jù)返回值我們知道這個(gè)mWindow必然是一個(gè)Window對象。

在哪里做的初始化?

上面這個(gè)方法是Activity的attach方法。而這個(gè)attach() 方法會(huì)在onCreate()方法之前調(diào)用,這里就涉及到Activity啟動(dòng)流程了,暫時(shí)不做深究,我們只需要知道onCreate() 前,mWindow 已經(jīng)完成了初始化,并且指向了PhoneWindow對象。
回到上面 getWindow().setContentView(layoutResID),我們查看PhoneWindow里的setContentView方法。
// 以下代碼來自 PhoneWindow 的setContentView方法
public void setContentView(int layoutResID) {
if (mContentParent == null) {
//1.初始化ContentView
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID, getContext());
transitionTo(newScene);
} else {
//2.添加layoutResID布局到mContentParent
mLayoutInflater.inflate(layoutResID, mContentParent);
}
mContentParent.requestApplyInsets();
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
mContentParentExplicitlySet = true;
}
首次進(jìn)入這個(gè)方法的時(shí)候,mContentParent 必然為null,所以上面代碼我們只需要關(guān)心標(biāo)號(hào)的兩個(gè)部分,一個(gè)是installDecor() 初始化,二是inflate 解析加載,如代碼注釋所標(biāo)。
而后者我們在寫代碼中也很常見,這里就不多說了,就是把layout解析加載到mContentParent 中,所以著重看一下installDecor。
// 以下代碼來自 PhoneWindow 的installDecor方法
private void installDecor() {
mForceDecorInstall = false;
//如果decorView為空,就生成decorView
if (mDecor == null) {
//1.初始化decorView
mDecor = generateDecor(-1);
mDecor.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
mDecor.setIsRootNamespace(true);
if (!mInvalidatePanelMenuPosted && mInvalidatePanelMenuFeatures != 0) {
mDecor.postOnAnimation(mInvalidatePanelMenuRunnable);
}
} else {
mDecor.setWindow(this);
}
//如果mContentParent 為空,就初始化
if (mContentParent == null) {
//2.初始化mContentParent
mContentParent = generateLayout(mDecor);
...
}
...
}
這個(gè)方法代碼很多,著重看兩個(gè)地方,
1.mDecor = generateDecor(-1);
2.mContentParent = generateLayout(mDecor);
其中很顯然,mDecor 是一個(gè) DecorView,mContentParent 是 ContentView
先看generateDecor
// 以下代碼來自 PhoneWindow 的generateDecor方法
protected DecorView generateDecor(int featureId) {
Context context;
if (mUseDecorContext) {
Context applicationContext = getContext().getApplicationContext();
if (applicationContext == null) {
context = getContext();
} else {
context = new DecorContext(applicationContext, getContext().getResources());
if (mTheme != -1) {
context.setTheme(mTheme);
}
}
} else {
context = getContext();
}
return new DecorView(context, featureId, this, getAttributes());
}
核心代碼就是最后一句,生成了一個(gè)DecorView返回,而我們已經(jīng)知道DecorView是一個(gè)FrameLayout,它是PhoneWindow的內(nèi)部類。
而對于generateLayout代碼很長,下面我寫個(gè)偽代碼確認(rèn)以下流程即可。
protected ViewGroup generateLayout(DecorView decor) {
int layoutResource;
//確認(rèn)布局
if( xxx 主題 xxx 特性){
layoutResource = R.layout.xxxxx1;
} else if( xxxx 主題 xxx 特性){
layoutResource = R.layout.xxxxx2;
} else if(){
……
}else{
……
}
mDecor.onResourcesLoaded(mLayoutInflater, layoutResource);
ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
……
return contentParent;
}
這段代碼其實(shí)很簡單,就是根據(jù)你設(shè)置的主題和feature來選擇默認(rèn)加載的界面(xml),
什么是theme 和 feature ?
theme是<Application android:theme=""/>,或者<Activity/>節(jié)點(diǎn)指定的themes
feature是requestWindowFeature()中指定的Features
相信這些你都用過。
這也就解釋了為什么必須要在setContentView(...)之前才能執(zhí)行requestWindowFeature(...)
選擇好了之后將 布局加載到DecorView中,并且找到ID_ANDROID_CONTENT這個(gè)控件作為ViewGroup返回,也就是賦值給mContentParent
那么問題是ID_ANDROID_CONTENT到底是個(gè)什么?
很顯然,他就是id為content的那個(gè)frameLayout(在各種各樣的主題xml中,都是這個(gè)名字)
至于onResourcesLoaded,我們知道他是將layoutResource加載到mDecor里的方法,內(nèi)部調(diào)用了addView,這里就不深究了。
總結(jié)
稍稍總結(jié)一下。
Q3: setContentView的流程是什么?
- 首先 attach 方法建立 PhoneWindow,在PhoneWindow的 setContentView 方法中 初始化 DecorView,
- 調(diào)用generateLayout方法選擇合適主題布局加載到decorView上,最后對mContentParent 賦值,并且將setContentView所傳入的xml布局加載在mContentParent 上。
- 到此為止實(shí)現(xiàn)了布局的加載。

最后補(bǔ)充一點(diǎn),狀態(tài)欄和導(dǎo)航欄也是在DecorView中的
這里借一張Hierarchy 圖展示一下

可以看到DecorView中除了LinearLayout還有其他兩部分,分別是狀態(tài)欄和底部導(dǎo)航欄。
看到這里,我們就可以對一個(gè)界面有很深刻的認(rèn)識(shí),那么雖然這不是本文的重點(diǎn),但是也是作為基礎(chǔ)的重要一環(huán),下面來看看View的繪制吧。
3. Decor與Window的小確幸
學(xué)習(xí)到這里,我們發(fā)現(xiàn)界面顯示貌似與Activity無關(guān),所有的View操作都是PhoneWindow來完成的,所以我們還要深究這個(gè)Window。
通過上面我們看到了UI界面的一個(gè)架構(gòu),我們?nèi)庋鬯姷木褪荄ecorView,那么DecorView的內(nèi)容是如何加載到Window上的,你可能會(huì)說了上面setWindow不是嗎?其實(shí)那只是配置一下關(guān)系而已,按照正常的流程需要Window 添加 DecorView才對不是嗎?
Window偷偷說:你也太小瞧我了,我還沒發(fā)話了,你們就自行搞定了???
那么這個(gè)流程是怎么完成的呢?
還記得我們剛才提到過的attach方法吧,我們說attach是onCreate方法調(diào)用之前所調(diào)用的,是屬于Activity啟動(dòng)的一部分,在attach之前的過程,我們暫時(shí)先不說,我們說接下來的流程。
//以下代碼Activity 的 attach方法,有省略
final void attach( …… ){
……
//1. 創(chuàng)建PhoneWindow
mWindow = new PhoneWindow(this, window, activityConfigCallback);
……
//2. setWindowManager
mWindow.setWindowManager(
(WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
mToken, mComponent.flattenToString(),
(info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
}
……
//3 .getWindowManager
mWindowManager = mWindow.getWindowManager();
可以看到上面的代碼主要分為三部分,1.創(chuàng)建PhoneWindow,我們已經(jīng)熟悉了,2是設(shè)置一個(gè)WindowManager,3是取用這個(gè)WindowManager。
那么WindowManager是什么?
WindowManager繼承自ViewManager,是Android中一個(gè)重要的Service,全局唯一。WindowManager主要用來管理窗口的一些狀態(tài)、屬性、view增加、刪除、更新、窗口順序、消息收集和處理等。

如上圖是ViewManager,里面有幾個(gè)方法,addView等,是對View的操作,而ViewGroup也是實(shí)現(xiàn)了這個(gè)接口,可以自己去探索。
正是因?yàn)閷?shí)現(xiàn)了這個(gè)接口,WindowManager和ViewGroup擁有了控制View的能力。
暫時(shí)不深究,我們繼續(xù)看源碼。
// 以下代碼來自Window類的setWindowManager
public void setWindowManager(WindowManager wm, IBinder appToken, String appName,
boolean hardwareAccelerated) {
mAppToken = appToken;
mAppName = appName;
mHardwareAccelerated = hardwareAccelerated
|| SystemProperties.getBoolean(PROPERTY_HARDWARE_UI, false);
//核心代碼
if (wm == null) {
wm = (WindowManager)mContext.getSystemService(Context.WINDOW_SERVICE);
}
mWindowManager = ((WindowManagerImpl)wm).createLocalWindowManager(this);
}
檢查傳入的WindowManager 是否為null,為null就通過系統(tǒng)服務(wù)獲取一個(gè),(會(huì)發(fā)現(xiàn)調(diào)用前后都是通過系統(tǒng)服務(wù)獲取,不知道為啥還要判斷……)然后生成它的實(shí)現(xiàn)。
//以下代碼來自WindowManagerImpl的createLocalWindowManager
public WindowManagerImpl createLocalWindowManager(Window parentWindow) {
return new WindowManagerImpl(mContext, parentWindow);
}
根據(jù)上面這兩步,我們發(fā)現(xiàn)mWindowManager 已經(jīng)被初始化為WindowManagerImpl 。
那么我們就會(huì)明白,Activity內(nèi)部的mWindowManager 是一個(gè)WindowManagerImpl,插個(gè)小曲,看一眼WindowManagerImpl
//WindowManagerImpl 部分代碼節(jié)選
public final class WindowManagerImpl implements WindowManager {
private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}
}
可以看到,WindowManagerImpl 確實(shí)是 WindowManager 的實(shí)現(xiàn)類,而且內(nèi)部有實(shí)現(xiàn)addView方法,但是它的addView卻是調(diào)用 WindowManagerGlobal 的addView方法了,所以我們還要繼續(xù)去探索WindowManagerGlobal 。
// 以下代碼來自 WindowManagerGlobal 中的addView
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
……
//1. 關(guān)注 ViewRootImpl
ViewRootImpl root;
View panelParentView = null;
synchronized (mLock) {
……
//2 . 對ViewRootImpl的初始化操作
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 {
//3. 給root設(shè)置我們的布局
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
if (index >= 0) {
removeViewLocked(index, true);
}
throw e;
}
}
}
上面的代碼比較長,但要是理解這個(gè),我們本部分內(nèi)容就基本上結(jié)束了,我們一點(diǎn)點(diǎn)看。
首先看注釋1的地方,新建了一個(gè)ViewRootImpl 聲明,并在2的地方進(jìn)行了初始化,同時(shí)在3的地方調(diào)用了它的setView方法傳入了view(這里的view就是我們上一部分講的DecorView,先知曉一下,后面會(huì)驗(yàn)證。)
那么說白了最終我們的工作都交給了ViewRootImpl 去做,而ViewRootImpl是View中的最高層級,屬于所有View的根,但ViewRootImpl不是View,只是實(shí)現(xiàn)了ViewParent接口,可以看到ViewRootImpl一頭是View,一頭是WindowManager
而在上面注釋3的地方,我們說調(diào)用了ViewRootImpl的setView方法,這個(gè)方法我就不貼了,在這個(gè)方法里調(diào)用了addToDisplay方法來實(shí)現(xiàn)了Window中添加View。
mWindowSession實(shí)現(xiàn)了IWindowSession接口,它是Session的客戶端Binder對象.
addToDisplay是一次AIDL的跨進(jìn)程通信,通知WindowManagerService添加IWindow

到此結(jié)束。
但是好像有點(diǎn)不對勁?。可厦嬲f的流程怎么這么亂?下面來串聯(lián)一下吧。

首先我們上了一張圖,這個(gè)圖上有我們熟悉的,也有我們不熟悉的,我們從起點(diǎn)梳理一下。
當(dāng) startActivity方法調(diào)用的時(shí)候,首先執(zhí)行handleLaunchActivity來創(chuàng)建新Activity。
//以下代碼來自ActivityThread的handleLaunchActivity方法,只保留兩句核心代碼
public Activity handleLaunchActivity(ActivityClientRecord r,
PendingTransactionActions pendingActions, Intent customIntent) {
...
Activity a = performLaunchActivity(r, customIntent);
...
handleResumeActivity(……)
}
首先是調(diào)用了performLaunchActivity方法 ,其次調(diào)用了handleResumeActivity方法,這里跟我們上面圖中所畫是一樣的。
我們看一下performLaunchActivity源碼
//以下代碼來自ActivityThread的performLaunchActivity
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
//創(chuàng)建Activity所需的Context
ContextImpl appContext = createBaseContextForActivity(r);
Activity activity = null;
try {
java.lang.ClassLoader cl = appContext.getClassLoader();
//Activity通過ClassLoader創(chuàng)建出來
activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent);
//創(chuàng)建Application
Application app = r.packageInfo.makeApplication(false, mInstrumentation);
appContext.setOuterContext(activity);
//將Context與Activity進(jìn)行綁定,并調(diào)用Activity的attach方法
activity.attach(appContext, this, getInstrumentation(), r.token,
r.ident, app, r.intent, r.activityInfo, title, r.parent,
r.embeddedID, r.lastNonConfigurationInstances, config,
r.referrer, r.voiceInteractor, window, r.configCallback);
//調(diào)用activity.oncreate
mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
}
}
可以看到這里面調(diào)用了newActivity,調(diào)用了attach,然后還有callActivityOnCreate(回調(diào)onCreate)方法,這也跟我們圖中所畫的生命周期相關(guān)方法及流程一樣。
對于粉色對話泡泡1處,我們已經(jīng)了然于胸了,上文不止一次提到過attach里面所做的工作,包括下面的windowManager的創(chuàng)建,在attach完成window及windowmanager的初始化工作之后,
緊接著onCreate開始大展拳腳,setContentView的調(diào)用,粉色對話泡泡2,本文一開始就說了,這里也不再贅述。
接下來onStart就會(huì)被調(diào)用,(至于怎么被調(diào)用?我們不深究了,其實(shí)可以推斷應(yīng)該也是在DecorView初始化完成后的一個(gè)回調(diào))。
接著來到了主線程的流程。
handleResumeActivity的調(diào)用。
final void handleResumeActivity(IBinder token,
boolean clearHide, boolean isForward, boolean reallyResume) {
//1. 調(diào)用activity.onResume
ActivityClientRecord r = performResumeActivity(token, clearHide);
if (r != null) {
final Activity a = r.activity;
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
//2. DecorView的獲取
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
//3. 獲取一個(gè)WindowManger
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
if (a.mVisibleFromClient) {
a.mWindowAdded = true;
//4.把當(dāng)前的DecorView與WindowManager綁定一起
wm.addView(decor, l);
}
...
}
我畫出了4條重點(diǎn),其中1處是調(diào)用了onResume方法,2是獲取了DecorView,3是獲取了WindowManagerImpl,4是調(diào)用了WindowManagerImpl的addView方法將DecorView傳入進(jìn)去,這也就印證了我們剛才的鋪墊,DecorView就是從這里傳入進(jìn)去的,并且與Window建立連接的,至于WindowManagerImpl的addView都做了啥,我們上面都說過了,你不會(huì)忘記吧。(也就是粉色對話泡泡3的流程)
到此為止,我們的View就加載到Window里面了,那么接下來要做什么神奇的事情呢?
4 . 初探View繪制
上面我們提到一個(gè)關(guān)鍵的內(nèi)容ViewRootImpl,我們說他是溝通View與Window的橋梁,而且我們也對它的setView方法做了較為簡單的分析,下面我們詳細(xì)分析,setView方法內(nèi)容也比較多,這里就不貼了,里面除了調(diào)用addToDisplay來綁定window與decorView外,還在此前調(diào)用了requestLayout() 方法
requestLayout()主要是讓View經(jīng)歷measure layout draw三個(gè)階段,
//以下代碼來自ViewRootImpl的requestLayout
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}
這個(gè)代碼比較簡單,關(guān)注最后一句,調(diào)用了scheduleTraversals,我們來看一下它。
//以下代碼來自ViewRootImpl的scheduleTraversals
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
//核心內(nèi)容
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
關(guān)注postCallback方法,傳入了一個(gè)mTraversalRunnable任務(wù)。而通過定位我們會(huì)發(fā)現(xiàn)這個(gè)mTraversalRunnable其實(shí)是一個(gè)TraversalRunnable。

可以看到run方法只調(diào)用了doTraversal方法。
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
//關(guān)鍵內(nèi)容,這個(gè)就是繪制入口
performTraversals();
if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}
輾轉(zhuǎn)反側(cè),終于找到了繪制入口,而這個(gè)方法內(nèi)容比較多,我們只需要它內(nèi)部是如下類似的邏輯即可。
private void performTraversals() {
//測量
performMeasure(childWidthMeasureSpec,childHeightMeasureSpec);
//定位
performLayout(lp,desiredWindowWidth,desiredWindowHeight);
//繪制
performDraw();
}
好嘞,就到這里。
什么情況?就這樣就結(jié)束了?
NO,更關(guān)鍵的內(nèi)容等著我們?nèi)グl(fā)現(xiàn)嘞,明天我們繼續(xù)吧。
5 . 總結(jié)
我們本文從setContentView入手講了DecorView是如何初始化的,然后又從ActivityThread的角度來分析了DecorView是如何加載到Window上的,最后對View繪制進(jìn)行了簡單的探索,通過對performTraversals的初步探索,我們發(fā)現(xiàn)后面會(huì)有很多內(nèi)容,要完整的講明白是一件難事,所以一般會(huì)從主線入手,下一篇文章就來真正的談?wù)劺L制過程。
可以試著畫畫流程圖哦,看看我畫的兩張圖綜合起來是什么效果。