[Android] 狀態機 StateMachine 原始碼剖析

PigCanFly發表於2018-03-15

1. 案例

案例:我們常見的汽車,我們可以使用它行駛,也可以將它停止在路邊。當它在行駛的過程中,需要不斷的檢測油量,一旦油量不足的時候,就將陷入停止狀態。而停止在路邊的汽車,需要點火啟動,此時將檢測車中的油量,當油量不足的時候,汽車就需要去加油站加油。

當我們對汽車的狀態和行為進行抽象,汽車的狀態可以有 :

  • 停車 STOP
  • 行駛 RUN
  • 檢測油量 CHECK_OIL
  • 加油 ADDING_OIL

而我們可以對汽車的操作可以是:

  • 停車 ACTION_STOP
  • 行駛 ACTION_RUN
  • 加油 ACTION_ADD_OIL

我們建立一個二維表,將狀態和可操作的行為組合在一起:

狀態行為對應表

2. HSM

我們通過這個狀態表構建我們的狀態引用關係模型:

狀態轉化模型

這幅狀態圖實際上一個相對複雜的網狀圖形,當構建一個更為複雜的系統的時候,這種網狀圖將會以成倍的複雜性遞增。為了解決這個問題,我們需要將這種網狀的狀態機轉化為一個樹狀的層次狀態機,也叫 HSM (Hierarchical State Machine)。我們可以將上述的狀態模型轉化為:

層次狀態圖

這張圖裡,將 STOP 作為根節點,從層次上作為其他狀態節點的父節點。

  • STOP 作為初始狀態
  • 發生了 ACTION_ADD_OIL 動作,STOP 狀態就變成了 ADDING_OIL 狀態
  • ADDING_OIL 結束,發生了 ACTION_RUN 動作,就需要彈出 ADDING_OIL狀態 ,傳入到 CHECK_OIL,然後傳入 RUN 狀態。

3. [StateMachine] 初始化

StateMachineAndroid 系統提供的 HSM 狀態機的實現,它的原始碼在包com.android.internal.util下。StateMachine 提供了三個構造方法,但這三個方法大同小異:

protected StateMachine(String name) {
        mSmThread = new HandlerThread(name);
        mSmThread.start();
        Looper looper = mSmThread.getLooper();

        initStateMachine(name, looper);
}
複製程式碼

構造器呼叫 initStateMachine 函式,這個函式需要傳入了一個 Looper 物件,StateMachine 物件所有的操作都需要在這個 Looper 所在的執行緒中執行。而之間的通訊是通過 SmHandler 物件傳遞。

private void initStateMachine(String name, Looper looper) {
        mName = name;
        mSmHandler = new SmHandler(looper, this);
    }
複製程式碼

上面我們說了,StateMachineHSM 狀態機,構造它的時候,需要指定它的層次關係,這需要呼叫 addState 函式,這個函式有兩個引數,第第二個引數代表的是第一個引數的父節點:

protected final void addState(State state, State parent) {
        mSmHandler.addState(state, parent);
}
複製程式碼

而根節點,又稱為初始狀態節點,需要通過 setInitialState 函式指定:

 protected final void setInitialState(State initialState) {
        mSmHandler.setInitialState(initialState);
}
複製程式碼

這裡,不論設定什麼樣的節點,都需要通過 mSmHandler 物件設定,比如,當通過呼叫 StateMachine.addState 新增節點的時候,需要呼叫到 SmHandler.addState 函式:

//code SmHandler
private final StateInfo addState(State state, State parent) {
            if (mDbg) {
            //debug開關可以通過 StateMachine.setDbg介面設定開啟
                mSm.log("addStateInternal: E state=" + state.getName() + ",parent="
                        + ((parent == null) ? "" : parent.getName()));
            }
            StateInfo parentStateInfo = null;
            // StateInfo 表示在 HSM 樹中的狀態節點
            if (parent != null) {
                parentStateInfo = mStateInfo.get(parent);
                //mStateInfo 是一個hashmap物件
                if (parentStateInfo == null) {
                //當父節點不存在的時候,新增該節點
                    // Recursively add our parent as it's not been added yet.
                    parentStateInfo = addState(parent, null);
                }
            }
            StateInfo stateInfo = mStateInfo.get(state);
            //通過狀態構建一個狀態節點
            if (stateInfo == null) {
                stateInfo = new StateInfo();
                mStateInfo.put(state, stateInfo);
            }

            // Validate that we aren't adding the same state in two different hierarchies.
            if ((stateInfo.parentStateInfo != null)
                    && (stateInfo.parentStateInfo != parentStateInfo)) {
                    //不允許一個節點存在兩個父節點
                throw new RuntimeException("state already added");
            }
            stateInfo.state = state;
            stateInfo.parentStateInfo = parentStateInfo;
            //構建父子的層次關係
            stateInfo.active = false;
            if (mDbg) mSm.log("addStateInternal: X stateInfo: " + stateInfo);
            return stateInfo;
        }
