從源碼出發淺析Android TV的焦點移動原理

焦點:

焦點(Focus)可以理解為選中態,在Android TV上起很重要的作用。一個視圖控件只有在獲得焦點的狀態下,才能響應按鍵的Click事件。


image

上圖中,外面有一個綠色光圈的視圖,就是當前有焦點的視圖。

相對于手機上用手指點擊屏幕產生的Click事件, 在使用Android TV的過程中,遙控器是一個主流的操作工具,通過點擊遙控器的方向鍵來控制焦點的移動。當焦點移動到目標控件上之后,按下遙控器的確定鍵,才會觸發一個Click事件,進而去做下一步的處理。焦點的移動如下圖所示。


image

基礎的用法:

在處理焦點的時候,有一些基礎的用法需要知道。
首先,isFocusable()需要為true,一個控件才有資格可以獲取到焦點,可以通過setFocusable(boolean)方法來設置。如果想要在觸摸模式下獲取焦點(在我們用手機開發的過程中),需要isFocusableInTouchMode()為true,可以通過setFocusableInTouchMode(boolean)來設置。也可以直接在xml布局文件中指定:

<Button
    ...
    android:focusable="true"
    android:focusableInTouchMode="true"/>

然后,就是控制焦點的移動了。在谷歌官方文檔中提到:
焦點移動的時候(默認的情況下),會按照一種算法去找在指定移動方向上最近的鄰居。在一些情況下,焦點的移動可能跟開發者的意圖不符,這時開發者可以在布局文件中使用下面這些XML屬性來指定下一個焦點對象:

nextFocusDown
nextFocusLeft
nextFocusRight
nextFocusUp 

在Java代碼中,讓一個指定的View獲取焦點,可以調用它的requestFocus()方法。

遇到的問題:

盡管有了官方文檔中提到的基礎用法,但是在進行Android TV開發的過程中,還是經常會遇到一些焦點方面的問題或者疑問,如

“明明指定了焦點id,焦點卻跑丟了”
“onKeyDown里居然截獲不到按鍵事件”
“我沒有做任何焦點處理,焦點是怎么自己跑到那個View上的”
接下來,帶著這些問題,我們就從源碼的角度出發,簡單分析一下焦點的移動原理。本文以API 23作為參考。

KeyEvent

在手機上,當手指觸摸屏幕時,會產生一個的觸摸事件,MotionEvent,進而完成點擊,長按,滑動等行為。
而當按下遙控器的按鍵時,會產生一個按鍵事件,就是KeyEvent,包含“上”,“下”,“左”,“右”,“返回”,“確定”等指令。焦點的處理就在KeyEvent的分發當中完成。
首先,KeyEvent會流轉到ViewRootImpl中開始進行處理,具體方法是內部類ViewPostImeInputStage中的processKeyEvent。(在API 17之前,是deliverKeyEventPostIme這個方法,邏輯大體一致,本文僅以processKeyEvent作為參考)

private int processKeyEvent(QueuedInputEvent q) {
        final KeyEvent event = (KeyEvent)q.mEvent;
        ...
        // Deliver the key to the view hierarchy.
        // 1. 先去執行mView的dispatchKeyEvent
        if (mView.dispatchKeyEvent(event)) {
            return FINISH_HANDLED;
        }
        ...
        // Handle automatic focus changes.
        if (event.getAction() == KeyEvent.ACTION_DOWN) {
            int direction = 0;
            ...
            if (direction != 0) {
                View focused = mView.findFocus();
                if (focused != null) {
                    // 2. 之后會通過focusSearch去找下一個焦點視圖
                    View v = focused.focusSearch(direction);
                    if (v != null && v != focused) {
                        ...
                        if (v.requestFocus(direction, mTempRect)) {
                            ...
                            return FINISH_HANDLED;
                        }
                    }

                    // Give the focused view a last chance to handle the dpad key.
                    if (mView.dispatchUnhandledMove(focused, direction)) {
                        return FINISH_HANDLED;
                    }
                } else {
                    // find the best view to give focus to in this non-touch-mode with no-focus
                    // 3. 如果當前本來就沒有焦點視圖,也會通過focusSearch找一個視圖
                    View v = focusSearch(null, direction);
                    if (v != null && v.requestFocus(direction)) {
                        return FINISH_HANDLED;
                    }
                }
            }
        }
        return FORWARD;
    }

