问题表现
在开发中遇到一个偶现的崩溃,日志堆栈如下:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example.edittextdemo, PID: 9528
java.lang.IllegalStateException: focus search returned a view that wasn't able to take focus!
at android.widget.TextView.onKeyUp(TextView.java:7591)
at android.view.KeyEvent.dispatch(KeyEvent.java:2788)
at android.view.View.dispatchKeyEvent(View.java:11780)
at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:1845)
at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:1845)
at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:1845)
at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:1845)
at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:1845)
at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:1845)
at com.android.internal.policy.DecorView.superDispatchKeyEvent(DecorView.java:547)
at com.android.internal.policy.PhoneWindow.superDispatchKeyEvent(PhoneWindow.java:1884)
at android.app.Activity.dispatchKeyEvent(Activity.java:3430)
at android.support.v7.app.AppCompatActivity.dispatchKeyEvent(AppCompatActivity.java:534)
at android.support.v7.view.WindowCallbackWrapper.dispatchKeyEvent(WindowCallbackWrapper.java:58)
at android.support.v7.app.AppCompatDelegateImplBase$AppCompatWindowCallbackBase.dispatchKeyEvent(AppCompatDelegateImplBase.java:316)
at com.android.internal.policy.DecorView.dispatchKeyEvent(DecorView.java:421)
at android.view.ViewRootImpl$ViewPostImeInputStage.processKeyEvent(ViewRootImpl.java:5371)
at android.view.ViewRootImpl$ViewPostImeInputStage.onProcess(ViewRootImpl.java:5243)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:4737)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:4790)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:4756)
at android.view.ViewRootImpl$AsyncInputStage.forward(ViewRootImpl.java:4887)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:4764)
at android.view.ViewRootImpl$AsyncInputStage.apply(ViewRootImpl.java:4944)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:4737)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:4790)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:4756)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:4764)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:4737)
at android.view.ViewRootImpl.deliverInputEvent(ViewRootImpl.java:7363)
at android.view.ViewRootImpl.doProcessInputEvents(ViewRootImpl.java:7337)
at android.view.ViewRootImpl.enqueueInputEvent(ViewRootImpl.java:7295)
at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:4315)
at android.os.Handler.dispatchMessage(Handler.java:109)
at android.os.Looper.loop(Looper.java:166)
at android.app.ActivityThread.main(ActivityThread.java:7555)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:469)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:963)
复现路径
解决任何缺陷,不仅仅是崩溃问题,解决问题的第一步都是找到复现路径。如果找到了稳定复现的路径,那么恭喜你:已经成功了一半了!
对于本文中的崩溃问题,经过一层一层的抽丝剥茧,逐步剔除无关代码,终于在 Demo 工程中找到了稳定复现的路径。
Demo 工程是个列表页,用 RecyclerView 实现,列表中有 3 种类型的 Item:TextView、仅支持数字类型输入的 EditText 和 CheckBox。
当点击输入框后直接点击软键盘的回车按钮,必崩。完整代码和效果录屏分别如下:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
RecyclerView rv = new RecyclerView(this);
LinearLayoutManager lm = new LinearLayoutManager(this);
rv.setLayoutManager(lm);
rv.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
DividerItemDecoration did = new DividerItemDecoration(this, lm.getOrientation());
rv.addItemDecoration(did);
ArrayList<Integer> data = new ArrayList<Integer>(10);
for (int i = 0; i < 13; i++) {
data.add(i);
}
rv.setAdapter(new MyAdapter(data));
setContentView(rv);
}
class MyAdapter extends RecyclerView.Adapter {
ArrayList<Integer> data;
int viewTypeTextView, viewTypeEditText, viewTypeCheckbox;
public MyAdapter(ArrayList<Integer> data) {
this.data = data;
viewTypeEditText = this.data.size() - 2;
viewTypeCheckbox = this.data.size() - 1;
}
@Override
public int getItemViewType(int position) {
if (position == data.size() - 2) {
return viewTypeEditText;
} else if (position == data.size() - 1) {
return viewTypeCheckbox;
} else {
return viewTypeTextView;
}
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
View view;
if (viewType == viewTypeEditText) {
view = buildEditText(viewGroup.getContext());
} else if (viewTypeCheckbox == viewType) {
view = buildCheckbox(viewGroup.getContext());
} else {
view = buildTextView(viewGroup.getContext());
}
return new MyHolder(view);
}
@Override
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, int position) {
}
@Override
public int getItemCount() {
return data.size();
}
private View buildTextView(Context context) {
TextView textView = new TextView(context);
textView.setText("文本项");
textView.setTextSize(20);
textView.setGravity(Gravity.CENTER);
textView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
textView.setPadding(0, 50, 0, 50);
return textView;
}
private View buildEditText(Context context) {
EditText editText = new EditText(context);
editText.setHint("输入框");
editText.setInputType(EditorInfo.TYPE_CLASS_NUMBER);
editText.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
return editText;
}
private View buildCheckbox(Context context) {
CheckBox checkBox = new CheckBox(context);
checkBox.setText("单选框");
return checkBox;
}
class MyHolder extends RecyclerView.ViewHolder {
public MyHolder(@NonNull View itemView) {
super(itemView);
}
}
}
}
拥有13个文本项时会crash
)
奇怪的是,同样的代码,如果把文本控件的个数减少到 1 个,则不再出现崩溃,即将上面的代码仅仅做如下修改:
ArrayList<Integer> data = new ArrayList<Integer>(10);
for (int i = 0; i < 1; i++) {
data.add(i);
}
只有1个文本时则不会崩溃
对比之后,不免让人猜测:难道是否崩溃取决于单选框是否显示在屏幕中?即,当单选框出现在屏幕中时,不会崩溃?;否则会崩溃?我们来验证一下:在文本控件个数为13的场景下,滑动页面,将单选框展示在屏幕中,果然不会崩溃:
拥有13个文本项但不会crash这是一个神奇的问题,激起了我征服她的欲望。
问题原因
我们先看下这个异常是从什么地方抛出来的。直接在源码中全局搜索异常关键字 focus search returned
,发现总共有3处,全部集中在 TextView.java 中。
前两处在 onEditorAction()
中:
public void onEditorAction(int actionCode) {
// omitted
if (actionCode == EditorInfo.IME_ACTION_NEXT) {
View v = focusSearch(FOCUS_FORWARD);
if (v != null) {
if (!v.requestFocus(FOCUS_FORWARD)) {
throw new IllegalStateException("focus search returned a view "
+ "that wasn't able to take focus!");
}
}
return;
} else if (actionCode == EditorInfo.IME_ACTION_PREVIOUS) {
View v = focusSearch(FOCUS_BACKWARD);
if (v != null) {
if (!v.requestFocus(FOCUS_BACKWARD)) {
throw new IllegalStateException("focus search returned a view "
+ "that wasn't able to take focus!");
}
}
return;
}
// omitted
}
最后一处在 onKeyUp()
中:
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
// omitted
if (!hasOnClickListeners()) {
View v = focusSearch(FOCUS_DOWN);
if (v != null) {
if (!v.requestFocus(FOCUS_DOWN)) {
throw new IllegalStateException("focus search returned a view "
+ "that wasn't able to take focus!");
}
/*
* Return true because we handled the key; super
* will return false because there was no click
* listener.
*/
super.onKeyUp(keyCode, event);
return true;
}
}
// omitted
}
根据堆栈日志,我们可以看出实际抛出异常的地方是 onKeyUp()
这个方法。
仅仅从代码中我们可以看到,抛出该异常的前提是 !hasOnClickListeners() = true
,最简单粗暴的解决该崩溃的方法就是给 EditText 设置一个 OnClickListener,让代码运行不到抛出异常的地方,也就不会崩溃了,虽然不优雅,但是有效,可以应急。
我们都是有追求的程序员,不会这么浅尝辄止、得过且过,接着往下看。关键代码在于如下几行:
View v = focusSearch(FOCUS_DOWN);
if (v != null) {
if (!v.requestFocus(FOCUS_DOWN)) {
throw new IllegalStateException("focus search returned a view "
+ "that wasn't able to take focus!");
}
}
看过这几行代码,我们会心生疑问:
-
v
是否为空?如果不为空,是哪个控件? - 如果
v
不为空,v.requestFocus(FOCUS_DOWN)
为什么失败?
带着这2个问题,我们看下2个方法对应的代码。仅仅是看系统源代码,效率有点低。我们可以结合稳定复现的路径,进行单步调试,这样效率更高些。根据断点调试(关于如何对系统源代码进行断点调试,参见笔者的另一篇博客《浅尝安卓事件分发机制》),在不发生崩溃的路径上,v = null
;在发生崩溃的路径上,v
是输入框下面的单选框,此时 v.requestFocus(FOCUS_DOWN)=false
,正是造成崩溃的原因。
我们先不看 focusSearch()
,这牵涉到焦点搜索策略,内容较多。我们先看 View#requestFocus()
。从 《TouchMode 101》 我们知道,除了 EditText,其他控件,包括单选框控件,其 isFocusableInTouchMode()
都返回 false。View#requestFocus()
内部实际调用的方法是:
private boolean requestFocusNoSearch(int direction, Rect previouslyFocusedRect) {
// need to be focusable
if ((mViewFlags & FOCUSABLE) != FOCUSABLE
|| (mViewFlags & VISIBILITY_MASK) != VISIBLE) {
return false;
}
// need to be focusable in touch mode if in touch mode
if (isInTouchMode() &&
(FOCUSABLE_IN_TOUCH_MODE != (mViewFlags & FOCUSABLE_IN_TOUCH_MODE))) {
return false;
}
// need to not have any parents blocking us
if (hasAncestorThatBlocksDescendantFocus()) {
return false;
}
handleFocusGainInternal(direction, previouslyFocusedRect);
return true;
}
注意其中的第2个 if 语句,由于单选框在 TouchMode 下 focusable = false,于是代码运行到这个 if 语句就直接返回 false 了,导致在 TextView#onKeyUp()
方法中抛出异常。既然如此,我们如果强行把单选框的在 TouchMode 模式下设置为 focusable 的,是不是就解决该问题了?我们来验证下。在 Demo 工程中添加1行代码,做如下修改:
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
View view;
if (viewType == viewTypeEditText) {
view = buildEditText(viewGroup.getContext());
} else if (viewTypeCheckbox == viewType) {
view = buildCheckbox(viewGroup.getContext());
// 设置单选框在 TouchMode 模式下是 focusable 的
view.setFocusableInTouchMode(true);
} else {
view = buildTextView(viewGroup.getContext());
}
return new MyHolder(view);
}
在13个文本项且不滑动单选框到屏幕中的场景下,测试效果:
bingo
而且看起来并没有啥副作用。
回过头来看 View#focusSearch()
:
View v = focusSearch(FOCUS_DOWN);
if (v != null) {
if (!v.requestFocus(FOCUS_DOWN)) {
throw new IllegalStateException("focus search returned a view "
+ "that wasn't able to take focus!");
}
}
为什么单选框显示在屏幕上时 v = null
而其不显示在屏幕上时则指向单选框?focusSearch()
在 View、ViewGroup、RecyclerView 中分别有各自的实现方式。
在 View#focusSearch()
中,其实是调用的 ViewGroup 或者 RecyclerView 的 focusSearch()
:
public View focusSearch(@FocusRealDirection int direction) {
if (mParent != null) {
return mParent.focusSearch(this, direction);
} else {
return null;
}
}
而 ViewGroup#focusSearch()
看起来也比较简单,功能是一路类递归调用知道寻找到顶层的根控件,将根控件作为参数,调用 FocusFinder.getInstance().findNextFocus(this, focused, direction)
:
/**
* Find the nearest view in the specified direction that wants to take
* focus.
*
* @param focused The view that currently has focus
* @param direction One of FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, and
* FOCUS_RIGHT, or 0 for not applicable.
*/
@Override
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;
}
FocusFinder.getInstance().findNextFocus(this, focused, direction)
是 ViewGroup 中焦点搜索策略的核心代码,内容较多,暂且不展开讲述。
RecyclerView#focusSearch()
更为复杂:
/**
* Since RecyclerView is a collection ViewGroup that includes virtual children (items that are
* in the Adapter but not visible in the UI), it employs a more involved focus search strategy
* that differs from other ViewGroups.
* <p>
* It first does a focus search within the RecyclerView. If this search finds a View that is in
* the focus direction with respect to the currently focused View, RecyclerView returns that
* child as the next focus target. When it cannot find such child, it calls
* {@link LayoutManager#onFocusSearchFailed(View, int, Recycler, State)} to layout more Views
* in the focus search direction. If LayoutManager adds a View that matches the
* focus search criteria, it will be returned as the focus search result. Otherwise,
* RecyclerView will call parent to handle the focus search like a regular ViewGroup.
* <p>
* When the direction is {@link View#FOCUS_FORWARD} or {@link View#FOCUS_BACKWARD}, a View that
* is not in the focus direction is still valid focus target which may not be the desired
* behavior if the Adapter has more children in the focus direction. To handle this case,
* RecyclerView converts the focus direction to an absolute direction and makes a preliminary
* focus search in that direction. If there are no Views to gain focus, it will call
* {@link LayoutManager#onFocusSearchFailed(View, int, Recycler, State)} before running a
* focus search with the original (relative) direction. This allows RecyclerView to provide
* better candidates to the focus search while still allowing the view system to take focus from
* the RecyclerView and give it to a more suitable child if such child exists.
*
* @param focused The view that currently has focus
* @param direction One of {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN},
* {@link View#FOCUS_LEFT}, {@link View#FOCUS_RIGHT}, {@link View#FOCUS_FORWARD},
* {@link View#FOCUS_BACKWARD} or 0 for not applicable.
*
* @return A new View that can be the next focus after the focused View
*/
@Override
public View focusSearch(View focused, int direction) {
View result = mLayout.onInterceptFocusSearch(focused, direction);
if (result != null) {
return result;
}
final boolean canRunFocusFailure = mAdapter != null && mLayout != null
&& !isComputingLayout() && !mLayoutFrozen;
final FocusFinder ff = FocusFinder.getInstance();
if (canRunFocusFailure
&& (direction == View.FOCUS_FORWARD || direction == View.FOCUS_BACKWARD)) {
// convert direction to absolute direction and see if we have a view there and if not
// tell LayoutManager to add if it can.
boolean needsFocusFailureLayout = false;
if (mLayout.canScrollVertically()) {
final int absDir =
direction == View.FOCUS_FORWARD ? View.FOCUS_DOWN : View.FOCUS_UP;
final View found = ff.findNextFocus(this, focused, absDir);
needsFocusFailureLayout = found == null;
if (FORCE_ABS_FOCUS_SEARCH_DIRECTION) {
// Workaround for broken FOCUS_BACKWARD in API 15 and older devices.
direction = absDir;
}
}
if (!needsFocusFailureLayout && mLayout.canScrollHorizontally()) {
boolean rtl = mLayout.getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL;
final int absDir = (direction == View.FOCUS_FORWARD) ^ rtl
? View.FOCUS_RIGHT : View.FOCUS_LEFT;
final View found = ff.findNextFocus(this, focused, absDir);
needsFocusFailureLayout = found == null;
if (FORCE_ABS_FOCUS_SEARCH_DIRECTION) {
// Workaround for broken FOCUS_BACKWARD in API 15 and older devices.
direction = absDir;
}
}
if (needsFocusFailureLayout) {
consumePendingUpdateOperations();
final View focusedItemView = findContainingItemView(focused);
if (focusedItemView == null) {
// panic, focused view is not a child anymore, cannot call super.
return null;
}
eatRequestLayout();
mLayout.onFocusSearchFailed(focused, direction, mRecycler, mState);
resumeRequestLayout(false);
}
result = ff.findNextFocus(this, focused, direction);
} else {
result = ff.findNextFocus(this, focused, direction);
if (result == null && canRunFocusFailure) {
consumePendingUpdateOperations();
final View focusedItemView = findContainingItemView(focused);
if (focusedItemView == null) {
// panic, focused view is not a child anymore, cannot call super.
return null;
}
eatRequestLayout();
result = mLayout.onFocusSearchFailed(focused, direction, mRecycler, mState);
resumeRequestLayout(false);
}
}
if (result != null && !result.hasFocusable()) {
if (getFocusedChild() == null) {
// Scrolling to this unfocusable view is not meaningful since there is no currently
// focused view which RV needs to keep visible.
return super.focusSearch(focused, direction);
}
// If the next view returned by onFocusSearchFailed in layout manager has no focusable
// views, we still scroll to that view in order to make it visible on the screen.
// If it's focusable, framework already calls RV's requestChildFocus which handles
// bringing this newly focused item onto the screen.
requestChildOnScreen(result, null);
return focused;
}
return isPreferredNextFocus(focused, result, direction)
? result : super.focusSearch(focused, direction);
}
在崩溃的路径中,相关代码为:
/**
* Since RecyclerView is a collection ViewGroup that includes virtual children (items that are
* in the Adapter but not visible in the UI), it employs a more involved focus search strategy
* that differs from other ViewGroups.
* <p>
* It first does a focus search within the RecyclerView. If this search finds a View that is in
* the focus direction with respect to the currently focused View, RecyclerView returns that
* child as the next focus target. When it cannot find such child, it calls
* {@link LayoutManager#onFocusSearchFailed(View, int, Recycler, State)} to layout more Views
* in the focus search direction. If LayoutManager adds a View that matches the
* focus search criteria, it will be returned as the focus search result. Otherwise,
* RecyclerView will call parent to handle the focus search like a regular ViewGroup.
* <p>
* When the direction is {@link View#FOCUS_FORWARD} or {@link View#FOCUS_BACKWARD}, a View that
* is not in the focus direction is still valid focus target which may not be the desired
* behavior if the Adapter has more children in the focus direction. To handle this case,
* RecyclerView converts the focus direction to an absolute direction and makes a preliminary
* focus search in that direction. If there are no Views to gain focus, it will call
* {@link LayoutManager#onFocusSearchFailed(View, int, Recycler, State)} before running a
* focus search with the original (relative) direction. This allows RecyclerView to provide
* better candidates to the focus search while still allowing the view system to take focus from
* the RecyclerView and give it to a more suitable child if such child exists.
*
* @param focused The view that currently has focus
* @param direction One of {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN},
* {@link View#FOCUS_LEFT}, {@link View#FOCUS_RIGHT}, {@link View#FOCUS_FORWARD},
* {@link View#FOCUS_BACKWARD} or 0 for not applicable.
*
* @return A new View that can be the next focus after the focused View
*/
@Override
public View focusSearch(View focused, int direction) {
// omitted
if (canRunFocusFailure
&& (direction == View.FOCUS_FORWARD || direction == View.FOCUS_BACKWARD)) {
// omitted
} else {
// omitted
// 关键代码
result = mLayout.onFocusSearchFailed(focused, direction, mRecycler, mState);
// omitted
}
// omitted
}
关键在于 result = mLayout.onFocusSearchFailed(focused, direction, mRecycler, mState);
一行,这里的 mLayout 是 LinearLayoutManager,我们看 LinearLayoutManager#onFocusSearchFailed()
的代码实现:
@Override
public View onFocusSearchFailed(View focused, int focusDirection,
RecyclerView.Recycler recycler, RecyclerView.State state) {
resolveShouldLayoutReverse();
if (getChildCount() == 0) {
return null;
}
final int layoutDir = convertFocusDirectionToLayoutDirection(focusDirection);
if (layoutDir == LayoutState.INVALID_LAYOUT) {
return null;
}
ensureLayoutState();
ensureLayoutState();
final int maxScroll = (int) (MAX_SCROLL_FACTOR * mOrientationHelper.getTotalSpace());
updateLayoutState(layoutDir, maxScroll, false, state);
mLayoutState.mScrollingOffset = LayoutState.SCROLLING_OFFSET_NaN;
mLayoutState.mRecycle = false;
fill(recycler, mLayoutState, state, true);
// nextCandidate is the first child view in the layout direction that's partially
// within RV's bounds, i.e. part of it is visible or it's completely invisible but still
// touching RV's bounds. This will be the unfocusable candidate view to become visible onto
// the screen if no focusable views are found in the given layout direction.
final View nextCandidate;
if (layoutDir == LayoutState.LAYOUT_START) {
nextCandidate = findPartiallyOrCompletelyInvisibleChildClosestToStart(recycler, state);
} else {
nextCandidate = findPartiallyOrCompletelyInvisibleChildClosestToEnd(recycler, state);
}
// nextFocus is meaningful only if it refers to a focusable child, in which case it
// indicates the next view to gain focus.
final View nextFocus;
if (layoutDir == LayoutState.LAYOUT_START) {
nextFocus = getChildClosestToStart();
} else {
nextFocus = getChildClosestToEnd();
}
if (nextFocus.hasFocusable()) {
if (nextCandidate == null) {
return null;
}
return nextFocus;
}
return nextCandidate;
}
在崩溃的路径上,我们单步调试会发现,nextCandidate 是输入框控件,而 nextFocus 是单选框控件,于是该方法就返回了单选框控件。
解决方案
- 为输入框控件设置空的 OnClickListener;
- 将单选框控件设置为在 TouchMode 模式下 focusable 为 true;
- 给单选框控件添加
android:imeOptions="actionNone"
属性;
网友评论