Android事件分发的这些问题你真的搞懂了吗?

背景

前段时间项目上遇到一个bug,是跟事件分发有关的,故特意将事件分发相关的细节点整理出来。

Activity 启动涉及到的核心类说明

Activity展示过程中涉及到的核心类如下所示,因为后续多个场景会涉及到相关类的功能,大家可以先有个印象。

App(通过IActivityManger访问AMS)和AMS通信(通过IApplicationThread回调App):

  • ActivityThread: App进程启动入口类,同时提供主线程Looper。
  • IApplicationThread: Framework和App通信接口,在framework层定义,AMS所有的事件回调都通过该接口传递给App。
  • ApplicationThread: 继承IApplicationThread.stub,实现IApplicationThread接口。
  • IActivityManger: ActivityManger接口,定义AMS相关功能接口。
  • ActivityManagerService:实现IActivityManger接口。Application层通过ServiceManager获取接口。

App(通过IWindowManager访问WMS)和WMS通信(通过ViewRootImpl、Window等通知到App)

  • WindowManagerGlobal: 内部持有WMS接口,负责管理App的所有ViewRootIml。
  • WindowManagerImpl: App本地WM管理类,实现了ViewManaer和WindowManager(本质还是调用WMS)。
  • ViewRootImpl:每个窗口的业务处理类。比如窗口的绘制,事件注册和回调等都是在该类中完成的。

注意:WMS管理所有的Window,每次与WMS通信都需要持有Window对应的Session会话对象才能访问。该Session也是在ViewRootImpl 构造方法中实现的。

Window(窗口)和Activity之间的关系

因为我们在手机屏幕上看到的内容大多数时候都是Activity的内容,就会产生一种Window就是Activity的错觉。其实不然,Activity只是负责搭建绘制UI的环境而已(提供绘制所需资源和生命周期管理),它既不能直接绘制在屏幕上,也不能主动响应用户交互事件,这一切都需要依赖Window才能实现。两者之间的关系如下图所示:

从上可以看出

  • Window负责接收底层驱动的事件,然后将事件传递给Activity进行分发。
  • Window负责UI内容的绘制,其内部会维护一个Surface,Activity的内容全部都会绘制在该Surface上。我们看到的手机屏幕上的内容就是这个Surface的数据。

从上可以看出不管是绘制还是事件的传递都离不开Window,我们本篇文章重点介绍Android的事件分发

Window创建时机

既然Winodw这么重要,那它是在什么时候创建的呢?我们做App开发的时候好像很少接触到它。对于Activity而言,Window是在加载Activity对象过程中创建出来的。

从时序图上可以看出在执行LaunchActivity时重点执行了下面关键逻辑

  1. 创建Activity实例. 主要通过ClassLoader、类名、Intent参数 构建一个具体的Activity实例,如下所示

ActivityThread#performLaunchActivity

java 复制代码
...
...
...
try {
    java.lang.ClassLoader cl = appContext.getClassLoader();
    activity = mInstrumentation.newActivity(
            cl, component.getClassName(), r.intent);
    
}
...
...
...
  1. 将ActivityThread和Activity绑定,其中最核心的就是创建PhoneWindow 窗口对象并设置相关属性。

Activity#attach