複製程式碼

mStateInfo是一個 HashMap<State, StateInfo> 型別的物件, 而 StateInfo 類是用於記錄狀態 State 物件資訊,和父節點資訊的 HSM 節點物件

 private class StateInfo {
            /** The state */
            State state;

            /** The parent of this state, null if there is no parent */
            StateInfo parentStateInfo;

            /** True when the state has been entered and on the stack */
            boolean active;
    
 }
複製程式碼

按照我們剛才對 Car 這個模型的抽象,我們可以定義出一個 Car 的狀態機:


public class Car extends StateMachine {
    ....
    public Car(String name) {
        super(name);
        this.addState(mStopState,null);
        //mStopState 作為根節點狀態,沒有父節點
            this.addState(mAddOilState,mStopState);
            //mAddOilState 作為mStopState 的子狀態
            this.addState(mCheckOilState,mStopState);
            //mCheckOilState 作為mStopState 的子狀態
                this.addState(mRunState,mCheckOilState);
                //mRunState 作為mCheckOilState 的子狀態
        this.setInitialState(mStopState);
        // mStopState 為初始狀態
    }
}

複製程式碼

當我們構造完我們的樹形結構了以後,我們就可以將我們的狀態機啟動起來,這個啟動依賴於 StateMachine.start 函式:

public void start() {
        // mSmHandler can be null if the state machine has quit.
        SmHandler smh = mSmHandler;
        if (smh == null) return;
        /** Send the complete construction message */
        smh.completeConstruction();//呼叫SmHandler.completeConstruction
}
複製程式碼

StateMachine.start 中呼叫 SmHandler.completeConstruction用於提交我們之前的所有操作:

 private final void completeConstruction() {
            if (mDbg) mSm.log("completeConstruction: E");

            /**
             * Determine the maximum depth of the state hierarchy
             * so we can allocate the state stacks.
             */
            int maxDepth = 0;// step1
            for (StateInfo si : mStateInfo.values()) {
                int depth = 0;
                for (StateInfo i = si; i != null; depth++) {
                    i = i.parentStateInfo;
                }
                if (maxDepth < depth) {
                    maxDepth = depth;//找到一個最深的堆疊
                }
            }
            if (mDbg) mSm.log("completeConstruction: maxDepth=" + maxDepth);

            mStateStack = new StateInfo[maxDepth];
            mTempStateStack = new StateInfo[maxDepth];//用於計算的臨時變數
            setupInitialStateStack();//以初始狀態為棧底儲存到 mStateStack

            /** Sending SM_INIT_CMD message to invoke enter methods asynchronously */
            sendMessageAtFrontOfQueue(obtainMessage(SM_INIT_CMD, mSmHandlerObj));
            if (mDbg) mSm.log("completeConstruction: X");
        }
複製程式碼

按照我們的 HSM 模型,以 STOP 狀態為基礎狀態的時候,那麼我們以這個狀態為棧底向上延伸,我們可以得到兩個棧,分別是:

stack1: [STOP,CHECK_OIL,RUN]

stack2: [STOP,ADD_OIL]
複製程式碼

stack1 的最大深度為 3 , stack2 的最大深度為 2 。那麼 stack1 就可以應用於 stack2 的情況。 completeConstruction 程式碼中 step1 段的程式碼就是這個目的,找到一個最大的棧,用於給所有的棧情況使用。

