再谈Android消息机制

一、Android消息机制

1.1 组成

Looper

在线程中执行循环消息依赖的是Looper对消息进行loop循环分发,默认在线程中是没有消息循环能力的,如果要线程具备消息循环的能力,需要在线程中调用Looper的prepare函数来创建Looper对象,再调用loop()函数将整个消息处理循环运行起来。在任意一个线程都可以通过Looper.myLooper()静态函数来获取当前线程的Looper对象,这样极低的Looper对象的获取成本是因为每个线程自己的looper实例存储在ThreadLocal中。ThreadLocal是一个把当前线程当作Key的数据存储对象,用于创建线程的局部变量。

java 复制代码
public class ThreadLocal<T> {

    ...
    
    public T get() {
        Thread t = Thread.currentThread();
        // 读取本线程的ThreadLocalMap对象
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

    ...
}
  • Looper的创建:Looper通过prepare函数创建,并存储在线程局部变量中。
java 复制代码
private static void prepare(boolean quitAllowed) {
    if (sThreadLocal.get() != null) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}

public static void prepareMainLooper() {
    prepare(false);
    synchronized (Looper.class) {
        if (sMainLooper != null) {
            throw new IllegalStateException("The main Looper has already been prepared.");
        }
        sMainLooper = myLooper();
    }
}
  • Looper的执行:线程通过myLooper获取当前线程的Looper对象,调用loop函数启动。
java 复制代码
public static void loop() {
    final Looper me = myLooper();
    ...
    me.mInLoop = true;

    Binder.clearCallingIdentity();
    final long ident = Binder.clearCallingIdentity();
    ...

    final int thresholdOverride =
            SystemProperties.getInt("log.looper."
                    + Process.myUid() + "."
                    + Thread.currentThread().getName()
                    + ".slow", 0);

    me.mSlowDeliveryDetected = false;
    for (;;) {
        if (!cc(me, ident, thresholdOverride)) {
            return;
        }
    }
}

Handler

Handler在Android的消息机制中扮演着消息的传递者和消息的内容执行的角色。当我们创建Handler时,同时初始化Looper及MessageQueue对象,支持通过参数指定,否则默认使用当前线程的Looper对象。

  • 消息的创建:消息的创建可以通过如下三种方式创建,官方最后两种通过obtian形式获取的message,它将会从回收池里复用对象。
  • 构造Message对象
  • 构造Runnable对象
  • Handler#obtain & Message.obtain
  • 消息的发送:post形式发送的消息支持Runnable和Message两种形式,当外部构造一个Runnable时,到Handler内部也会被包装为Message的形式再入队到MessageQueue中。
  • post
  • postDelay
  • sendMessage
  • sendMessageDelay

MessageQueue

MessageQueue扮演消息管理和消息调度的角色,是消息机制中正真的执行者。大多数时候,MessageQueue在业务的使用中是不可见的,我们只需要构造Handler对象以及在构造Handler时传入Looper参数,即可以通过post或send一个任务交给主线程调度执行。它的组成和功能如下:

  • 消息队列管理:维护消息队列,消息入队排序,消息执行优先级调度
  • 消息循环:looper调用的next()函数源源不断返回message给到looper进行消息分法,当message为空时进入阻塞挂起状态
  • 空闲消息队列:支持特殊空闲消息队列,支持闲时调度
  • 底层实现:MessageQueue的底层实现使用了epoll机制来实现高效的消息处理。

二、消息机制的工作原理

2.1 主线程的Looper是何时运行起来的?

在Looper类中,有一个sMainLooper对象,保存着主线程的Looper的引用,以便于我们随时访问主线程Looper。而主线程Looper的创建在系统中有调用:一是系统进程,如:SystemServer的调用,二是ActivityThread的Main函数中调用。ActivityThread的Main函数是应用程序的入口点。所以所谓的主线程就是应用程序的入口线程。通过源码可以看到,ActivityThread的main函数的工作流程:

java 复制代码
public final class ActivityThread extends ClientTransactionHandler
        implements ActivityThreadInternal {

   
    public static void main(String[] args) {
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "ActivityThreadMain");

        // Install selective syscall interception
        AndroidOs.install();

        // CloseGuard defaults to true and can be quite spammy.  We
        // disable it here, but selectively enable it later (via
        // StrictMode) on debug builds, but using DropBox, not logs.
        CloseGuard.setEnabled(false);

        Environment.initForCurrentUser();

        // Make sure TrustedCertificateStore looks in the right place for CA certificates
        final File configDir = Environment.getUserConfigDirectory(UserHandle.myUserId());
        TrustedCertificateStore.setDefaultUserDirectory(configDir);

        // Call per-process mainline module initialization.
        initializeMainlineModules();

        Process.setArgV0("<pre-initialized>");

        Looper.prepareMainLooper();

        // 从命令行参数中解析出进程启动序列号,并将其存储在 startSeq 变量中。进程启动序列号是一个用于标识进程启动顺序的唯一标识符,可以用于跟踪和调试进程的启动过程。
        long startSeq = 0;
        if (args != null) {
            for (int i = args.length - 1; i >= 0; --i) {
                if (args[i] != null && args[i].startsWith(PROC_START_SEQ_IDENT)) {
                    startSeq = Long.parseLong(
                            args[i].substring(PROC_START_SEQ_IDENT.length()));
                }
            }
        }
        ActivityThread thread = new ActivityThread();
        thread.attach(false, startSeq);

        if (sMainThreadHandler == null) {
            sMainThreadHandler = thread.getHandler();
        }

        if (false) {
            Looper.myLooper().setMessageLogging(new
                    LogPrinter(Log.DEBUG, "ActivityThread"));
        }

        // End of event ActivityThreadMain.
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        // 这里并不需要通过指定MainLooer来启动主线程的Looper消息循环
        Looper.loop();

        throw new RuntimeException("Main thread loop unexpectedly exited");
    }
}
  • AndroidOs.install()

AndroidOs.install() 这行代码是用于初始化 Android 系统的类加载器。在 Android 系统中,类加载器负责加载应用程序的代码和资源。AndroidOs.install() 方法会创建一个新的 PathClassLoader 实例,并将其设置为当前线程的上下文类加载器。PathClassLoader 是一个特殊的类加载器,它可以从指定的路径加载类和资源。

  • Environment.initForCurrentUser()

Environment.initForCurrentUser() 这行代码是用于初始化当前用户的环境变量。 在 Android 系统中,每个用户都有自己的环境变量,如外部存储目录、数据目录等。Environment.initForCurrentUser() 方法会根据当前用户的信息,初始化这些环境变量。这样,应用程序可以根据这些环境变量来访问和操作用户的数据和资源。

  • Looper.loop()

此处启动的是主线程的looper循环,但在函数执行中不需要拿到sMainLooper对象就可以直接启动主线程Looper。因为每个线程有且仅有一个Looper,

java 复制代码
public static @Nullable Looper myLooper() {
  return sThreadLocal.get();
}

在代码中我们可以看到,Looper先在ActivityThread的实例创建出来之前先调用Prepare将MainLooper对象创建出来,而后才开始ActivityThread的创建和attach的执行,值得注意的是,这个时候,Looper并未开启looper消息循环,所以在ActivityThread的attach阶段handler发送出来的message并没有被立即执行,而是等到Looper的调用looper启动消息循环后才开始陆续被执行。

至此,主线程的消息队列已经开始循环工作起来了。

2.2 Looper如何进行消息循环与分发?

当我们调用loop时,会启动对应线程的消息循环,loop()函数的核心是调用loopOnce(...)函数具体的执行细节说明见代码注释。在looper中,主要是对messageQueue的消息分发耗时做相应的性能监控及相关日志的打印为主,以及持有对应的MessageQueue对象来获取和分发message。Looper通过MessageQueue的next()函数来完成从消息队列源源不断地读取消息以及空消息时的阻塞挂起,将资源让渡给其他线程。当有消息时,在通过Message的target分发对应的Message,完成消息的分发。

