Android從源碼層次分析Touch事件派發過程

對於android的窗口window管理,一直感覺很混亂,總想找個時間好好研究,卻不知如何入手,現在寫的Touch事件派發過程詳解,其實跟android的窗口window管理服務WindowManagerService存在緊密聯繫,所以從這裏入手切入到WindowManagerService的研究,本blog主要講述一個touch事件如何從用戶消息的採集,到WindowManagerService對Touch事件的派發,再到一個Activity窗口touch事件的派發,並着重講了Activity窗口touch事件的派發,因爲這個的理解對我們寫應用很好地處理touch事件很重要

一.用戶事件採集到WindowManagerService和派發

--1.WindowManagerService,顧名思義,它是是一個窗口管理系統服務,它的主要功能包含如下:
        --窗口管理,繪製
        --轉場動畫--Activity切換動畫
        --Z-ordered的維護,Activity窗口顯示前後順序
        --輸入法管理
        --Token管理
        --系統消息收集線程
        --系統消息分發線程
這裏,我關注的是系統消息的收集和系統消息的分發,其他功能,當我對WindowManagerService有一個完整的研究後在發blog

--2.系統消息收集和分發線程的創建
這個的從WindowManagerService服務的創建說起,與其他系統服務一樣,WindowManagerService在systemServer中創建的:
ServerThread.run
-->WindowManagerService.main
   -->WindowManagerService.WMThread.run(構建一個專門線程負責WindowManagerService)
      -->WindowManagerService s = new WindowManagerService(mContext, mPM,mHaveInputMethods);
         --mQueue = new KeyQ();//消息隊列,在構造KeyQ中會創建一個InputDeviceReader線程去讀取用戶輸入消息
         --mInputThread = new InputDispatcherThread();//創建一個消息分發線程,讀取並處理mQueue中消息

整個過程處理原理很簡單,典型的生產者消費者模型,我先畫個圖,後面針對代碼進一步說明

