Android强行进阶—按键事件&焦点事件攻略(1)

这里看看View中事件分发。

//View

/**

  • Dispatch a key event to the next view on the focus path. This path runs
  • from the top of the view tree down to the currently focused view. If this
  • view has focus, it will dispatch to itself. Otherwise it will dispatch
  • the next node down the focus path. This method also fires any key
  • listeners.
  • @param event The key event to be dispatched.
  • @return True if the event was handled, false otherwise.
    */
    public boolean dispatchKeyEvent(KeyEvent event) {
    if (mInputEventConsistencyVerifier != null) {
    mInputEventConsistencyVerifier.onKeyEvent(event, 0);
    }

// Give any attached key listener a first crack at the event.
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
//如果我们给View设置了OnKeyListener且View是ENABLED状态,
//则会回调我们的了OnKeyListener
if (li != null && li.mOnKeyListener != null && (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnKeyListener.onKey(this, event.getKeyCode(), event)) {
return true;
}
//调用KeyEvent.dispatch方法,并将view对象本身作为参数传递进去,view的各种callback方法在这里被触发
if (event.dispatch(this, mAttachInfo != null
? mAttachInfo.mKeyDispatchState : null, this)) {
return true;
}

if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
}
return false;
}
复制代码

ViewGroup和View的dispatchKeyEvent()就构成了View层次结构的KeyEvent分发,且总是从树根DecorView开始到具体的View。注意到此处在View不消费KeyEvent会调用KeyEvent.dispatch方法,在Activity也会调用该方法。

//KeyEvent
public final boolean dispatch(Callback receiver, DispatcherState state,
Object target) {
switch (mAction) {
case ACTION_DOWN: {
mFlags &= ~FLAG_START_TRACKING;
//回调Callback对象receiver的onKeyDown函数,上文知道Activity和View都实现Callback
boolean res = receiver.onKeyDown(mKeyCode, this);
if (state != null) {//通常成立
if (res && mRepeatCount == 0 && (mFlags&FLAG_START_TRACKING) != 0) {//判断是否轨迹事件
state.startTracking(this, target);
} else if (isLongPress() && state.isTracking(this)) {
try {
//处理长按事件
if (receiver.onKeyLongPress(mKeyCode, this)) {
state.performedLongPress(this);
res = true;//消费该事件
}
} catch (AbstractMethodError e) {
}
}
}
return res;
}
case ACTION_UP:
if (state != null) {
//reset state的内部状态,也改变了KeyEvent的某些状态
state.handleUpEvent(this);
}
//回调Callback对象receiver的onKeyUp函数
return receiver.onKeyUp(mKeyCode, this);
case ACTION_MULTIPLE:
final int count = mRepeatCount;
final int code = mKeyCode;
if (receiver.onKeyMultiple(code, count, this)) {
return true;
}
if (code != KeyEvent.KEYCODE_UNKNOWN) {
mAction = ACTION_DOWN;
mRepeatCount = 0;
boolean handled = receiver.onKeyDown(code, this);
if (handled) {
mAction = ACTION_UP;
receiver.onKeyUp(code, this);
}
mAction = ACTION_MULTIPLE;
mRepeatCount = count;
return handled;
}
return false;
}
return false;
}
复制代码

在上面代码中,可以看到Callback对象的onKeyDown(),onKeyUp(),onKeyLongPress()函数被回调。而Activity和View是Callback接口的实现,因此调用Activity和View对应的方法。

先看看Activity对几个方法的实现:

//Activity

public boolean onKeyDown(int keyCode, KeyEvent event) {
//处理返回键
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (getApplicationInfo().targetSdkVersion

= Build.VERSION_CODES.ECLAIR) {
// 标记追踪这个key event
event.startTracking();
} else {
//手机APP常在Activity重写该方法,
//要求用户双击两次来退出APP,而不是一次就退出APP
onBackPressed();
}
return true;
}