java 复制代码
private static boolean loopOnce(final Looper me,
        final long ident, final int thresholdOverride) {
    Message msg = me.mQueue.next(); // might block
    if (msg == null) {
        return false;
    }

    // Looper消息处理的监控一开始的方案都是从这个Printer入手
    // 定制自己的logging对象来实现消息循环的监控
    final Printer logging = me.mLogging;
    if (logging != null) {
        logging.println(">>>>> Dispatching to " + msg.target + " "
                + msg.callback + ": " + msg.what);
    }
    
    // 这里是Android-31版本的代码,可以从这个Observer实现新的消息队列监控
    final Observer observer = sObserver;
    ...

    try {
        msg.target.dispatchMessage(msg);
        ...
    } catch (Exception exception) {
        ...
    } finally {
        ThreadLocalWorkSource.restore(origWorkSource);
        ...
    }
    
    ...
    msg.recycleUnchecked();

    return true;
}
  • 关于Looper死循环为何不卡住主线程的经典问题

在许多面试场景中,面试官经常会问,为何Looper的死循环不会导致界面卡死,发生ANR。

这里其实我理解是混淆了几个概念:

  • 「关于界面卡死」:所谓界面的卡顿,是由于界面的帧率绘制掉帧而发生的,而界面的绘制需要经过几个过程:测量、布局、绘制、刷新(将绘制的内容刷到屏幕上)。而这几个过程中,前三者(测量、布局、绘制)需要主线程参数,后者则由GPU完成,非主线程完成。而主线程参与的测量、布局、绘制则都由handler以消息的形式分发给Looper在消息循环中完成,所谓的Looper的死循环就是在在做这些事情,所以Looper的死循环并不会影响界面的绘制,反而是这些死循环在做的事情完成了界面的绘制;
  • 「关于ANR」:ANR是系统抛出的一种异常,ANR的本质就是主线程Looper消费消息的速度太慢,进而导致用于系统监控进程中的异常消息未被及时处理掉而被执行,从而显示为用户可见的ANR提醒而杀掉应用进程。
  • 关于"死循环":另一个关于"卡死"的论题来源在于for(;;)看似停不下来的循环,然而在这个循环中,却并不无限地抢占CPU资源,而是在MessageQueue为空时主线程主动阻塞挂起,让渡CPU资源,从而使得其他线程得以执行,在下一次MessageQueue有新的消息入队时,再唤醒主线程继续执行。
  • 关于当消息队列为空时,Looper是否在空转?

在源码中,有这么一段代码

java 复制代码
Message msg = me.mQueue.next(); // might block

当消息队列内容为空时,其前的主线程处于阻塞状态,而阻塞住主线程的就是MessageQueuenext()函数中的nativePollOnce的函数中。nativePollOnce()方法是一个native方法,在收到新的消息前,它会一直阻塞队列或者超时。这个函数的实现在Android的系统底层。

2.3 handler如何传递消息与消费消息?

在上一部分,我们提到了looper通过MessageQueue的next来读取消息队列中的消息,再通过Message的target的dispatcher将消息分发出去。那Handler是如何将消息分发给MessageQueue的呢?而MessageQueue在收到消息后又是如何唤醒主线程的呢?

  • Handler消息的传递:在业务开发中,我们一般通过在当前线程构造一个Handler对象,再通过这个Handler对象post或者sendMessag来发送消息。而所有的消息发送最终都是走到同一个函数中