--3.InputDeviceReader線程,KeyQ構建時,會啓動一個線程去讀取用戶消息,具體代碼在KeyInputQueue.mThread,在構造函數中,mThread會start,接下來,接研究一下mThread.run:
    //用戶輸入事件消息讀取線程
    Thread mThread = new Thread("InputDeviceReader") {
        public void run() {
            RawInputEvent ev = new RawInputEvent();
            while (true) {//開始消息讀取循環
                try {
                    InputDevice di;
                    //本地方法實現,讀取用戶輸入事件
                    readEvent(ev);
                    //根據ev事件進行相關處理
                    ...
                    synchronized (mFirst) {//mFirst是keyQ隊列頭指針
                    ...
                    addLocked(di, curTimeNano, ev.flags,RawInputEvent.CLASS_TOUCHSCREEN, me);
                    ...
                    }
                }
        }
       }
函數我也沒有看大明白:首先調用本地方法readEvent(ev);去讀取用戶消息,這個消息包括按鍵,觸摸,滾輪等所有用戶輸入事件,後面不同的事件類型會有不同的處理,不過最後事件都要添加到keyQ的隊列中,通過addLocked函數

--4隊列添加和讀取函數addLocked,getEvent
addLocked函數比較簡單,就分析一下,有助於對消息隊列KeyQ的數據結構進行理解:
    //event加入inputQueue隊列
    private void addLocked(InputDevice device, long whenNano, int flags,
            int classType, Object event) {
        boolean poke = mFirst.next == mLast;//poke爲true表示消息隊列爲空
        //從QueuedEvent緩存QueuedEvent獲取一個QueuedEvent對象,並填入用戶事件數據,包裝成一個QueuedEvent
        QueuedEvent ev = obtainLocked(device, whenNano, flags, classType, event);
        QueuedEvent p = mLast.prev;//隊列尾節點爲mLast,把ev添加到mlast前
        while (p != mFirst && ev.whenNano < p.whenNano) {
            p = p.prev;
        }
        ev.next = p.next;
        ev.prev = p;
        p.next = ev;
        ev.next.prev = ev;
        ev.inQueue = true;

        if (poke) {//poke爲true,意味着在空隊列中添加了一個QueuedEvent,這時系統消息分發線程可能在wait,需要notify一下
            long time;
            if (MEASURE_LATENCY) {
                time = System.nanoTime();
            }
            mFirst.notify();//喚醒在 mFirst上等待的線程
            mWakeLock.acquire();
            if (MEASURE_LATENCY) {
                lt.sample("1 addLocked-queued event ", System.nanoTime() - time);
            }
        }
    }
很簡單,使用mFirst,mLast實現的指針隊列,addLocked是QueuedEvent對象添加函數,對應在系統消息分發線程中會有一個getEvent函數來讀取inputQueue隊列的消息,我在這裏也先講一下:
    QueuedEvent getEvent(long timeoutMS) {
        long begin = SystemClock.uptimeMillis();
        final long end = begin+timeoutMS;
        long now = begin;
        synchronized (mFirst) {//獲取mFirst上同步鎖
            while (mFirst.next == mLast && end > now) {
                try {//mFirst.next == mLast意味隊列爲空,同步等待mFirst鎖對象
                    mWakeLock.release();
                    mFirst.wait(end-now);
                }
                catch (InterruptedException e) {
                }
                now = SystemClock.uptimeMillis();
                if (begin > now) {
                    begin = now;
                }
            }
            if (mFirst.next == mLast) {
                return null;
            }
            QueuedEvent p = mFirst.next;//返回mFirst的下一個節點爲處理的QueuedEvent
            mFirst.next = p.next;
            mFirst.next.prev = mFirst;
            p.inQueue = false;
            return p;
        }
    }

通過上面兩個函數得知,消息隊列是通過mFirst,mLast實現的生產者消費模型的同步鏈表隊列

--5.InputDispatcherThread線程
InputDispatcherThread處理InputDeviceReader線程存放在KeyInputQueue隊列中的消息,分發到具體的一個客戶端的IWindow
InputDispatcherThread.run
-->windowManagerService.process{                
            ...
            while (true) {                
                // 從mQueue(KeyQ)獲取一個用戶輸入事件,正上調用我上面提到的getEvent方法,若隊列爲空,線程阻塞掛起
                QueuedEvent ev = mQueue.getEvent(
                    (int)((!configChanged && curTime < nextKeyTime)
                            ? (nextKeyTime-curTime) : 0));
                ...
                try {
                    if (ev != null) {
                        ...
                        if (ev.classType == RawInputEvent.CLASS_TOUCHSCREEN) {//touch事件
                            eventType = eventType((MotionEvent)ev.event);
                        } else if (ev.classType == RawInputEvent.CLASS_KEYBOARD ||
                                    ev.classType == RawInputEvent.CLASS_TRACKBALL) {//鍵盤輸入事件
                            eventType = LocalPowerManager.BUTTON_EVENT;
                        } else {
                            eventType = LocalPowerManager.OTHER_EVENT;//其他事件
                        }
                        ...
                        switch (ev.classType) {
                            case RawInputEvent.CLASS_KEYBOARD:
                                ...
                                dispatchKey((KeyEvent)ev.event, 0, 0);//鍵盤輸入,派發key事件
                                mQueue.recycleEvent(ev);
                                break;
                            case RawInputEvent.CLASS_TOUCHSCREEN:
                                dispatchPointer(ev, (MotionEvent)ev.event, 0, 0);//touch事件,派發touch事件
                                break;
                            case RawInputEvent.CLASS_TRACKBALL:
                                dispatchTrackball(ev, (MotionEvent)ev.event, 0, 0);//滾輪事件,派發Trackball事件
                                break;
                            case RawInputEvent.CLASS_CONFIGURATION_CHANGED:
                                configChanged = true;
                                break;
                            default:
                                mQueue.recycleEvent(ev);//銷燬事件
                            break;
                        }

                    } 
                } catch (Exception e) {
                    Slog.e(TAG,
                        "Input thread received uncaught exception: " + e, e);
                }
            }        
   }

WindowManagerService.dispatchPointer,一旦判斷QueuedEvent爲屏幕點擊事件,就調用函數WindowManagerService.dispatchPointer進行處理:
WindowManagerService.dispatchPointer
-->WindowManagerService.KeyWaiter.waitForNextEventTarget(獲取touch事件要派發的目標windowSate)
   -->WindowManagerService.KeyWaiter.findTargetWindow(從一個一個WindowSate的z-order順序列表mWindow中獲取一個能夠接收當前touch事件的WindowSate)
-->WindowSate target = waitForNextEventTarget返回的WindowSate對象
-->target.mClient.dispatchPointer(ev, eventTime, true);(往目標window派發touch消息
target.mClient是一個IWindow代理對象IWindow.Proxy,它對應的代理類是ViewRoot.W,通過遠程代理調用,WindowManagerService把touch消息派發到了對應的Activity的PhoneWindow
之後進一步WindowManagerService到Activity消息的派發在下文中說明

二WindowManagerService派發Touch事件到當前top Activity

--1.先我們看一個system_process的touch事件消息調用堆棧,在WindowManagerService中的函數dispatchPointer,通過一個IWindow的客戶端代理對象把消息發送到相應的IWindow服務端,也就是一個IWindow.Stub子類。
Thread [<21> InputDispatcher] (Suspended (breakpoint at line 321 in IWindow$Stub$Proxy))       
        IWindow$Stub$Proxy.dispatchPointer(MotionEvent, long, boolean) line: 321       
        WindowManagerService.dispatchPointer(KeyInputQueue$QueuedEvent, MotionEvent, int, int) line: 5270              
        WindowManagerService$InputDispatcherThread.process() line: 6602        
        WindowManagerService$InputDispatcherThread.run() line: 6482  

--2.通過IWindow.Stub.Proxy代理對象把消息傳遞給IWindow.Stub對象。code=TRANSACTION_dispatchPointer,IWindow.Stub對象被ViewRoot擁有(成員mWindow,它是一個ViewRoot.W類對象)

--3.在case TRANSACTION_dispatchPointer會調用IWindow.Stub子類的實現方法dispatchPointer

--4.IWindow.Stub.dispatchPointer
        -->ViewRoot.W.dispatchPointer
                -->ViewRoot.dispatchPointer
    public void dispatchPointer(MotionEvent event, long eventTime,
            boolean callWhenDone) {
        Message msg = obtainMessage(DISPATCH_POINTER);
        msg.obj = event;
        msg.arg1 = callWhenDone ? 1 : 0;
        sendMessageAtTime(msg, eventTime);
    }

--5.ViewRoot繼承自handle,在handleMessage函數的case-DISPATCH_POINTER會調用mView.dispatchTouchEvent(event),
mView是一個PhoneWindow.DecorView對象,在PhoneWindow.openPanel方法會創建一個ViewRoot對象,並設置ViewRoot對象的mView爲一個PhoneWindow.decorView成員,PhoneWindow.DecorView是真正的root view,它繼承自FrameLayout,這樣調用mView.dispatchTouchEvent(event)
其實就是調用PhoneWindow.decorView的dispatchTouchEvent方法:
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            final Callback cb = getCallback();
            return cb != null && mFeatureId < 0 ? cb.dispatchTouchEvent(ev) : super
                    .dispatchTouchEvent(ev);
        } 

--6.分析上面一段紅色代碼,可以寫成return (cb != null) && (mFeatureId < 0 ? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev)).當cb不爲null執行後面,如果mFeatureId<0,執行cb.dispatchTouchEvent(ev),否則執行super.dispatchTouchEvent(ev),也就是FrameLayout.dispatchTouchEvent(ev),那麼callback cb是什麼呢?是Window類的一個成員mCallback,我下面給一個圖你可以看到何時被賦值的:
setCallback(Callback) : void - android.view.Window
        -->attach(Context, ActivityThread, Instrumentation, IBinder, int, Application, Intent, ActivityInfo, CharSequence, Activity, String, Object, HashMap<String, Object>, Configuration) : void - android.app.Activity
               --> performLaunchActivity(ActivityRecord, Intent) : Activity - android.app.ActivityThread