if (mDefaultKeyMode == DEFAULT_KEYS_DISABLE) {
return false;
} else if (mDefaultKeyMode == DEFAULT_KEYS_SHORTCUT) {
Window w = getWindow();
if (w.hasFeature(Window.FEATURE_OPTIONS_PANEL) &&
w.performPanelShortcut(Window.FEATURE_OPTIONS_PANEL, keyCode, event,
Menu.FLAG_ALWAYS_PERFORM_CLOSE)) {
return true;
}
return false;
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
return false;
} else {
boolean clearSpannable = false;
boolean handled;
if ((event.getRepeatCount() != 0) || event.isSystem()) {
clearSpannable = true;
handled = false;
} else {
handled = TextKeyListener.getInstance().onKeyDown(
null, mDefaultKeySsb, keyCode, event);
if (handled && mDefaultKeySsb.length() > 0) {
final String str = mDefaultKeySsb.toString();
clearSpannable = true;
switch (mDefaultKeyMode) {
case DEFAULT_KEYS_DIALER:
Intent intent = new Intent(Intent.ACTION_DIAL, Uri.parse(“tel:” + str));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
break;
case DEFAULT_KEYS_SEARCH_LOCAL:
startSearch(str, false, null, false);
break;
case DEFAULT_KEYS_SEARCH_GLOBAL:
startSearch(str, false, null, true);
break;
}
}
}
if (clearSpannable) {
mDefaultKeySsb.clear();
mDefaultKeySsb.clearSpans();
Selection.setSelection(mDefaultKeySsb,0);
}
return handled;
}
}

public boolean onKeyLongPress(int keyCode, KeyEvent event) {
return false;
}

public boolean onKeyUp(int keyCode, KeyEvent event) {
if (getApplicationInfo().targetSdkVersion

= Build.VERSION_CODES.ECLAIR) {
if (keyCode == KeyEvent.KEYCODE_BACK && event.isTracking()
&& !event.isCanceled()) {
onBackPressed();
return true;
}
}
return false;
}
复制代码

而View中实现:

//View
public boolean onKeyDown(int keyCode, KeyEvent event) {
//KEYCODE_DPAD_CENTER、KEYCODE_ENTER、
//KEYCODE_SPACE、KEYCODE_NUMPAD_ENTER都返回ture,其他返回false
if (KeyEvent.isConfirmKey(keyCode)) {
//当前View是DISABLED状态直接返回false
if ((mViewFlags & ENABLED_MASK) == DISABLED) {
return true;
}

if (event.getRepeatCount() == 0) {
final boolean clickable = (mViewFlags & CLICKABLE) == CLICKABLE
|| (mViewFlags & LONG_CLICKABLE) == LONG_CLICKABLE;
if (clickable || (mViewFlags & TOOLTIP) == TOOLTIP) {
final float x = getWidth() / 2f;
final float y = getHeight() / 2f;
if (clickable) {
//标记为Pressed,例如根据状态改变背景
setPressed(true, x, y);
}
//长按检测
checkForLongClick(0, x, y);
return true;
}
}
}

return false;
}

public boolean onKeyUp(int keyCode, KeyEvent event) {
if (KeyEvent.isConfirmKey(keyCode)) {
if ((mViewFlags & ENABLED_MASK) == DISABLED) {
return true;
}
if ((mViewFlags & CLICKABLE) == CLICKABLE && isPressed()) {
setPressed(false);

if (!mHasPerformedLongPress) {
removeLongPressCallback();
if (!event.isCanceled()) {
return performClickInternal();
}
}
}
}
return false;
}
复制代码

在DecorView的superDispatchKeyEvent()函数最后一行,如果View层次结构不消费事件,那么会调用ViewRootImpl对象的dispatchUnhandledKeyEvent()函数,这里主要是将未被消费的KeyEvent分发给注册了处理所有KeyEvent的处理者(监听器)

//ViewRootImpl.java

private final UnhandledKeyManager mUnhandledKeyManager
=new UnhandledKeyManager();