java 复制代码
public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) {
    MessageQueue queue = mQueue;
    if (queue == null) {
        RuntimeException e = new RuntimeException(
                this + " sendMessageAtTime() called with no mQueue");
        Log.w("Looper", e.getMessage(), e);
        return false;
    }
    return enqueueMessage(queue, msg, uptimeMillis);
}
private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
        long uptimeMillis) {
    msg.target = this;
    msg.workSourceUid = ThreadLocalWorkSource.getUid();

    // APP上层业务的Handler分法的Message一般都是false
    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

2.4 MessageQueue如何管理消息?

在Handler的调度中,应用层的消息通过Looper中的MessageQueue来讲消息入队到消息列表中。

消息入队

java 复制代码
boolean enqueueMessage(Message msg, long when) {
    if (msg.target == null) {
        throw new IllegalArgumentException("Message must have a target.");
    }

    synchronized (this) {
        if (msg.isInUse()) {
            throw new IllegalStateException(msg + " This message is already in use.");
        }

        // 当消息队列处于停止状态,则不再允许消息入队
        // 消息进行回收并return
        if (mQuitting) {
            IllegalStateException e = new IllegalStateException(
                    msg.target + " sending message to a Handler on a dead thread");
            Log.w(TAG, e.getMessage(), e);
            msg.recycle();
            return false;
        }

        msg.markInUse();
        msg.when = when;
        Message p = mMessages;
        boolean needWake;
        if (p == null || when == 0 || when < p.when) {
            // New head, wake up the event queue if blocked.
            msg.next = p;
            mMessages = msg;
            needWake = mBlocked;
        } else {
            // Inserted within the middle of the queue.  Usually we don't have to wake
            // up the event queue unless there is a barrier at the head of the queue
            // and the message is the earliest asynchronous message in the queue.
            needWake = mBlocked && p.target == null && msg.isAsynchronous();
            Message prev;
            // 循环插入新消息
            for (;;) {
                prev = p;
                p = p.next;
                // 插入标准是when值是否更小
                if (p == null || when < p.when) {
                    break;
                }
                // 修改唤醒标记
                if (needWake && p.isAsynchronous()) {
                    needWake = false;
                }
            }
            // 修改链表指针,插入消息
            msg.next = p; // invariant: p == prev.next
            prev.next = msg;
        }

        // We can assume mPtr != 0 because mQuitting is false.
        // 是否唤醒消息队列
        if (needWake) {
            nativeWake(mPtr);
        }
    }
    return true;
}

消息在入队时,经过一系列的参数校验和状态判断,不符合条件的消息要么拒绝入队,要么丢出异常,而后才是进入到消息队列中。入队的流程为

  1. 判断当前队头消息是否为空,或是否为迟于当前消息执行时间,若是,则将自己修改队头,将当前的队头指向自己的next
  2. 若为非空,则循环当前的消息队列,根据when,找到适合自己插入的位置,修改消息链表
  3. 最后根据当前的消息队列是否有异步消息屏障来决定是否唤醒当前的消息队列进入looper消息循环。

消息出队

MessageQueue的消息出队依赖的是一次次的MessageQueue的next(),所以消息的next()即为消息的消费出队的核心逻辑:

java 复制代码
@UnsupportedAppUsage
Message next() {
    final long ptr = mPtr;
    if (ptr == 0) {
        return null;
    }

    int pendingIdleHandlerCount = -1; // -1 only during first iteration
    int nextPollTimeoutMillis = 0;
    for (;;) {
        if (nextPollTimeoutMillis != 0) {
            Binder.flushPendingCommands();
        }
        // 通过native来控制delay消息的唤醒
        nativePollOnce(ptr, nextPollTimeoutMillis);

        synchronized (this) {
            // Try to retrieve the next message.  Return if found.
            final long now = SystemClock.uptimeMillis();
            Message prevMsg = null;
            Message msg = mMessages;
            
            // 取出屏障消息
            if (msg != null && msg.target == null) {
                // Stalled by a barrier.  Find the next asynchronous message in the queue.
                // 再取出异步消息执行
                do {
                    prevMsg = msg;
                    msg = msg.next;
                } while (msg != null && !msg.isAsynchronous());
            }
            
            // 若有异步消息,则此处已经是异步消息,否则为普通消息
            if (msg != null) {
                if (now < msg.when) {
                    // 消息未准备好,需要继续睡眠
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                    // 取出消息,修改当前消息队头,标记消息,返回消息
                    // Got a message.
                    mBlocked = false;
                    if (prevMsg != null) {
                        prevMsg.next = msg.next;
                    } else {
                        mMessages = msg.next;
                    }
                    msg.next = null;
                    if (DEBUG) Log.v(TAG, "Returning message: " + msg);
                    msg.markInUse();
                    return msg;
                }
            } else {
                // No more messages.
                nextPollTimeoutMillis = -1;
            }

            // Process the quit message now that all pending messages have been handled.
            if (mQuitting) {
                dispose();
                return null;
            }

            // 当消息队列已经取不到消息了,则进入idle时刻
            if (pendingIdleHandlerCount < 0
                    && (mMessages == null || now < mMessages.when)) {
                pendingIdleHandlerCount = mIdleHandlers.size();
            }
            if (pendingIdleHandlerCount <= 0) {
                // No idle handlers to run.  Loop and wait some more.
                mBlocked = true;
                continue;
            }

            if (mPendingIdleHandlers == null) {
                mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
            }
            mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
        }

        // Run the idle handlers.
        // We only ever reach this code block during the first iteration.
        for (int i = 0; i < pendingIdleHandlerCount; i++) {
            final IdleHandler idler = mPendingIdleHandlers[i];
            mPendingIdleHandlers[i] = null; // release the reference to the handler

            boolean keep = false;
            try {
                keep = idler.queueIdle();
            } catch (Throwable t) {
                Log.wtf(TAG, "IdleHandler threw exception", t);
            }

            if (!keep) {
                synchronized (this) {
                    mIdleHandlers.remove(idler);
                }
            }
        }

        // Reset the idle handler count to 0 so we do not run them again.
        pendingIdleHandlerCount = 0;

        // While calling an idle handler, a new message could have been delivered
        // so go back and look again for a pending message without waiting.
        nextPollTimeoutMillis = 0;
    }
}

1. 如何实现消息的delay执行

消息的delay和唤醒依赖的是native层的定时唤醒,而消息在入队时就就行执行时间的顺序排列。

  • 消息入队时,使用如下方式唤醒:
java 复制代码
if (needWake) {
    nativeWake(mPtr);
}
  • 消息循环时,使用如下方式延迟唤醒:
java 复制代码
nativePollOnce(ptr, nextPollTimeoutMillis);

这些即时唤醒和延迟唤醒以来的都是native层的能力来达成。它们使用JNI与底层C++代码进行交互,以实现消息队列的等待、检索和唤醒功能。这些方法使得MessageQueue能够在适当的时间执行任务,并确保消息和Runnable按预期顺序执行。在底层实现中使用了epoll机制,这种基于epoll的实现使得MessageQueue能够在高效地处理大量的消息和事件,同时避免了资源浪费和性能瓶颈。这对于Android应用程序的性能和响应速度至关重要。

2. MessageQueue是如何再被唤醒的?

  1. Message入队主动唤醒:当新消息入队时,根据异步屏障的状态,入队的message的when,决定是否要唤醒MessageQueue,MessageQueue的唤醒则是通过nativeWake(mPtr);来唤醒,mPtr 是一个指向 MessageQueue 的本地对象的指针。
  2. MessageQueue被动唤醒:在MessageQueue的next()函数中有一个nativePollOnce(ptr, nextPollTimeoutMillis);函数,即为当前主线程阻塞挂起,交由native在间隔nextPollTimeoutMillis时间后被native唤醒,继续执行MessageQueue中的消息。

3. 消息队列中有哪几个优先级消息在处理?

  1. 屏障消息与异步消息

在Message中有一种类型叫:异步消息。当系统需要对消息队列中的消息做调度优化时,则会在消息队头插入一个屏障消息,在存在屏障消息时,会优先消费队列中的异步消息,其他的非异步消息无法得到调度,直到屏障消息被移除,屏障消息的标识即:message的target属性为空。符合下面if的判断条件的message即为屏障消息。当队头为屏障消息时,只有isAsynchronous为true的异步消息有机会得到执行。

java 复制代码
if (msg != null && msg.target == null) {
    // Stalled by a barrier.  Find the next asynchronous message in > the queue.
    do {
        prevMsg = msg;
        msg = msg.next;
    } while (msg != null && !msg.isAsynchronous());
}

而屏障消息一般由系统层面发出,用来保障系统的正常运行,例如以下场景:

  1. Activity生命周期:在 Activity 的 onPause()onStop()onDestroy() 方法中,框架可能会发出一个内存屏障消息,以确保在执行这些方法之前,所有与 Activity 相关的消息都已经处理完毕。
  2. View的绘制:在 View 的绘制过程中,框架可能会发出一个内存屏障消息,以确保在执行绘制操作之前,所有与 View 相关的消息都已经处理完毕。
  3. 动画和过渡动效:在执行动画或过渡效果时,框架可能会发出一个内存屏障消息,以确保在执行动画或过渡之前,所有与动画或过渡相关的消息都已经处理完毕。

下图是ViewRootImpl在绘制流程中发出的屏障消息和移除屏障消息的源码:

  1. IdleHandler消息

除了日常使用的Handler的post函数来延迟对实时性要求不高的主线程任务来优化主线程负担外,常用的优化手段还有通过IdleHandler来取代普通的Handler发出的消息。

  • Idle消息执行时机

如其名称,这类消息在消息队列进入idle时刻才能得到执行机会。从源码中看到,在for(;;)时,若异步消息和普通消息都取不到值时,则会进入idle message逻辑:

  • 从Idle消息数组中取出部分Idle消息
  • for循环的形式直接调用Idle消息执行 从以上两个特点可以了解:
  1. idle消息并不在Looper中进行消息分法执行,所以在代码使用中,如果要使用IdleHandler也是通过获取Looper中的message queue对象来设置;
  2. 多个idle消息的执行耗时会体现在一次连续的messagequeue队列消息执行结束后,且idle消息的数量是一次性且连续的执行,所以Idle消息不可一次性放入过多消息或过多较为耗时的任务,否则仍然可能造成主线程卡顿
  3. 目前的主线程消息执行耗时监控更多的是依赖Looper中的final Printer logging = me.mLogging;的消息打印,这种方案无法监控到Idle消息的执行耗时,idle消息的执行耗时只会体现在me.mQueue.next();函数的执行耗时上。
  • Idle消息重组 idle消息存放在mIdleHandlers,外部设置Idle消息也是直接写入这个对象中,而在idle消息的使用中,则是通过mPendingIdleHandlers数组来对idle消息进行循环消费执行,mPendingIdleHandlers数组的创建是在同步块中执行,而消息消费则在代码块外,这种设计保证了idle消息的并发安全。
java 复制代码
// 在 synchronized(this) 块中执行
if (pendingIdleHandlerCount < 0
        && (mMessages == null || now < mMessages.when)) {
    pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
    // No idle handlers to run.  Loop and wait some more.
    mBlocked = true;
    continue;
}

if (mPendingIdleHandlers == null) {
    mPendingIdleHandlers = new > IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
java 复制代码
// 在 synchronized(this) 块外执行
for (int i = 0; i < pendingIdleHandlerCount; i++) {
    final IdleHandler idler = mPendingIdleHandlers[i];
    mPendingIdleHandlers[i] = null; // release the reference to the handler

    boolean keep = false;
    try {
        keep = idler.queueIdle();
    } catch (Throwable t) {
        Log.wtf(TAG, "IdleHandler threw exception", t);
    }

    if (!keep) {
        synchronized (this) {
            mIdleHandlers.remove(idler);
        }
    }
}
  • idle消息的妙用

idle消息返回一个Boolean参数,表示是否需要在idle队列中保留这个任务,若保留则在每次message队列执到空状态时,idle message则会得到执行时刻,我们可以利用这个特性,在idle中执行一些小而轻的逻辑。预加载数据、清理缓存等操作。注意,在idle消息入队的时候,并不会主动唤醒消息队列,也不会立即做任何执行动作,它的执行依赖消息next()函数的执行,所以执行时机和执行情况难以保证,适合做一些低优先级的任务。

相关推荐
刘艳兵的学习博客3 小时前
刘艳兵-DBA033-如下那种应用场景符合Oracle ROWID存储规则?
服务器·数据库·oracle·面试·刘艳兵
guoruijun_2012_44 小时前
fastadmin多个表crud连表操作步骤
android·java·开发语言
Winston Wood4 小时前
一文了解Android中的AudioFlinger
android·音频
B.-6 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
有趣的杰克6 小时前
Flutter【04】高性能表单架构设计
android·flutter·dart
大耳猫11 小时前
主动测量View的宽高
android·ui
帅次14 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
枯骨成佛14 小时前
Android中Crash Debug技巧
android
用户31574760813514 小时前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
布川ku子15 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试