從幾處關鍵的代碼,可以看到這里的邏輯是:

  1. 先去執行mView的dispatchKeyEvent
  2. 之后會通過focusSearch去找下一個焦點視圖
  3. 如果當前本來就沒有焦點View,也會通過focusSearch找一個視圖

ViewRootImpl就是ViewRoot,繼承了ViewParent,但本身并不是一個View,可以看作是View樹的管理者。而這里的成員變量mView就是DecorView,它指向的對象跟Window和Activity的mDecor指向的對象是同一個對象。所有的View組成了一個View樹,每一個View都是樹中的一個節點,如下圖所示:


image

最上層的根是DecorView,中間是各ViewGroup,最下層是View。
本文的分析都是基于View樹的。

在processKeyEvent中,首先走了mView的dispatchKeyEvent,也就是從DecorView開始進行KeyEvent的分發。

  1. dispatchKeyEvent

首先走DecorView的dispatchKeyEvent,之后會依次從Activity->ViewGroup->View的方向分發KeyEvent。
有興趣的話可以通過trace看一下KeyEvent的流轉方向:


image

對于KeyEvent的分發,之后會另開一篇細講,包括KeyEvent的處理優先級,長按的識別等,這里只簡單看一下ViewGroup和View的dispatchKeyEvent。

首先看ViewGroup的dispatchKeyEvent。

@Override
public boolean dispatchKeyEvent(KeyEvent event) {
    if ((mPrivateFlags & (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS))
            == (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS)) {
        // 1.1 以View的身份處理KeyEvent
        if (super.dispatchKeyEvent(event)) {
            return true;
        }
    } else if (mFocused != null && (mFocused.mPrivateFlags & PFLAG_HAS_BOUNDS)
            == PFLAG_HAS_BOUNDS) {
        // 1.2 以ViewGroup的身份把KeyEvent交給mFocused處理
        if (mFocused.dispatchKeyEvent(event)) {
            return true;
        }
    }
    return false;
}

通過flag的判斷,有兩個處理路徑,也可以看到在處理keyEvent時,ViewGroup扮演兩個角色: 
1. View的角色,也就是此時keyEvent需要在自己與其他View之間流轉 
2. ViewGroup的角色,此時keyEvent需要在自己的子View之間流轉

當作View的時候,會調用自己View的dispatchKeyEvent。 
當作ViewGroup的時候,會調用當前焦點View的dispatchKeyEvent。 
其實,從概念上來看,都是調用當前有焦點View的dispatchKeyEvent,只不過有時是自己本身,有時是他的子View。

再看看View的dispatchKeyEvent