private final void setupInitialStateStack() {
            if (mDbg) {
                mSm.log("setupInitialStateStack: E mInitialState=" + mInitialState.getName());
            }

            StateInfo curStateInfo = mStateInfo.get(mInitialState);
            for (mTempStateStackCount = 0; curStateInfo != null; mTempStateStackCount++) {
                mTempStateStack[mTempStateStackCount] = curStateInfo;
                curStateInfo = curStateInfo.parentStateInfo;
            }
            //將初始狀態位根狀態以0->N的順序存入 tempStack

            // Empty the StateStack
            mStateStackTopIndex = -1;
            moveTempStateStackToStateStack();//將 tempstack 倒敘複製給 stateStack
        }
複製程式碼

mTempStateStack 是一箇中間變數,它存的是倒敘的 mStateStack 。比如我們的初始狀態是 RUN 。那麼我們需要不斷迴圈將 RUN 的父節點存入 mTempStateStack 得到:

mTempStateStack :[RUN,CHECK_OIL,STOP]
複製程式碼

這時候我們需要呼叫 moveTempStateStackToStateStack 函式將它倒敘複製到 mStateStack 物件中,保證當前狀態 RUN 位於棧頂:

mStateStack: [STOP,CHECK_OIL,RUN]
複製程式碼

mStateStackTopIndex 變數指向 mStateStack 的棧頂。剛才的這個例子,mStateStackTopIndex 的值為 2 ,指向 RUN 所在的陣列索引位置。

到了 start 函式呼叫的這一步,我們就完成了一個樹形資料結構和初始狀態的設定,接下來,我們就可以往我們的狀態機上傳送我們的指令。

4. [StateMachine] 處理訊息

我們通過上面的手段構造完一個狀態機以後,就可以通過指令讓這個狀態機去處理訊息了。我們先給我們的狀態機開一些外部呼叫的介面:

public interface ICar {
    
    public void run();
    public void stop();
    public void addOil();
    
}

public class Car extends StateMachine implements ICar{
    ....
}

public void func() {
    ICar car = new Car("Ford");
    car.addOil();
    car.run();
    car.stop();
}
複製程式碼

當我們要向我們的狀態機傳送指令的時候,需要呼叫狀態機的 sendMessage(...) 函式,這套函式跟 android.os.Handler 提供的 api 的含義一模一樣。實際上,狀態機在處理這種訊息的時候,也是採用 Handler 的方式,而我們上面反覆提到的 SmHandler 物件實際上就是 Handler 物件的子類。

public final void sendMessage(int what) {
        // mSmHandler can be null if the state machine has quit.
        SmHandler smh = mSmHandler;
        if (smh == null) return;

        smh.sendMessage(obtainMessage(what));//通過Handler方式傳送訊息
}
複製程式碼

這樣,我們就可以通過這個函式去實現我們的幾個介面方法:

public class Car extends StateMachine implements ICar{ 
    ...
    public void run() {
        this.sendMessage(ACTION_RUN);
    } 
    
    public void stop() {
        this.sendMessage(ACTION_STOP);
    }
    
    public void addOil() {
        this.sendMessage(ACTION_ADD_OIL);
    }
}
複製程式碼

根據我們對 Handler 類的瞭解,每當我們通過 Handler.sendMessage 函式傳送一個訊息的時候,都將在 Looper 的下個處理訊息執行的時候,回撥 Handler.handleMessage(Message msg) 方法。由於 SmHandler 繼承於 Handler,並且它複寫了 handleMessage 函式,因此 , 訊息傳送之後,最後將回撥到SmHandler.handleMessage 方法中。

//code SmHandler
public final void handleMessage(Message msg) {
            if (!mHasQuit) {
                if (mDbg) mSm.log("handleMessage: E msg.what=" + msg.what);

                /** Save the current message */
                mMsg = msg;

                /** State that processed the message */
                State msgProcessedState = null;
                if (mIsConstructionCompleted) {
                    /** Normal path */
                    msgProcessedState = processMsg(msg);
                    //由當前狀態處理
                } else if (!mIsConstructionCompleted && (mMsg.what == SM_INIT_CMD)
                        && (mMsg.obj == mSmHandlerObj)) {
                    /** Initial one time path. */
                    //執行初始化操作函式
                    mIsConstructionCompleted = true;
                    invokeEnterMethods(0);
                    //當呼叫
                } else {
                    throw new RuntimeException("StateMachine.handleMessage: "
                            + "The start method not called, received msg: " + msg);
                }
                performTransitions(msgProcessedState, msg);

                // We need to check if mSm == null here as we could be quitting.
                if (mDbg && mSm != null) mSm.log("handleMessage: X");
            }
        }