java 复制代码
...
...
...
mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(this);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
if (info.softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED) {
    mWindow.setSoftInputMode(info.softInputMode);
}
if (info.uiOptions != 0) {
    mWindow.setUiOptions(info.uiOptions);
}
...
...
...
//设置一个ViewManager、WindowManager 接口实例对象,即WindowManagerImpl对象。
mWindow.setWindowManager(
        (WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
        mToken, mComponent.flattenToString(),
        (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
...
...
...

注意:Window本身是一个抽象类,因为Android支持多种不同类型的设备,比如手机,TV,Watch等等,此处PhoneWindow就代表手机窗口。

  1. 执行Activity#onCreate 回调。App一般会在onCreate回调中会调用setContentView 来指定加载的布局资源。在该过程中会初始化DecorView,并且将指定的布局资源添加到DecorView对应的父布局中去。

Activity#setContentView

java 复制代码
public void setContentView(@LayoutRes int layoutResID) {
    getWindow().setContentView(layoutResID);
    initWindowDecorActionBar();
}

由此可见,常说的DecorView是在onCreate阶段创建的。

由上可以知道对于Activity来说,Window就是在Activity#attach 的时候创建的,此处Window还仅仅设置了基础的回调(大多数都是直接回调Activity)和需要显示的内容,但是此时还无法绘制到屏幕上。

Window何时与WMS完成注册绑定

上面我们创建出了PhoneWindow出来,但是其还是一个独立的存在,并未与WMS完成绑定,此时底层的Input事件(KeyEvent和TouchEvent 统称为InputEvent)也传递不过来,那是什么时候完成WMS注册的呢?在Activity Resume的过程中会完成窗口等相关的注册绑定,具体如下图所示:

ActivityThread处理resume过程核心分2步:

markdown 复制代码
1. Activity内部状态设置。会分别回调onStart,onResume回调。
2. 若Window还未添加过DecorView,则会添加DecorView。在此过程中会将Window添加到WindowmanagerService中,并且创建InputChannel全双工通道进行InputEvent的分发。

接下来我们对照源码来重点说明下ViewRootImpl的创建和setView的过程,因为这了涉及到和WMS的通信

WindowManagerGlobal.java

java 复制代码
public static IWindowSession getWindowSession() {
    synchronized (WindowManagerGlobal.class) {
        if (sWindowSession == null) {
            try {
                InputMethodManager imm = InputMethodManager.getInstance();
                IWindowManager windowManager = getWindowManagerService();
                sWindowSession = windowManager.openSession(
                        new IWindowSessionCallback.Stub() {
                            @Override
                            public void onAnimatorScaleChanged(float scale) {
                                ValueAnimator.setDurationScale(scale);
                            }
                        },
                        imm.getClient(), imm.getInputContext());
            } catch (RemoteException e) {
                throw e.rethrowFromSystemServer();
            }
        }
        return sWindowSession;
    }
}

public void addView(View view, ViewGroup.LayoutParams params,
        Display display, Window parentWindow) {
    ...
    ...
    ...
        ViewRootImpl root;
        root = new ViewRootImpl(view.getContext(), display);
        ...
        ...
        ...
        // do this last because it fires off messages to start doing things
        try {
            root.setView(view, wparams, panelParentView);
        } catch (RuntimeException e) {
            // BadTokenException or InvalidDisplayException, clean up.
            if (index >= 0) {
                removeViewLocked(index, true);
            }
            throw e;
        }
    }
  • getWindowSession()

    在ViewRootImpl 构建过程中会从WMS获取该Window的Session会话对象,后续和WMS的通信都基于该Session会话进行。 com.android.server.wm.Session 实现了IWindowSession接口。

  • addView(...)

    在addView 方法中我们构建ViewRootImpl 对象,并且调用其setView 方法。

ViewRootImpl.java

scss 复制代码
public ViewRootImpl(Context context, Display display) {
    ...
    ...
    ...
    mWindowSession = WindowManagerGlobal.getWindowSession();
    ...
    ...
    ...
}


public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    ...
    ...
    ...
    if ((mWindowAttributes.inputFeatures
        & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) == 0) {
        mInputChannel = new InputChannel();
    }
    
    ...
    
    res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
        getHostVisibility(), mDisplay.getDisplayId(),
        mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
        mAttachInfo.mOutsets, mInputChannel);

    ...
    ...
    ...
}
  • ViewRootImpl(): 在构造方法中我们获取了mWindowSession 对象。
  • setView(...):
    • 若没有指定 INPUT_FEATURE_NO_INPUT_CHANNEL 属性,则会对mInputChannel 赋值一个InputChannel 对象。
    • 紧接着通过Session#addToDisplay 方法讲Window和相关参数添加到WMS中去。

Session.java

java 复制代码
public Session(WindowManagerService service, IWindowSessionCallback callback,
        IInputMethodClient client, IInputContext inputContext) {
    mService = service;
    ...
    ...
}

@Override
public int addToDisplay(IWindow window, int seq, WindowManager.LayoutParams attrs,
        int viewVisibility, int displayId, Rect outContentInsets, Rect outStableInsets,
        Rect outOutsets, InputChannel outInputChannel) {
    return mService.addWindow(this, window, seq, attrs, viewVisibility, displayId,
            outContentInsets, outStableInsets, outOutsets, outInputChannel);
}

可以看出在addToDisplay 中直接调用WindowManagerService#addWindow 方法。由此可见,Session只是WindowManagerService 的一个Client代理而已。

WindManagerService.java

java 复制代码
public int addWindow(Session session, IWindow client, int seq,
        WindowManager.LayoutParams attrs, int viewVisibility, int displayId,
        Rect outContentInsets, Rect outStableInsets, Rect outOutsets,
        InputChannel outInputChannel) {
...
...
...
final WindowState win = new WindowState(this, session, client, token, parentWindow,
        appOp[0], seq, attrs, viewVisibility, session.mUid,
        session.mCanAddInternalSystemWindow);
...
...
final boolean openInputChannels = (outInputChannel != null
        && (attrs.inputFeatures & INPUT_FEATURE_NO_INPUT_CHANNEL) == 0);
if  (openInputChannels) {
    win.openInputChannel(outInputChannel);
}
...
...
...
}
  • 创建WindowState对象。WMS中关于Window信息都存储在WindowState 对象中。
  • WindowState内部通过InputChannel 打开全双工通信通道(核心实现在Native层),并且将其中一个InputChannel对象所有权转移到outInputChannel 中(即ViewRootImpl中创建传入的)。

在上面时许图中省略了WindowState的描述,其内部会调用InputChannel打开管道通道。

InputTransport.cpp

InputTransport.cpp 是创建底层InputChannel的实现类,下面是创建InputChannel的过程。

C++ 复制代码
status_t InputChannel::openInputChannelPair(const String8& name,
        sp<InputChannel>& outServerChannel, sp<InputChannel>& outClientChannel) {
    int sockets[2];
    if (socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sockets)) {
        status_t result = -errno;
        ALOGE("channel '%s' ~ Could not create socket pair.  errno=%d",
                name.string(), errno);
        outServerChannel.clear();
        outClientChannel.clear();
        return result;
    }

    int bufferSize = SOCKET_BUFFER_SIZE;
    setsockopt(sockets[0], SOL_SOCKET, SO_SNDBUF, &bufferSize, sizeof(bufferSize));
    setsockopt(sockets[0], SOL_SOCKET, SO_RCVBUF, &bufferSize, sizeof(bufferSize));
    setsockopt(sockets[1], SOL_SOCKET, SO_SNDBUF, &bufferSize, sizeof(bufferSize));
    setsockopt(sockets[1], SOL_SOCKET, SO_RCVBUF, &bufferSize, sizeof(bufferSize));

    String8 serverChannelName = name;
    serverChannelName.append(" (server)");
    outServerChannel = new InputChannel(serverChannelName, sockets[0]);

    String8 clientChannelName = name;
    clientChannelName.append(" (client)");
    outClientChannel = new InputChannel(clientChannelName, sockets[1]);
    return OK;
}
  1. 创建socket套接字数组

    socketpair 函数说明:

    • domain:通常设置为 AF_UNIX, 表示使用Unix 域套接字。
    • type:套接字类型,可以是 SOCK_STREAM、SOCK_DGRAM 或 SOCK_SEQPACKET,分别对应不同的通信协议。
    • protocol:指定使用的协议,通常设置为 0,表示使用默认协议。
    • sv:一个包含两个整数的数组,用于存储创建的套接字描述符。
  2. 设置全双工通信。

  3. 创建 Servcer端和client端对应的InputChannel对象。