public boolean dispatchKeyEvent(KeyEvent event) {
ListenerInfo li = mListenerInfo;
// 1.3 如果設置了mOnKeyListener,則優先走onKey方法
if (li != null && li.mOnKeyListener != null && (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnKeyListener.onKey(this, event.getKeyCode(), event)) {
return true;
}
// 1.4 把View自己當作參數傳入,調用KeyEvent的dispatch方法
if (event.dispatch(this, mAttachInfo != null
? mAttachInfo.mKeyDispatchState : null, this)) {
return true;
}
return false;
}

View這里,會優先處理OnKeyListener的onKey回調。 
然后才可能會走KeyEvent的dispatch,最終走到View的OnKeyDown或者OnKeyUp。

將大體的流轉順序總結如下圖 

![](https://img-blog.csdn.net/20170306223500360?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvYXJjaGVyX3pvcm8=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
其中任何一步都可以通過return true的方式來消費掉這個KeyEvent,結束這個分發過程。

2. focusSearch

如果dispatchKeyEvent沒有消費掉這個KeyEvent,會由系統來處理焦點的移動。 
通過View的focusSearch方法找到下一個獲取焦點的View,然后調用requestFocus

那focusSearch是如何找到下一個焦點視圖的呢?

// View.java
public View focusSearch(@FocusRealDirection int direction) {
if (mParent != null) {
return mParent.focusSearch(this, direction);
} else {
return null;
}
}

View并不會直接去找,而是交給它的parent去找。

// ViewGroup.java
public View focusSearch(View focused, int direction) {
if (isRootNamespace()) {
// root namespace means we should consider ourselves the top of the
// tree for focus searching; otherwise we could be focus searching
// into other tabs. see LocalActivityManager and TabHost for more info
return FocusFinder.getInstance().findNextFocus(this, focused, direction);
} else if (mParent != null) {
return mParent.focusSearch(focused, direction);
}
return null;
}

判斷是否為頂層布局,若是則執行對應方法,若不是則繼續向上尋找,說明會從內到外的一層層進行判斷,直到最外層的布局為止。 
有意思的是,Android提供了設置isRootNamespace的方法,但又hide了起來不讓使用,看來這個邏輯還有待優化。

/**

  • {@hide}
  • @param isRoot true if the view belongs to the root namespace, false
  •    otherwise
    

*/
public void setIsRootNamespace(boolean isRoot) {
if (isRoot) {
mPrivateFlags |= PFLAG_IS_ROOT_NAMESPACE;
} else {
mPrivateFlags &= ~PFLAG_IS_ROOT_NAMESPACE;
}
}

最后的算法交給了FocusFinder

FocusFinder.getInstance().findNextFocus(this, focused, direction);
1
isRootNamespace()的ViewGroup把自己和當前焦點(View)以及方向傳入。

// FocusFinder.java

public final View findNextFocus(ViewGroup root, View focused, int direction) {
return findNextFocus(root, focused, null, direction);
}

private View findNextFocus(ViewGroup root, View focused, Rect focusedRect, int direction) {
View next = null;
if (focused != null) {
// 2.1 優先從xml或者代碼中指定focusid的View中找
next = findNextUserSpecifiedFocus(root, focused, direction);
}
if (next != null) {
return next;
}
ArrayList<View> focusables = mTempList;
try {
focusables.clear();
root.addFocusables(focusables, direction);
if (!focusables.isEmpty()) {
// 2.2 其次,根據算法去找,原理就是找在方向上最近的View
next = findNextFocus(root, focused, focusedRect, direction, focusables);
}
} finally {
focusables.clear();
}
return next;
}

這里root是上面isRootNamespace()為true的ViewGroup,focused是當前焦點視圖 
1. 優先找開發者指定的下一個focus的視圖 ,就是在xml或者代碼中指定NextFocusDirection Id的視圖 
2. 其次,根據算法去找,原理就是找在方向上最近的視圖

2.1 findNextUserSpecifiedFocus

// FocusFinder.java
private View findNextUserSpecifiedFocus(ViewGroup root, View focused, int direction) {
// check for user specified next focus
View userSetNextFocus = focused.findUserSetNextFocus(root, direction);
if (userSetNextFocus != null && userSetNextFocus.isFocusable()
&& (!userSetNextFocus.isInTouchMode()
|| userSetNextFocus.isFocusableInTouchMode())) {
return userSetNextFocus;
}
return null;

首先執行View的findUserSetNextFocus方法

// View.java
View findUserSetNextFocus(View root, @FocusDirection int direction) {
switch (direction) {
case FOCUS_LEFT:
if (mNextFocusLeftId == View.NO_ID) return null;
return findViewInsideOutShouldExist(root, mNextFocusLeftId);
}
}
return null;
}

比如,按了“左”方向鍵,如果設置了mNextFocusLeftId,則會通過findViewInsideOutShouldExist去找這個View。 
mNextFocusLeftId一般是在xml里面設置的,比如

<Button
android:id="@+id/btn_1"
android:nextFocusLeft="@+id/btn_2"
... />

也可以在java代碼里設置

mBtn1.setNextFocusLeftId(R.id.btn_2);
1
來看看findViewInsideOutShouldExist做了什么。

private View findViewInsideOutShouldExist(View root, int id) {
if (mMatchIdPredicate == null) {
// 可以理解為一個判定器,如果id匹配則判定成功
mMatchIdPredicate = new MatchIdPredicate();
}
mMatchIdPredicate.mId = id;
View result = root.findViewByPredicateInsideOut(this, mMatchIdPredicate);
return result;
}
public final View findViewByPredicateInsideOut(View start, Predicate<View> predicate) {
View childToSkip = null;
for (;;) {
// 從當前起始節點開始尋找(ViewGroup是遍歷自己的child),尋找id匹配的View,跳過childToSkip,具體可去看View和ViewGroup中該方法的具體實現
View view = start.findViewByPredicateTraversal(predicate, childToSkip);
if (view != null || start == this) {
return view;
}

    ViewParent parent = start.getParent();
    if (parent == null || !(parent instanceof View)) {
        return null;
    }

    // 如果如果當前節點沒有,則往上一級,從自己的parent中查找,并跳過自己
    childToSkip = start;
    start = (View) parent;
}

}

protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) {
if (predicate.apply(this)) {
return this;
}
return null;
}

ViewGroup的findViewByPredicateTraversal

// ViewGroup
@Override
protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) {
if (predicate.apply(this)) {
return this;
}

final View[] where = mChildren;
final int len = mChildrenCount;

for (int i = 0; i < len; i++) {
    View v = where[i];

    if (v != childToSkip && (v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
        v = v.findViewByPredicate(predicate);

        if (v != null) {
            return v;
        }
    }
}

return null;

}

可以看到,findViewInsideOutShouldExist這個方法從當前指定視圖去尋找指定id的視圖。首先從自己開始向下遍歷,如果沒找到則從自己的parent開始向下遍歷,直到找到id匹配的視圖為止。 
這里要注意的是,也許存在多個相同id的視圖(比如ListView,RecyclerView,ViewPager等場景),但是這個方法只會返回在View樹中節點范圍最近的一個視圖,這就是為什么有時候看似指定了focusId,但實際上焦點卻丟失的原因,因為焦點跑到了另一個“意想不到”的相同id的視圖上。

2.2 findNextFocus

如果開發者沒有指定nextFocusId,則用findNextFocus找指定方向上最近的視圖 
看一下這里的用法

focusables.clear();
// 2.2.1 找到所有isFocusable的View
root.addFocusables(focusables, direction);
if (!focusables.isEmpty()) {
// 2.2.2 從focusables中找到最近的一個
next = findNextFocus(root, focused, focusedRect, direction, focusables);
}

2.2.1 View.addFocusables,從root開始找所有isFocusable的視圖

public void addFocusables(ArrayList<View> views, @FocusDirection int direction) {
addFocusables(views, direction, FOCUSABLES_TOUCH_MODE);
}

public void addFocusables(ArrayList<View> views, @FocusDirection int direction,
@FocusableMode int focusableMode) {
views.add(this);
}

如果root是一個單純View,則添加自己,但這種情況很少見,大部分的root都是ViewGroup

// ViewGroup.java
public void addFocusables(ArrayList<View> views, int direction, int focusableMode) {
final int focusableCount = views.size();

final int descendantFocusability = getDescendantFocusability();

if (descendantFocusability != FOCUS_BLOCK_DESCENDANTS) {
    ...
    final int count = mChildrenCount;
    final View[] children = mChildren;

    for (int i = 0; i < count; i++) {
        final View child = children[i];
        if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE) {
            child.addFocusables(views, direction, focusableMode);
        }
    }
}

if ((descendantFocusability != FOCUS_AFTER_DESCENDANTS
        // No focusable descendants
        || (focusableCount == views.size())) &&
        (isFocusableInTouchMode() || !shouldBlockFocusForTouchscreen())) {
    super.addFocusables(views, direction, focusableMode);
}

}