複製程式碼

SmHandler.handleMessage 函式主要執行以下幾個操作:

  1. 根據 mHasQuit 判斷是否退出,如果退出將不執行後續指令
  2. 判斷是否初始完成(根據變數mIsConstructionCompleted),如果初始化完成呼叫 processMsg 將訊息拋給當前狀態執行
  3. 如果尚未初始化,並且接受的是初始化命令 SM_INIT_CMD 將執行一次初始化操作
  4. 當命令執行結束後,執行 performTransitions 函式用於轉變當前狀態和 mStateStack

我們先接著上面第三個主題 [StateMachine] 初始化 看下第三步。 SM_INIT_CMD 指令的發出位於 SmHandler.completeConstruction 函式中:

//code SmHandler
 private final void completeConstruction() {
            ...
            /** Sending SM_INIT_CMD message to invoke enter methods asynchronously */
            sendMessageAtFrontOfQueue(obtainMessage(SM_INIT_CMD, mSmHandlerObj));
            ...
        }
複製程式碼

處理初始化訊息的時候會先將 mIsConstructionCompleted 設定為 true ,告訴狀態機已經初始化過了,可以讓狀態處理訊息了。然後呼叫了個 invokeEnterMethods 函式。這個函式的目的是回撥當前 mStateStack 棧中所有的活動狀態的 enter 方法。並且將非活躍狀態設定為活躍態:

private final void invokeEnterMethods(int stateStackEnteringIndex) {
            for (int i = stateStackEnteringIndex; i <= mStateStackTopIndex; i++) {
                mStateStack[i].state.enter();
                mStateStack[i].active = true;
            }
        }
複製程式碼

這樣,如果我們的初始狀態是 STOP 的話,我們就可以在後臺列印中看到:

//console output:
output: [StateMachine] StopState enter
複製程式碼

如果我們的初始狀態是 RUN 狀態的話就可以看到:

//console output:
output: [StateMachine] StopState enter
output: [StateMachine] CheckOilState enter
output: [StateMachine] RunState enter
複製程式碼

上面就是處理初始化訊息的過程,到這一步,初始化的過程算是完整走完。我們繼續來看初始化後的邏輯,當初始化已經結束之後,再收到的訊息將通過 processMsg 函式提交給合適的狀態執行。

private final State processMsg(Message msg) {
            StateInfo curStateInfo = mStateStack[mStateStackTopIndex];
            //獲取當前狀態節點
            if (isQuit(msg)) {
                //判斷當前訊息是否是退出訊息
                transitionTo(mQuittingState);
            } else {
                while (!curStateInfo.state.processMessage(msg)) {
                //當該狀態不處理當前訊息的時候,將委託給父狀態處理
                    curStateInfo = curStateInfo.parentStateInfo;
                    if (curStateInfo == null) {
                        mSm.unhandledMessage(msg);
                        //當沒有狀態可以處理當前訊息的時候回撥unhandledMessage 
                        break;
                    }
                }
            }
            return (curStateInfo != null) ? curStateInfo.state : null;
        }
複製程式碼

processMsg 會先判斷當前是否是退出訊息,如果 isQuit 成立,將轉入 mQuittingState 狀態。我們將在後面分析如何執行退出操作,這塊東西,我們暫且有個印象。當並非退出訊息時候,將會分配給當前狀態處理,如果當前狀態處理不了,將委託給父狀態處理。比如當前我們的初始狀態是 RUN 。那麼對應的 mStateStack 為:

[STOP,CHECK_OIL,RUN]
複製程式碼

我們給狀態的測試程式碼是:

private class BaseState extends State {
        @Override
        public void enter() {
            log(" enter "+this.getClass().getSimpleName());
            super.enter();

        }

        @Override
        public void exit() {
            log(" exit "+this.getClass().getSimpleName());
            super.exit();
        }
}

public class StopState extends BaseState {

        @Override
        public boolean processMessage(Message msg) {
            log("StopState.processMessage");
            return HANDLED;//處理訊息
        }
    }

public class CheckOilState extends BaseState {
        @Override
        public boolean processMessage(Message msg) {
            log("CheckOilState.processMessage");
            return NOT_HANDLED;// 不處理訊息
        }
}

public class RunState extends BaseState {}
複製程式碼