全双工通信如下图所示:

为什么选择SocketPair通信,而不是Binder

Binder

Binder 是基于C/S(Client-Server)架构,允许一个进程调用另一个进程中的方法。其通过内存映射技术减少了数据在进程间传递时的拷贝次数,提高了通信效率。

SocketPair

SocketPair通信有下面的特点:

  • 有序性。SOCK_SEQPACKET 保证数据包的顺序。如果数据包丢失或顺序错误,它会重新发送或重新排序,以确保接收方收到的数据包是有序的。
  • 可靠性:SOCK_SEQPACKET 提供了与 SOCK_STREAM 类似的可靠性保证。它确保数据包正确无误地到达目的地,如果传输过程中出现错误,会进行重试
  • 消息边界保留。 SOCK_STREAM 是基于数据流的,数据报之间的边界是不清晰的,可能导致所谓的"粘包"现象。而 SOCK_SEQPACKET 则保证了每次 write 都会发起底层发送,每次 read 调用将返回一个完整的数据包。

由此可以看出,Binder具有更好的性能,数据读写只需要拷贝一次,而SocketPair需要拷贝2次,那为什么还会使用SocketPair用于InputEvent的通信方式呢? 主要考虑到数据的有序性 (确保事件发生的顺序),可靠性 (不能因为一次读取异常就将消息丢掉),消息边界保留 (每次都是读取一个完整事件内容,不需要业务层自己实现)等特点,这个是Binder和其他协议都不具备的。Input事件输入场景每次通信的数据量比较小,所以此处传输性能在该场景虽不及Binder,但也是够用的。