對于ViewGroup來說,遍歷并添加自己的所有isFocusable的child 
這里有個descendantFocusability變量,有三個取值

FOCUS_BEFORE_DESCENDANTS:在所有子視圖之前獲取焦點
FOCUS_AFTER_DESCENDANTS: 在所有子視圖之后獲取焦點
FOCUS_BLOCK_DESCENDANTS: 阻止所有子視圖獲取焦點,即使他們是focusable的
2.2.2 FocusFinder.findNextFocus

private View findNextFocus(ViewGroup root, View focused, Rect focusedRect,
int direction, ArrayList<View> focusables) {
if (focused != null) {
if (focusedRect == null) {
focusedRect = mFocusedRect;
}
// 2.2.2.1 取得考慮scroll之后的焦點Rect,該Rect是相對focused視圖本身的
// fill in interesting rect from focused
focused.getFocusedRect(focusedRect);
// 2.2.2.2 將當前focused視圖的坐標系,轉換到root的坐標系中,統一坐標,以便進行下一步的計算
root.offsetDescendantRectToMyCoords(focused, focusedRect);
} else {

}

switch (direction) {

    case View.FOCUS_UP:
    case View.FOCUS_DOWN:
    case View.FOCUS_LEFT:
    case View.FOCUS_RIGHT:
        2.2.2.3 找出指定方向上的下一個focus視圖
        return findNextFocusInAbsoluteDirection(focusables, root, focused,
                focusedRect, direction);
    default:
        throw new IllegalArgumentException("Unknown direction: " + direction);
}

}