public boolean dispatchUnhandledKeyEvent(KeyEvent event) {
return mUnhandledKeyManager.dispatch(mView, event);
}

boolean dispatch(View root, KeyEvent event) {
if (mDispatched) {
return false;
}
View consumer;
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, “UnhandledKeyEvent dispatch”);
mDispatched = true;
//将未处理的KeyEvent进行分发,如果有View消费该事件,则返回该
//View
consumer = root.dispatchUnhandledKeyEvent(event);
//用于追踪该事件
if (event.getAction() == KeyEvent.ACTION_DOWN) {
int keycode = event.getKeyCode();
if (consumer != null && !KeyEvent.isModifierKey(keycode)) {
mCapturedKeys.put(keycode, new WeakReference<>(consumer));
}
}
} finally {
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
return consumer != null;
}
复制代码

这里的root是DecorView,但从参数的类型来说,这里root.dispatchUnhandledKeyEvent(event)应该是在View中实现的。

//View.java

View dispatchUnhandledKeyEvent(KeyEvent evt) {
if (onUnhandledKeyEvent(evt)) {
return this;
}
return null;
}

boolean onUnhandledKeyEvent(@NonNull KeyEvent event) {
if (mListenerInfo != null && mListenerInfo.mUnhandledKeyListeners != null) {
//mListenerInfo通过栈的方式保存是否有View设置UnhandledKeyListener,如果有且消费事件,则该DecorView消费该事件
for (int i = mListenerInfo.mUnhandledKeyListeners.size() - 1; i >= 0; --i) {
if (mListenerInfo.mUnhandledKeyListeners.get(i).onUnhandledKeyEvent(this, event)) {
return true;
}
}
}
return false;
}
复制代码

通过上文代码可以知道,我们可以在View中添加addOnUnhandledKeyEventListener(OnUnhandledKeyEventListener)来监听所有未被处理的KeyEvent。会在KeyEvent正常分发未被消费前,且早于Window的Callback被回调。

在DecorView的dispatchKeyEvent()函数,如果View层次结构不消费事件,那么会交给PhoneWindow的onKeyDown()onKeyUp()函数。

//PhoneWindow
protected boolean onKeyDown(int featureId, int keyCode, KeyEvent event) {

final KeyEvent.DispatcherState dispatcher =
mDecor != null ? mDecor.getKeyDispatcherState() : null;
Integer.toHexString(event.getFlags()));

switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
case KeyEvent.KEYCODE_VOLUME_DOWN:
case KeyEvent.KEYCODE_VOLUME_MUTE: {
if (mMediaController != null) {
mMediaController.dispatchVolumeButtonEventAsSystemService(event);
} else {
getMediaSessionManager().dispatchVolumeKeyEventAsSystemService(event,
mVolumeControlStreamType);
}
return true;
}

case KeyEvent.KEYCODE_MEDIA_PLAY:
case KeyEvent.KEYCODE_MEDIA_PAUSE:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
case KeyEvent.KEYCODE_MUTE:
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_STOP:
case KeyEvent.KEYCODE_MEDIA_NEXT:
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
case KeyEvent.KEYCODE_MEDIA_RECORD:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: {
if (mMediaController != null) {
if (mMediaController.dispatchMediaButtonEventAsSystemService(event)) {
return true;
}
}
return false;
}

case KeyEvent.KEYCODE_MENU: {
onKeyDownPanel((featureId < 0) ? FEATURE_OPTIONS_PANEL : featureId, event);
return true;
}

case KeyEvent.KEYCODE_BACK: {
if (event.getRepeatCount() > 0) break;
if (featureId < 0) break;
if (dispatcher != null) {
dispatcher.startTracking(event, this);
}
return true;
}

}

return false;
}