Framework层事件分发流程

整体分发流程如下图所示:

InputManager系统是如何启动的?

关键类说明:

WindowManagerService: Android窗口管理服务。

InputManagerServer: 输入事件服务,由WindowManagerService管理。

EventHub: 系统所有事件的中央处理站,从驱动文件读取RawEvents。

InputReader: 利用EventHub读取raw事件,并转化为Android系统事件类型。

InputDispatcher: 将InputReader读取的事件分发到各目标。

ViewRootImpl: 收到事件通知,读取事件,分发到View系统

相关源码路径

bash 复制代码
frameworks/base/services/java/com/android/server/wm/WindowManagerService.java
frameworks/base/services/java/com/android/server/input/InputManagerService.java
frameworks/base/services/java/com/android/server/jni/com_android_server_input_InputManagerService.cpp
frameworks/base/services/input/InputManager.cpp
frameworks/base/services/input/InputDispatcher.cpp
frameworks/base/services/input/InputReader.cpp
frameworks/base/services/input/EventHub.cpp
frameworks/base/jni/android_view_InputChannel.cpp
frameworks/base/core/java/android/view/InputChannel.java
frameworks/base/core/java/android/view/InputEventReceiver.java
frameworks/base/core/jni/android_view_InputEventReceiver.cpp
frameworks/base/core/java/android/view/ViewRootImpl.java
frameworks/base/policy/src/com/android/internal/policy/impl/PhoneWindow.java
frameworks/base/policy/src/com/android/internal/policy/impl/PhoneWindowManager.java
  • 事件消息系统启动

Android系统的按键、触屏类事件是由InputManager来监控的,而InputManager是由窗口管理服务WindowManagerService来启动的。事件消息系统服务跟随WindowManagerService一起启动,随后在本地层创建InputReader和InputDispatcher完成事件的读取、分发工作。

com_android_server_input_InputManagerService.cpp

C++ 复制代码
static void nativeStart(JNIEnv* env, jclass clazz, jint ptr) {
    NativeInputManager* im = reinterpret_cast<NativeInputManager*>(ptr);
    status_t result = im->getInputManager()->start();
    if (result) {
        jniThrowRuntimeException(env, "Input manager could not be started.");
    }
}

InputManager.cpp

C++ 复制代码
InputManager::InputManager(
        const sp<EventHubInterface>& eventHub,
        const sp<InputReaderPolicyInterface>& readerPolicy,
        const sp<InputDispatcherPolicyInterface>& dispatcherPolicy) {
    mDispatcher = new InputDispatcher(dispatcherPolicy);
    mReader = new InputReader(eventHub, readerPolicy, mDispatcher);
    initialize();
}

status_t InputManager::start() {
    status_t result = mDispatcherThread->run("InputDispatcher", PRIORITY_URGENT_DISPLAY);
    if (result) {
        ALOGE("Could not start InputDispatcher thread due to error %d.", result);
        return result;
    }
    
    result = mReaderThread->run("InputReader", PRIORITY_URGENT_DISPLAY);
    if (result) {
        ALOGE("Could not start InputReader thread due to error %d.", result);
        mDispatcherThread->requestExit();
        return result;
    }
    return OK;
}

这里注意在InputManager构造的时候构造了reader和dispatcher,reader的第三个参数是一个listener,该listener就是dispatcher。当收到消息之后要通过该listener通知派发消息。

InputDispatcher.cpp

C++ 复制代码
bool InputDispatcherThread::threadLoop() {
    mDispatcher->dispatchOnce();
    return true;
}

InputReader.cpp

C++ 复制代码
bool InputReaderThread::threadLoop() {
    mReader->loopOnce();
    return true;
}