2.2.2.1 focused.getFocusedRect(focusedRect);

public void getFocusedRect(Rect r) {
getDrawingRect(r);
}

public void getDrawingRect(Rect outRect) {
outRect.left = mScrollX;
outRect.top = mScrollY;
outRect.right = mScrollX + (mRight - mLeft);
outRect.bottom = mScrollY + (mBottom - mTop);
}

這里是取得考慮scroll之后的焦點Rect,該Rect是相對focused視圖本身的

2.2.2.2 root.offsetDescendantRectToMyCoords(focused, focusedRect);

public final void offsetDescendantRectToMyCoords(View descendant, Rect rect) {
offsetRectBetweenParentAndChild(descendant, rect, true, false);
}

/**

  • Helper method that offsets a rect either from parent to descendant or

  • descendant to parent.
    */
    void offsetRectBetweenParentAndChild(View descendant, Rect rect,
    boolean offsetFromChildToParent, boolean clipToBounds) {

    // already in the same coord system :)
    if (descendant == this) {
    return;
    }

    ViewParent theParent = descendant.mParent;

    // search and offset up to the parent
    // 在View樹上往上層層遍歷,直到root為止
    while ((theParent != null)
    && (theParent instanceof View)
    && (theParent != this)) {

     if (offsetFromChildToParent) {
         // 把focusedRect轉換到當前當前parent的坐標系中去
         rect.offset(descendant.mLeft - descendant.mScrollX,
                 descendant.mTop - descendant.mScrollY);
     } else {
         rect.offset(descendant.mScrollX - descendant.mLeft,
                 descendant.mScrollY - descendant.mTop);
     }
    
     // 繼續往上找
     descendant = (View) theParent;
     theParent = descendant.mParent;
    

    }

    // now that we are up to this view, need to offset one more time
    // to get into our coordinate space
    if (theParent == this) {
    if (offsetFromChildToParent) {
    // 最后再轉換一次,終于把focusedRect的坐標轉換到了root的坐標中
    rect.offset(descendant.mLeft - descendant.mScrollX,
    descendant.mTop - descendant.mScrollY);
    } else {
    rect.offset(descendant.mScrollX - descendant.mLeft,
    descendant.mScrollY - descendant.mTop);
    }
    } else {
    throw new IllegalArgumentException("parameter must be a descendant of this view");
    }
    }

經過層層轉換,最終把focused視圖的坐標,轉換到了root坐標系中。這樣就統一了坐標,以便進行下一步的計算。

2.2.2.3 找出指定方向上的下一個focus視圖

findNextFocusInAbsoluteDirection(focusables, root, focused, focusedRect, direction);

