Android FrameWork——Touch事件派發過程詳解

轉自:http://blog.csdn.net/stonecao/article/details/6759189

對於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執行 touch事件派發流程,該響應點擊的時候響應子view的點擊,該父view移動的時候攔截touch事件交給父view進行處理。


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