我們往狀態機 Car 傳送一條訊息:

Car car = new Car();
car.sendMessage(0x01);
複製程式碼

我們將在後臺列印出log:

 --> enter StopState
 --> enter CheckOilState
 --> enter RunState
 // 初始化結束
 -->[StateMachine]:handleMessage 1
-->CheckOilState.processMessage // run狀態不處理,扔給checkoil狀態
-->StopState.processMessage // checkoil狀態不處理,扔給stop 狀態
複製程式碼

當然,如果你並不希望訊息被委託呼叫,你可以在初始狀態呼叫 processMessage 函式的時候,返回 HANDLED 常量,這樣就不會往下呼叫。

5. [StateMachine] 狀態轉換

通常,我們會在 State.processMessage 內部,通過呼叫 transitionTo 函式執行一次狀態轉換,而呼叫這個函式只是將你要轉換的狀態存入一個臨時的物件中:

protected final void transitionTo(IState destState) {
        mSmHandler.transitionTo(destState);
}
private final void transitionTo(IState destState) {
            mDestState = (State) destState;
}
複製程式碼

真正的狀態轉換將發生在 SmHandler.handleMessage 函式執行之後:

 public final void handleMessage(Message msg) {
            if (!mHasQuit) {
                ...
                performTransitions(msgProcessedState, msg);//變更狀態
            }
        }
複製程式碼

這裡將呼叫 performTransitions 函式完成狀態轉換,假如,現在的狀態是 RUN 狀態,當需要轉成 ADD_OIL 狀態的時候,將進行一下轉變:

/**
初始:
mStateStack : [ STOP,CHECK_OIL,RUN]
*/

private void performTransitions(State msgProcessedState, Message msg) {
            State orgState = mStateStack[mStateStackTopIndex].state;
            //orgState記錄當前狀態
            State destState = mDestState;
            //destState 記錄要轉變的目標狀態
            if (destState != null) {
                while (true) {
                    StateInfo commonStateInfo = setupTempStateStackWithStatesToEnter(destState);
                    //查詢跟目標狀態的公共節點狀態,此時為 STOP 狀態節點
                    invokeExitMethods(commonStateInfo);
                    //從棧頂一直到commonStateInfo(不包含) 所在的位置執行退出操作
                    int stateStackEnteringIndex = moveTempStateStackToStateStack();
                    invokeEnterMethods(stateStackEnteringIndex);
                    moveDeferredMessageAtFrontOfQueue();
                    //將Deferred 訊息放入佇列頭部優先執行
                    if (destState != mDestState) {
                        destState = mDestState;
                    } else {
                        break;
                    }
                }
                mDestState = null;
            }
            if (destState != null) {
                if (destState == mQuittingState) {
                    //TODO clean
                } else if (destState == mHaltingState) {
                    //TODO halt
                }
            }
        }

複製程式碼

這段程式碼執行的時候,會先去尋找目標節點和當前節點的公共祖先節點,這是通過呼叫 setupTempStateStackWithStatesToEnter 呼叫的。StateMachine 的函式名起的見名知意,*Temp* 代表這個函式中要使用中間變數 mTempStateStack*ToEnter 代表需要對新增進的狀態執行 State.enter 操作。

private final StateInfo setupTempStateStackWithStatesToEnter(State destState) {
            mTempStateStackCount = 0;//重置 mTempStateStack
            StateInfo curStateInfo = mStateInfo.get(destState);
            do {
                mTempStateStack[mTempStateStackCount++] = curStateInfo;
                curStateInfo = curStateInfo.parentStateInfo;
            } while ((curStateInfo != null) && !curStateInfo.active);
            //找到第一個 active 的狀態節點。
            return curStateInfo;
        }
複製程式碼

setupTempStateStackWithStatesToEnter 函式就是將目標節點的堆疊複製到 mTempStateStack 變數中,然後將最終相交的節點返回。這裡採用 do-while的寫法,說明這個函式的執行,至少包含一個 destState 元素。剛才從 RUN->ADD_OIL 的例子中,setupTempStateStackWithStatesToEnter 將返回 STOP 狀態,mTempStateStack 的為:

mTempStateStack: {ADD_OIL}
複製程式碼

我們回到 performTransitions 的流程,執行 setupTempStateStackWithStatesToEnter 完,將執行 invokeExitMethods 函式。