performLaunchActivity我們很熟識,因爲我前面在講Activity啓動過程詳解時候講過,在啓動一個新的Activity會執行該方法,在該方法裏面會執行attach方法,找到attach方法對應代碼可以看到:
        mWindow = PolicyManager.makeNewWindow(this);
        mWindow.setCallback(this);
mWindow就是一個PhoneWindow,它是Activity的一個內部成員,通過調用mWindow的setCallback(this),把新建立的Activity設置爲PhoneWindow一個mCallback成員,這樣我們就清楚了,前面的cb就是擁有這個PhoneWindow的Activity,cb.dispatchTouchEvent(ev)也就是執行:Activity.dispatchTouchEvent
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        //getWindow()返回的就是PhoneWindow對象,執行superDispatchTouchEvent,就是執行PhoneWindow.superDispatchTouchEvent
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        //執行Activity.onTouchEvent方法
        return onTouchEvent(ev);
    }

--7.再看PhoneWindow.superDispatchTouchEvent:
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);
                -->        public boolean superDispatchTouchEvent(MotionEvent event) {
                                    return super.dispatchTouchEvent(event);//FrameLayout.dispatchTouchEvent
        }
    }
superDispatchTouchEvent調用super.dispatchTouchEvent,我前面講過mDector是一個PhoneWindow.DecorView,它是一個真正Activity的root view,它繼承了FrameLayout,通過super.dispatchTouchEvent他會把touchevent派發給各個activity的子view,也就是我們再Activity.onCreat方法中setContentView時設置的view,touch event時間如何在Activity各個view中進行派發的我後面再作詳細說明,但是從上面我們可以看出一點若Activity下面的子view攔截了touchevent事件(返回true),Activity.onTouchEvent就不會執行。