Framework读取流程

InputDispatcher和InputReader分别用于分发和读取事件。其中InputDispatcher会调用dispatchOnce()不断分发事件,InputReader调用loopOnce()不断地读取事件。详细见以下图,看InputReader如何将事件传到InputDispatcher再传到ViewRoot。

派发流程

  • framework层的派发如下图所示:最终通过SocketPair将消息发送到另一端。
  • App层的派发过程如下图所示:

系统级事件处理

如果是系统事件,将不会分发给应用程序,有两个地方:

  • InputDispatcher.notifyKey在加入队列之前;
  • InputDispatcher.dispatchkeyLocked在分发之前;

这两个都可以拦截事件,交给系统窗口处理。

应用层View的事件处理

按键事件

之前framework层讲到事件消息传递到了PhoneWindow.DecorView实例,接下来就是执行PhoneWindow.DecorView.dispatchKeyEvent()处理事件,相关逻辑如下:

  1. 处理系统快捷键,比如最常见的是触发Menu键调出对应的Panel菜单项;

  2. 若消息还未被处理,判断该PhoneWindow实例是否有Window.Callback实例(该实例在PhoneWindow初始化时设置),若有一般为当前窗口所对应的Activity;

    2.1 不存在Activity回调实例,执行DecorView.super.dispatchKeyEvent()方法,DecorView的父类依次是FrameLayout ---> ViewGroup ---> View,所以最终会执行到View.dispatchKeyEvent(.);

    • 判断当前View有没有设置过 OnKeyListener接口实例,若有,则调用实例的 onKey(...)方法处理消息;

    • 若没有设置过,则执行View.onKeyDown/onKeyUp回调方法;在这里将处理单击Click和长按LongClick两种事件,具体方案之前已经提到,就是会在Down事件产生时调用postDelayed(mPendingCheckForLongPress, ViewConfiguration.getLongPressTimeout() - delayOffset);产生一个延时异步任务;并根据用户长按是否超过该时间间隔选择执行还是删除该异步任务;

      注意:在ViewGroup中处理按键消息时,是首先调用super.dispatchKeyEvent(),即优先让父视图处理,若父视图没有消耗,才调用子视图mFocusedView.dispatchKeyEvent()进行处理;

    2.2 如存在Activity回调实例,执行Activity.dispatchKeyEvent()方法;

    • 执行onUserInteraction();可以在自己的Activity中重载该方法以便在消息被处理之前做点什么;
    • 执行PhoneWindow. superDispatchKeyEvent();其实是转交执行DecorView.super.dispatchKeyEvent(.),这个步骤和上面不存在Activity回调实例时的逻辑是一致的,即不管如何,按键信息都会首先让View系统自身处理;
    • 若View系统自身未处理该消息,则执行Activity.onKeyDown/onKeyUp回调方法;

    2.3 若消息还未被处理,调用PhoneWindow.this.onKeyDown/Up(.)方法,主要是处理音量键、回退键、菜单键、搜索键等;

  3. 若消息已被消费,执行finishInputEvent(...)完成一次消息派发。

触摸事件

Android 中与 Touch 事件相关的方法包括:dispatchTouchEvent(MotionEvent ev)、onInterceptTouchEvent(MotionEvent ev)、onTouchEvent(MotionEvent ev);能够响应这些方法的控件包括:ViewGroup、View、Activity。方法与控件的对应关系如下表所示:

从这张表中我们可以看到 ViewGroup 和 View 对与 Touch 事件相关的三个方法均能响应,而 Activity 对 onInterceptTouchEvent(MotionEvent ev) 也就是事件拦截不进行响应。另外需要注意的是 View 对 dispatchTouchEvent(MotionEvent ev) 和onInterceptTouchEvent(MotionEvent ev) 的响应的前提是可以向该 View 中添加子 View,如果当前的 View 已经是一个最小的单元 View(比如 TextView),那么就无法向这个最小 View 中添加子 View,也就无法向子 View 进行事件的分发和拦截,所以它没有dispatchTouchEvent(MotionEvent ev) 和 onInterceptTouchEvent(MotionEvent ev),只有 onTouchEvent(MotionEvent ev)。

事件分发

public boolean dispatchTouchEvent(MotionEvent ev)