private final void invokeExitMethods(StateInfo commonStateInfo) {
            while ((mStateStackTopIndex >= 0)
                    && (mStateStack[mStateStackTopIndex] != commonStateInfo)) {
                State curState = mStateStack[mStateStackTopIndex].state;
                curState.exit();
                mStateStack[mStateStackTopIndex].active = false;
                mStateStackTopIndex -= 1;
            }
}
複製程式碼

這個函式相當於將 mStateStack 棧中的非 commonStateInfo 進行出棧。

mStateStack: {STOP,CHECK_OIL,RUN} ->
invokeExitMethods(STOP) ->
mStateStack: {STOP}
複製程式碼

執行完出棧後,只需要將我們剛才構建的 mTempStateStack 拷貝到 mStateStack 就可以構建新的狀態棧了,而這個操作是通過 moveTempStateStackToStateStack 函式完成,而 moveTempStateStackToStateStack 我們剛才說過,實際上就是將 mTempStateStack 逆序賦值到 mStateStack。這樣,我們就構建了一個新的 mStateStack:

mStateStack: {STOP,ADD_OIL}
複製程式碼

這個時候,我們構建了一個新的狀態棧,相當於已經切換了狀態。performTransitions 在執行完 moveTempStateStackToStateStack 之後,呼叫 invokeEnterMethods 函式,執行非 active 狀態的 enter 方法。之後執行 moveDeferredMessageAtFrontOfQueue 將通過 deferMessage 函式快取的訊息佇列放到 Handler 訊息佇列的頭部:

...

                    int stateStackEnteringIndex = moveTempStateStackToStateStack();
                    invokeEnterMethods(stateStackEnteringIndex);
                    moveDeferredMessageAtFrontOfQueue();
                    //將Deferred 訊息放入佇列頭部優先執行
                    if (destState != mDestState) {
                        destState = mDestState;
                    } else {
                        break;
                    }
                    
                    ...
複製程式碼

當我們完成狀態的轉換了以後,需要對兩種特殊的狀態進行處理,在 performTransitions 函式的末尾會判斷兩個特殊的狀態:

1. HaltingState
2. QuittingState
複製程式碼

6. [StateMachine] 狀態機的退出

狀態機的退出,StateMachine 提供了幾個方法:

  1. quit: 執行完訊息佇列中所有的訊息後執行退出和清理操作
  2. quitNow: 拋棄掉訊息佇列中的訊息,直接執行退出和清理操作
  3. transitionToHaltingState: 拋棄掉訊息佇列中的訊息,直接執行退出,不做清理

從上面的表述中看,quit 相對 halt 操作來說更加的安全。這個 Threadinterceptstop 方法很類似,很好理解。上面我們說到,退出狀態 HaltingStateQuittingState 是在performTransitions 函式的末尾判斷和執行的,我們來看下程式碼:

if (destState != null) {
                if (destState == mQuittingState) {
                    mSm.onQuitting();
                    cleanupAfterQuitting();//清理操作
                } else if (destState == mHaltingState) {
                    mSm.onHalting();//只是執行回撥
                }
            }
            
            
private final void cleanupAfterQuitting() {
            if (mSm.mSmThread != null) {
                getLooper().quit();//退出執行緒
                mSm.mSmThread = null;
            }
            /*清空資料*/
            mSm.mSmHandler = null;
            mSm = null;
            mMsg = null;
            mLogRecords.cleanup();
            mStateStack = null;
            mTempStateStack = null;
            mStateInfo.clear();
            mInitialState = null;
            mDestState = null;
            mDeferredMessages.clear();
            mHasQuit = true;
        }
複製程式碼

destState == mQuittingState 語句成立,將回撥 StateMachine.onQuitting 函式,之後將執行 cleanupAfterQuitting 進行清理操作。清理操作中,會將執行緒清空,和其他資料變數清空,而如果 destState == mHaltingState 成立,StateMachine 將不執行任何的清理操作,通過回撥 onHalting 函式來通知狀態機退出。

7. 總結

Android 裡面的這個 StateMachine 狀態機在很多原始碼中都有涉及,程式碼也很簡單,沒有什麼太大的難度,希望以上的總結能幫各位看官理解 StateMachine 原始碼的含義,並且能基於它,開發更多個性化的功能

相關文章