protected boolean onKeyUp(int featureId, int keyCode, KeyEvent event) {
final KeyEvent.DispatcherState dispatcher =
mDecor != null ? mDecor.getKeyDispatcherState() : null;
if (dispatcher != null) {
dispatcher.handleUpEvent(event);
}

switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
case KeyEvent.KEYCODE_VOLUME_DOWN: {
if (mMediaController != null) {
mMediaController.dispatchVolumeButtonEventAsSystemService(event);
} else {
getMediaSessionManager().dispatchVolumeKeyEventAsSystemService(
event, mVolumeControlStreamType);
}
return true;
}
case KeyEvent.KEYCODE_VOLUME_MUTE: {
getMediaSessionManager().dispatchVolumeKeyEventAsSystemService(
event, AudioManager.USE_DEFAULT_STREAM_TYPE);
return true;
}

case KeyEvent.KEYCODE_MEDIA_PLAY:
case KeyEvent.KEYCODE_MEDIA_PAUSE:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
case KeyEvent.KEYCODE_MUTE:
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_STOP:
case KeyEvent.KEYCODE_MEDIA_NEXT:
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
case KeyEvent.KEYCODE_MEDIA_RECORD:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: {
if (mMediaController != null) {
if (mMediaController.dispatchMediaButtonEventAsSystemService(event)) {
return true;
}
}
return false;
}

case KeyEvent.KEYCODE_MENU: {
onKeyUpPanel(featureId < 0 ? FEATURE_OPTIONS_PANEL : featureId,
event);
return true;
}

case KeyEvent.KEYCODE_BACK: {
if (featureId < 0) break;
if (event.isTracking() && !event.isCanceled()) {
if (featureId == FEATURE_OPTIONS_PANEL) {
PanelFeatureState st = getPanelState(featureId, false);
if (st != null && st.isInExpandedMode) {
reopenMenu(true);
return true;
}
}
closePanel(featureId);
return true;
}
break;
}

case KeyEvent.KEYCODE_SEARCH: { /*

if (isNotInstantAppAndKeyguardRestricted()) {
break;
}
if ((getContext().getResources().getConfiguration().uiMode
& Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_WATCH) {
break;
}
if (event.isTracking() && !event.isCanceled()) {
launchDefaultSearch(event);
}
return true;
}

case KeyEvent.KEYCODE_WINDOW: {
if (mSupportsPictureInPicture && !event.isCanceled()) {
getWindowControllerCallback().enterPictureInPictureModeIfPossible();
}
return true;
}
}

return false;
}
复制代码

从上面可以看到,PhoneWindow也只对一些物理按键做了处理,如果PhoneWindow和View、Activity都没有消费事件,那么ViewPostImeInputStage对象通过系统算法自动寻找焦点了。

总结一下:
  1. 监听器的优先级高于Callback的回调,也就是说OnKeyListener的函数优先Callback的onKeyDown等等函数的回调。
  2. View的Callback回调要早于Activity,Activity的回调早于PhoneWindow。
  3. 优先级高的消费KeyEvent,优先级低的不再受理该事件。

下面通过时序图对上文KeyEvent做一个整体流程的阐释(虽然不能准备表达意思)

在图表示在KeyEvent在DecorView开始不拦截最终在View的OnKeyListener或Callback对象被消费的过程。

参考文章

系统自动寻找焦点

回到梦开始的地方ViewPostImeInputStage对象的processKeyEvent()函数的末尾,在自动处理焦点的地方,会调用performFocusNavigation()函数来寻找下个获得焦点的View。

//ViewRootImpl.java

private boolean performFocusNavigation(KeyEvent event) {

int direction = 0;
//从下面代码可以看出,switch语句在此的主要作用是判断焦点的方向
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_LEFT:
if (event.hasNoModifiers()) {
direction = View.FOCUS_LEFT;
}
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
if (event.hasNoModifiers()) {
direction = View.FOCUS_RIGHT;
}
break;
case KeyEvent.KEYCODE_DPAD_UP:
if (event.hasNoModifiers()) {
direction = View.FOCUS_UP;
}
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
if (event.hasNoModifiers()) {
direction = View.FOCUS_DOWN;
}
break;
case KeyEvent.KEYCODE_TAB:
if (event.hasNoModifiers()) {
direction = View.FOCUS_FORWARD;
} else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
direction = View.FOCUS_BACKWARD;
}
break;
}

if (direction != 0) {
//分析一
//mView在这里是DecorView对象,查找出当前获得焦点的View
View focused = mView.findFocus();
if (focused != null) {
//分析二
//在当前获得焦点View通过指定方向搜索下一个获得焦点的View
View v = focused.focusSearch(direction);
if (v != null && v != focused) {
focused.getFocusedRect(mTempRect);
if (mView instanceof ViewGroup) {
((ViewGroup) mView).offsetDescendantRectToMyCoords(
focused, mTempRect);
((ViewGroup) mView).offsetRectIntoDescendantCoords(
v, mTempRect);
}
if (v.requestFocus(direction, mTempRect)) {
playSoundEffect(SoundEffectConstants
.getContantForFocusDirection(direction));
return true;
}
}

//给DecorView最后一次处理焦点的机会
if (mView.dispatchUnhandledMove(focused, direction)) {
return true;
}
} else {
//如果没有View获得焦点
if (mView.restoreDefaultFocus()) {
return true;
}
}
}
return false;
}
复制代码

在分析一处,通过DecorView对象mView来查找已获得焦点的View,findFocus()函数在ViewGroup和View都有实现,而DecorView继承自ViewGroup。这里其实进入了View的层次结果查找已获得焦点的View.

//ViewGroup.java
@Override
public View findFocus() {

if (isFocused()) {
return this;
}

if (mFocused != null) {
//mFocused表示获得焦点的View,有可能是ViewGroup,也有可能是具体的View
return mFocused.findFocus();
}
return null;
}

//View.java
public View findFocus() {
return (mPrivateFlags & PFLAG_FOCUSED) != 0 ? this : null;
}

public boolean isFocused() {
return (mPrivateFlags & PFLAG_FOCUSED) != 0;
}
复制代码

对于ViewGroup来说,如果本身获得焦点则直接返回自身即可,否则继续通过mFocused.findFocus()函数继续查找已获得焦点的View。具体的View是否获得焦点与ViewGroup的判断条件是一致的,判断PFLAG_FOCUSED标志位,也就是我们调用View的focuesabe=true,如果设置,则返回该View,否则返回null,表示没有View获得焦点。

假设寻找到了已获得焦点的View,那么下面就是寻找下一个获得焦点的View。也就是ViewPostImeInputStage对象的performFocusNavigation()函数分析二的代码。由于focused对象有可能是ViewGroup,也有可能是具体的View。一起看看它两的实现。

//View
//具体View的实现非常的简单,如果有父视图ViewGroup,则在俯视图在寻找,
//否则返回null
public View focusSearch(@FocusRealDirection int direction) {
if (mParent != null) {
return mParent.focusSearch(this, direction);
} else {
return null;
}
}
//ViewGroup.java
@Override
public View focusSearch(View focused, int direction) {
if (isRootNamespace()) { //判断当前ViewGroup是否顶层View,即DecorView
//调用FocusFinder实例findNextFocus进行查找
return FocusFinder.getInstance().findNextFocus(this, focused, direction);
} else if (mParent != null) {
//递归到顶层View
return mParent.focusSearch(focused, direction);
}
return null;
}
复制代码

通过递归方式,从内到外,到布局最外层View,准确说是ViewGroup。然后调用FocusFinder的实例方法findNextFocus()来寻找下一个获得焦点的View。 FocusFinder类通过算法,根据当前获得焦点的View和按键方向来寻找下一个获得焦点的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;
ViewGroup effectiveRoot = getEffectiveRoot(root, focused);
if (focused != null) {
//寻找当前获得焦点view是否设置了指定方向的下一个获得焦点的View
next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction);
}
if (next != null) {
return next;
}
ArrayList focusables = mTempList;
try {
focusables.clear();
effectiveRoot.addFocusables(focusables, direction);
if (!focusables.isEmpty()) {
//通过算法寻找最近可获得焦点的View
next = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);
}
} finally {
focusables.clear();
}
return next;
}
复制代码

findNextFocus()函数主要通过两种方式来寻找指定方向的下一个获得焦。的View:我们在XML布局或者代码设置特定方向获得焦点的View;通过算法来寻找特定方向可以获得焦点最近的View。

对于方式一,findNextUserSpecifiedFocus()函数的实现如下:

//View.java
//主要作用是判断当前获得焦点的View有没有指定下一个获得焦点View的ID
View findUserSetNextFocus(View root, @FocusDirection int direction) {
switch (direction) {
case FOCUS_LEFT:
if (mNextFocusLeftId == View.NO_ID) return null;
return findViewInsideOutShouldExist(root, mNextFocusLeftId);
case FOCUS_RIGHT:
if (mNextFocusRightId == View.NO_ID) return null;
return findViewInsideOutShouldExist(root, mNextFocusRightId);
case FOCUS_UP:
if (mNextFocusUpId == View.NO_ID) return null;
return findViewInsideOutShouldExist(root, mNextFocusUpId);
case FOCUS_DOWN:
if (mNextFocusDownId == View.NO_ID) return null;
return findViewInsideOutShouldExist(root, mNextFocusDownId);
case FOCUS_FORWARD:
if (mNextFocusForwardId == View.NO_ID) return null;
return findViewInsideOutShouldExist(root, mNextFocusForwardId);
case FOCUS_BACKWARD: {
if (mID == View.NO_ID) return null;
final int id = mID;
return root.findViewByPredicateInsideOut(this, new Predicate() {
@Override
public boolean test(View t) {
return t.mNextFocusForwardId == id;
}
});
}
}
return null;
}
//MatchIdPredicate匹配到相同ID会返回true
private View findViewInsideOutShouldExist(View root, int id) {
if (mMatchIdPredicate == null) {
mMatchIdPredicate = new MatchIdPredicate();
}
mMatchIdPredicate.mId = id;
View result = root.findViewByPredicateInsideOut(this, mMatchIdPredicate);
if (result == null) {
Log.w(VIEW_LOG_TAG, "couldn’t find view with id " + id);
}
return result;
}
//从当前View开始,遍历View的层次结构来的下一个获得焦点的View
public final T findViewByPredicateInsideOut(
View start, Predicate predicate) {
View childToSkip = null;
for (;😉 {
T view = start.findViewByPredicateTraversal(predicate, childToSkip);
if (view != null || start == this) {
return view;
}

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

childToSkip = start;
start = (View) parent;
}
}
//匹配相同ID,返回该View
protected T findViewByPredicateTraversal(Predicate predicate,
View childToSkip) {
if (predicate.test(this)) {
return (T) this;
}
return null;
}
复制代码

在上面的相关方法,主要是通过在View的层次结构中去寻找到和指定id匹配的View。

那么方式二,通过算法来寻找下一个焦点又是如何的呢?

//FocusFinder.java
private View findNextFocus(ViewGroup root, View focused, Rect focusedRect,
int direction, ArrayList focusables) {
if (focused != null) {
if (focusedRect == null) {
focusedRect = mFocusedRect;
}
// fill in interesting rect from focused
focused.getFocusedRect(focusedRect);
root.offsetDescendantRectToMyCoords(focused, focusedRect);
} else {
if (focusedRect == null) {
focusedRect = mFocusedRect;

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助

因此我收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
rds(focused, focusedRect);
} else {
if (focusedRect == null) {
focusedRect = mFocusedRect;

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助

因此我收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-nLxXJxao-1715419433716)]

[外链图片转存中…(img-9sMwefw1-1715419433717)]

[外链图片转存中…(img-8jRY4H2Y-1715419433718)]

[外链图片转存中…(img-gowFvBMc-1715419433719)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值