--8.這部分,我再畫一個靜態類結構圖把前面講到的一些類串起來看一下:

我用紅色箭頭線把整個消息派發過程過程給串起來,然後system_process進程和ap進程分別用虛線橢圓圈起,這樣以後相信你更理解各個類之間關係。

對應的對象空間圖如下,與上面圖是對應的,只是從不同角度去看:

--9.其實上面所講的大部分已經是在客戶端ap中執行了,也就是在ap進程中,只是執行邏輯基本是框架代碼中,還沒有到達我們使用layout.xml佈局的view中來,這裏我先在我們的一個view中onTouchEvent插入一個斷點看一看消息從WindowManagerService到達Activity.PhoneWindow後執行堆棧情況(我插入的斷點在Launcher2的HandleView中),後面繼續講解:
Thread [<1> main] (Suspended (breakpoint at line 4280 in View))        
        HandleView(View).onTouchEvent(MotionEvent) line: 4280        
        HandleView.onTouchEvent(MotionEvent) line: 71        
        HandleView(View).dispatchTouchEvent(MotionEvent) line: 3766        
        RelativeLayout(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863       
        DragLayer(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863        
        FrameLayout(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863        
        PhoneWindow$DecorView(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863       
        PhoneWindow$DecorView.superDispatchTouchEvent(MotionEvent) line: 1671       
        PhoneWindow.superDispatchTouchEvent(MotionEvent) line: 1107        
        ForyouLauncher(Activity).dispatchTouchEvent(MotionEvent) line: 2086       
        PhoneWindow$DecorView.dispatchTouchEvent(MotionEvent) line: 1655        
        ViewRoot.handleMessage(Message) line: 1785        
        ViewRoot(Handler).dispatchMessage(Message) line: 99        
        Looper.loop() line: 123        
        ActivityThread.main(String[]) line: 4634

三.Activity中View中的Touch事件派發

--1.首先我畫一個Activity中的view層次結構圖:

前面我講過,來自windowManagerService的touch消息最終會派發到到Decorview,Decorview繼承子FrameLayout,它只有一個子view就是mContentParent,我們寫ap的view全部添加到到mContentParent。

--2.瞭解了Activity中的view的層次結構,那先從DecorView開始看touch事件是如何被派發的,前面講過最終消息會派發到FrameLayout.dispatchTouchEvent也就是ViewGroup.dispatchTouchEvent(FrameLayout也沒有覆蓋該方法),
同樣mContentParent也是執行ViewGroup.dispatchTouchEvent來派發touch消息,那我們就詳細看一下ViewGroup.dispatchTouchEvent(若要很好掌握應用程序touch事件處理,這部分要重點看):
    public boolean dispatchTouchEvent(MotionEvent ev) {
        ......
        boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//計算是否禁止touch Intercept
        if (action == MotionEvent.ACTION_DOWN) {//按下事件,也就是touch開始
            if (mMotionTarget != null) {
                mMotionTarget = null;//清除mMotionTarget,也就是說每次touch開始,mMotionTarget要被重新設置
            }
            if (disallowIntercept || !onInterceptTouchEvent(ev)) {//判斷消息是否需要被viewGroup攔截
                // 消息不被viewGroup攔截,找到相應的子view進行touch事件派發
                ev.setAction(MotionEvent.ACTION_DOWN);//重新設置event 爲action_down
               
                final int scrolledXInt = (int) scrolledXFloat;
                final int scrolledYInt = (int) scrolledYFloat;
                final View[] children = mChildren;//獲取viewgroup所有的子view
                final int count = mChildrenCount;
                for (int i = count - 1; i >= 0; i--) {
                    final View child = children[i];
                    if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE
                            || child.getAnimation() != null) {//若子view可見或者有動畫在執行的,才能夠接收touch事件
                        child.getHitRect(frame);//獲取子view的佈局座標區域
                        if (frame.contains(scrolledXInt, scrolledYInt)) {//若子view 區域包含當前touch點擊區域
                            // offset the event to the view's coordinate system
                            final float xc = scrolledXFloat - child.mLeft;
                            final float yc = scrolledYFloat - child.mTop;
                            ev.setLocation(xc, yc);
                            child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
                            if (child.dispatchTouchEvent(ev))  {//派發TouchEvent給包含這個touch區域的子view
                                // 若該子view消費了對應的touch事件
                                mMotionTarget = child;//設置viewgroup消息派發的目標子view
                                return true;//返回true,該touch事件被消費掉
                            }
                        }
                    }
                }
            }
          //若touch事件被攔截,mMotionTarget = null,後面touch消息不再派發給子view
        }

        boolean isUpOrCancel = (action == MotionEvent.ACTION_UP) ||//計算是up或者cancel
                (action == MotionEvent.ACTION_CANCEL);

        if (isUpOrCancel) {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        }

       
        final View target = mMotionTarget;
        if (target == null) {
            //target爲null,意味着在ACTION_DOWN時沒有找到能消費touch消息的子view或者在ACTION_DOWN時消息被攔截了,這個時候
            //調用父類view的dispatchTouchEvent消息進行派發,也就是說,此時viewgroup處理touch消息跟普通view一致。
            ev.setLocation(xf, yf);
            if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) {
                ev.setAction(MotionEvent.ACTION_CANCEL);
                mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
            }
            return super.dispatchTouchEvent(ev);
        }

        //target!=null,意味在ACTION_DOWN時touch消息沒有被攔截,而且子view target消費了ACTION_DOWN消息,需要再判斷消息是否被攔截
        if (!disallowIntercept && onInterceptTouchEvent(ev)) {
            //消息被攔截,而前面ACTION_DOWN時touch消息沒有被攔截,所以需要發送ACTION_CANCEL通知子view target
            final float xc = scrolledXFloat - (float) target.mLeft;
            final float yc = scrolledYFloat - (float) target.mTop;
            mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
            ev.setAction(MotionEvent.ACTION_CANCEL);
            ev.setLocation(xc, yc);
            if (!target.dispatchTouchEvent(ev)) {
                // 派發消息ACTION_CANCEL給子view target
            }
            // mMotionTarget=null,後面消息不再派發給子view
            mMotionTarget = null;
            return true;
        }

        if (isUpOrCancel) {
            //isUpOrCancel,設置mMotionTarget=null,後面消息不再派發給子view
            mMotionTarget = null;
        }

        ......
        //沒有被攔截繼續派發消息給子view target
        return target.dispatchTouchEvent(ev);
    }

--3.ViewGroup.dispatchTouchEvent我查看了一下所有子類,只有PhoneWindow.DecorView覆蓋了該方法,該方法前面講DecorView消息派發時提過,它會找到對應包含這個PhoneWindow.DecorView對象的Activity把消息交給Activity去處理,其它所有viewGroup的子類均沒有覆蓋dispatchTouchEvent,也就是說所有包含子view的父view對於touch消息派發均採用上面的邏輯,當然,必要的時候我們可以覆蓋該方法實現自己的touch消息派發邏輯,如Launcher2中的workspace類就是重新實現的該dispatchTouchEvent方法,從上面的dispatchTouchEvent函數邏輯其實我們也可以總結幾條touch消息派發邏輯:
(1).onInterceptTouchEvent用來定義是否截取touch消息邏輯,若在groupview中想截取touch消息,必須覆蓋viewgroup中該方法
(2).消息在整個dispatchTouchEvent過程中,若子view.dispatchTouchEvent返回true,父view中將不再處理該消息,但前提是該消息沒有被父view截取,在整個touch消息處理過程中,若處理函數返回true,我們稱之爲消費了該touch事件,並且後面的父view將不再處理該消息。
(3).在整個touch事件過程中,從action_down到action_up,若父ViewGroup的函數onInterceptTouchEvent一旦返回true,消息將不再派發給子view,細分可爲兩種情況,若是在action_down時onInterceptTouchEvent返回true,不會派發任何消息給子view,並且後面onInterceptTouchEvent函數將不再會被執行若是action_down時onInterceptTouchEvent返回false ,而後面touch過程中onInterceptTouchEvent==true,父viewGroup會把action_cancel派發給子view,也之後不再派發消息給子view,並且onInterceptTouchEvent函數後面將不再被執行。

--4.爲了更清楚的理解viewGroup消息的派發流程,我畫一個流程圖如下:

--5.上面我只是講了父view與子view之間當有touch事件的消息派發流程,對於view的消息是怎麼派發的(也包裹viewGroup沒有子view或者有子view但是不消費該touch消息情況),因爲從繼承結構上看viewgroup繼承了view,viewgroup覆蓋了view的dispatchTouchEvent方法,不過從上面流程圖也可以看到當mMotionTarget爲Null它會執行父類view.dispatchTouchEvent,其他view的子類都是執行view.dispatchTouchEvent派發touch事件,不過若我們自定義view是可以覆蓋該方法的。下面就仔細研究一下view.dispatchTouchEvent方法的代碼:
    public final boolean dispatchTouchEvent(MotionEvent event) {
        //mOnTouchListener是被View.setOnTouchListener設置的,(mViewFlags & ENABLED_MASK)計算view是否可被點擊
        //當view可被點擊並且mOnTouchListener被設置,執行mOnTouchListener.onTouch
        if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&
                mOnTouchListener.onTouch(this, event)) {
            return true;//若mOnTouchListener.onTouch返回true,函數返回true
        }
        return onTouchEvent(event);//若mOnTouchListener.onTouch返回false,調用onToucheEvent
    }
函數邏輯很簡單,前面的viewGroup touch事件流程圖中我已經畫出的,爲區別我把它着色成青綠色,總結一句話若mOnTouchListener處理了touch消息,不執行onTouchEvent,否則交給onTouchEvent進行處理。

不知道是否講清楚的,要清楚掌握估計還得寫些例子測試一下是否是我上面所說的流程,不過我想了解事件的派發流程,對寫應用的事件處理相信很有用,比如我以前碰到一個問題是手指點擊屏幕到底是子view執行onclick還是執行父view的view移動,這個時候就需要深入瞭解viewde touch事件派發流程,該響應點擊的時候響應子view的點擊,該父view移動的時候攔截touch事件交給父view進行處理。

轉載地址 http://blog.csdn.net/stonecao/article/details/6759189

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章