View findNextFocusInAbsoluteDirection(ArrayList<View> focusables, ViewGroup root, View focused,
Rect focusedRect, int direction) {
// initialize the best candidate to something impossible
// (so the first plausible view will become the best choice)
mBestCandidateRect.set(focusedRect);
switch(direction) {
case View.FOCUS_LEFT:
// 先虛構出一個默認候選Rect,就是把focusedRect向右移一個"身位",按鍵向左,那么他肯定就是優先級最低的了
mBestCandidateRect.offset(focusedRect.width() + 1, 0);
break;

}

View closest = null;

int numFocusables = focusables.size();
// 遍歷所有focusable的視圖
for (int i = 0; i < numFocusables; i++) {
    View focusable = focusables.get(i);

    // only interested in other non-root views
    if (focusable == focused || focusable == root) continue;

    // get focus bounds of other view in same coordinate system
    focusable.getFocusedRect(mOtherRect);
    // 將focusable的坐標轉換到root的坐標系中,統一坐標
    root.offsetDescendantRectToMyCoords(focusable, mOtherRect);

    // 進行比較,選出較好的那一個,如果都是默認候選的Rect差,則closest為null
    if (isBetterCandidate(direction, focusedRect, mOtherRect, mBestCandidateRect)) {
        mBestCandidateRect.set(mOtherRect);
        closest = focusable;
    }
}
return closest;

}

在統一坐標之后,對于所有focusable的視圖,進行一次遍歷比較,得到最“近”的視圖作為下一個焦點視圖。這里用到了一個方法isBetterCandidate,從兩個候選Rect中找到在指定方向上離當前Rect最近的一個,具體算法這里不細講了。

至此,就找到了下一個焦點視圖,然后調用requestFocus方法,讓其獲得焦點。

小結

經過對源碼的分析,系統本身尋找下一個焦點視圖的過程是: 
1. 首先尋找用戶指定了id的視圖,從當前焦點視圖的節點開始遍歷,直到找到匹配該id的視圖。也許存在多個相同id的視圖,但是只會找到視圖節點樹中最近的一個。 
2. 如果沒有指定id,則遍歷找出所有isFocusable的視圖,統一坐標系,然后計算出指定方向上離當前焦點視圖最近的一個視圖。

結合KeyEvent事件的流轉,處理焦點的時機,按照優先級(順序)依次是: 
1. dispatchKeyEvent 
2. mOnKeyListener.onKey回調 
3. onKeyDown/onKeyUp 
4. focusSearch 
5. 指定nextFocusId 
6. 系統自動從所有isFocusable的視圖中找下一個焦點視圖

以上任一處都可以指定焦點,一旦使用了就不再往下走。

很多視圖控件就重寫了其中一些方法。 
比如ScrollView,它會在dispatchKeyEvent的時候,自己去處理,用來進行內部的焦點移動或者整體滑動。

// ScrollView.java
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// Let the focused view and/or our descendants get the key first
return super.dispatchKeyEvent(event) || executeKeyEvent(event);
}

public boolean executeKeyEvent(KeyEvent event) {
mTempRect.setEmpty();

if (!canScroll()) {
    if (isFocused() && event.getKeyCode() != KeyEvent.KEYCODE_BACK) {
        View currentFocused = findFocus();
        if (currentFocused == this) currentFocused = null;
        View nextFocused = FocusFinder.getInstance().findNextFocus(this,
                currentFocused, View.FOCUS_DOWN);
        // 如果不能滑動,則直接讓下一個Focus視圖獲取焦點
        return nextFocused != null
                && nextFocused != this
                && nextFocused.requestFocus(View.FOCUS_DOWN);
    }
    return false;
}

boolean handled = false;
// 如果可以滑動,則進行ScrollView本身的滑動
if (event.getAction() == KeyEvent.ACTION_DOWN) {
    switch (event.getKeyCode()) {
        case KeyEvent.KEYCODE_DPAD_UP:
            if (!event.isAltPressed()) {
                handled = arrowScroll(View.FOCUS_UP);
            } else {
                handled = fullScroll(View.FOCUS_UP);
            }
            break;
        case KeyEvent.KEYCODE_DPAD_DOWN:
            if (!event.isAltPressed()) {
                handled = arrowScroll(View.FOCUS_DOWN);
            } else {
                handled = fullScroll(View.FOCUS_DOWN);
            }
            break;
        case KeyEvent.KEYCODE_SPACE:
            pageScroll(event.isShiftPressed() ? View.FOCUS_UP : View.FOCUS_DOWN);
            break;
    }
}

return handled;

}

由于在dispatchKeyEvent里優先處理的,因此對于滑動方向的KeyEvent,onKeyDown就監聽不到了。這也就是為什么onKeyDown里居然截獲不到按鍵事件的原因。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。