Touch 事件发生时 Activity 的 dispatchTouchEvent(MotionEvent ev) 方法会以隧道方式(从根元素依次往下传递直到最内层子元素或在中间某一元素中由于某一条件停止传递)将事件传递给最外层 View 的 dispatchTouchEvent(MotionEvent ev) 方法,并由该 View 的 dispatchTouchEvent(MotionEvent ev) 方法对事件进行分发。dispatchTouchEvent 的事件分发逻辑如下:

  • 如果 return true,事件会分发给当前 View 并由 dispatchTouchEvent 方法进行消费,同时事件会停止向下传递;

  • 如果 return false,事件分发分为两种情况:

    • 如果当前 View 获取的事件直接来自 Activity,则会将事件返回给 Activity 的 onTouchEvent 进行消费;
    • 如果当前 View 获取的事件来自外层父控件,则会将事件返回给父 View 的 onTouchEvent 进行消费。
  • 如果返回系统默认的 super.dispatchTouchEvent(ev),事件会自动的分发给当前 View 的 onInterceptTouchEvent 方法。

事件拦截

public boolean onInterceptTouchEvent(MotionEvent ev)

在外层 View 的 dispatchTouchEvent(MotionEvent ev) 方法返回系统默认的 super.dispatchTouchEvent(ev) 情况下,事件会自动的分发给当前 View 的 onInterceptTouchEvent 方法。onInterceptTouchEvent 的事件拦截逻辑如下:

  • 如果 onInterceptTouchEvent 返回 true,则表示将事件进行拦截,并将拦截到的事件交由当前 View 的 onTouchEvent 进行处理
  • 如果 onInterceptTouchEvent 返回 false,则表示将事件放行,当前 View 上的事件会被传递到子 View 上,再由子 View 的 dispatchTouchEvent 来开始这个事件的分发;
  • 如果 onInterceptTouchEvent 返回 super.onInterceptTouchEvent(ev),事件默认会被拦截,并将拦截到的事件交由当前 View 的 onTouchEvent 进行处理。

事件响应

public boolean onTouchEvent(MotionEvent ev)

在 dispatchTouchEvent 返回 super.dispatchTouchEvent(ev) 并且 onInterceptTouchEvent 返回 true 或返回 super.onInterceptTouchEvent(ev) 的情况下 onTouchEvent 会被调用。onTouchEvent 的事件响应逻辑如下:

  • 如果事件传递到当前 View 的 onTouchEvent 方法,而该方法返回了 false,那么这个事件会从当前 View 向上传递,并且都是由上层 View 的 onTouchEvent 来接收,如果传递到上面的 onTouchEvent 也返回 false,这个事件就会"消失",而且接收不到下一次事件。
  • 如果返回了 true 则会接收并消费该事件。
  • 如果返回 super.onTouchEvent(ev) 默认处理事件的逻辑和返回 false 时相同。

总结

本篇文章主要整理了与Android 事件系统相关的一些细节,主要包括:

  1. Window 与Activity 的区别。
  2. Window创建时机
  3. Window和WMS绑定过程
  4. Input系统为何采用SocketPair方式完成IPC通信
  5. Framework层与App层的消息事件分发

参考文档:

相关推荐
画江湖Test8 分钟前
分享一个您在汽车软件安全性测试中发现严重漏洞的案例,以及如何处理
嵌入式硬件·安全·面试·汽车·软件测试面试
午后书香1 小时前
看两道关于异步的字节面试题...
前端·javascript·面试
红米饭配南瓜汤1 小时前
Android显示系统(02)- OpenGL ES - 概述
android·音视频·图形渲染
m0_726965982 小时前
Android笔记【12】脚手架Scaffold和导航Navigation
android·笔记
xiaoerbuyu12332 小时前
Android直接播放麦克风采集到的声音
android
李艺为3 小时前
RePlugin调试时执行Gradle任务rpInstallAndRunPluginDebug失败原因分析
android·java·gradle·android studio
MorleyOlsen4 小时前
【Android Debug Bridge】adb常用指令(更新中)
android·adb
小安25 小时前
android 简单实现录音功能
android
诸神黄昏EX6 小时前
Android KEY的哪些事儿
android
薯条不要番茄酱7 小时前
【JavaEE初阶】落霞与孤鹜齐飞,秋水共长天一色 - (重点)线程
android·java·java-ee