深入源码理解LiveData的实现原理

我们将从它的设计哲学出发,深入到三大核心场景的源码实现,并揭示其内部精妙的"防坑"设计。


第一部分:设计哲学与核心组件

  • 1. 核心设计:观察者模式 + 生命周期感知

    • LiveData 本质上是一个实现了观察者模式的数据容器。你可以往里面放数据,也可以注册观察者来监听数据的变化。
    • 它的"魔法"在于,它不是一个普通的观察者模式,而是与 Android 的 Lifecycle 组件紧密绑定。这使得它能自动管理订阅关系,避免内存泄漏和空指针异常。
  • 2. "演员表":关键类与接口

    • LiveData<T> : 主角。它持有数据 (mData)、一个观察者列表 (mObservers) 和一个版本号 (mVersion)。
    • Observer<T> : 用户实现的接口,只有一个 onChanged(T data) 方法,用于接收数据更新。
    • LifecycleOwner : 通常是 ActivityFragment,它提供了生命周期状态。
    • LifecycleBoundObserver : 最重要的内部类 ,是连接三者的"胶水"。它将你传入的 ObserverLifecycleOwner 包装 起来,同时它自己也实现了 LifecycleEventObserver,从而能"监听"生命周期的变化。

第二部分:三大核心场景源码剖析

场景一:liveData.observe(lifecycleOwner, observer) - 订阅的建立

当你调用 observe 方法时,一场精密的"绑定仪式"就开始了。

简化源码 & 步骤解析:

java 复制代码
// LiveData.java
@MainThread
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
    // 1. 安全检查:如果组件已销毁,直接忽略,防止内存泄漏
    if (owner.getLifecycle().getCurrentState() == DESTROYED) {
        return;
    }

    // 2. 核心步骤:将你的 Observer 和 LifecycleOwner 打包成一个 LifecycleBoundObserver
    LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);

    // 3. 存储:将这个包装好的 wrapper 存入 mObservers 映射表中
    //    如果这个 observer 之前已经订阅过,会先移除旧的,再添加新的
    ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
    
    // ... 处理重复订阅的逻辑 ...

    // 4. 关键绑定:让 wrapper 开始观察 LifecycleOwner 的生命周期
    owner.getLifecycle().addObserver(wrapper);
}

发生了什么?

  1. 创建"胶水"对象LiveData 并不直接持有你的 Observer,而是创建了一个 LifecycleBoundObserver 包装类。
  2. 双向绑定
    • LiveData 在自己的 mObservers 列表里持有了这个 wrapper
    • wrapper 通过 addObserver 方法,把自己注册为了 LifecycleOwner 的一个生命周期观察者。
  3. 结果 :现在,LifecycleOwner 的任何生命周期变化(如 ON_START, ON_STOP),都会通知到 LifecycleBoundObserver,这是实现生命周期感知的关键。

场景二:liveData.setValue(data) / postValue(data) - 数据的分发

  • setValue(T value) - 主线程更新

    java 复制代码
    // LiveData.java
    @MainThread
    protected void setValue(T value) {
        // 1. 安全检查:必须在主线程调用
        assertMainThread("setValue");
    
        // 2. 版本升级:这是防止数据倒灌和重复通知的关键
        mVersion++; 
        
        // 3. 更新数据
        mData = value;
    
        // 4. 开始分发
        dispatchingValue(null);
    }
  • postValue(T value) - 任意线程更新

    java 复制代码
    // LiveData.java
    protected void postValue(T value) {
        // 将 setValue 操作打包成一个 Runnable,post 到主线程的消息队列
        ArchTaskExecutor.getInstance().postToMainThread(new Runnable() {
            @Override
            public void run() {
                setValue(value);
            }
        });
    }
  • dispatchingValue(@Nullable ObserverWrapper initiator) - 分发的核心

    java 复制代码
    // LiveData.java
    void dispatchingValue(@Nullable ObserverWrapper initiator) {
        // ... 省略了重入检查逻辑 ...
    
        // 遍历所有已注册的观察者 wrapper
        for (Iterator<Map.Entry<Observer<? super T>, ObserverWrapper>> iterator =
                mObservers.iteratorWithAdditions(); iterator.hasNext(); ) {
            // 对每个观察者,调用 considerNotify
            considerNotify(iterator.next().getValue());
        }
        
        // ...
    }
  • considerNotify(ObserverWrapper observer) - 决定是否通知

    java 复制代码
    // LiveData.java
    private void considerNotify(ObserverWrapper observer) {
        // 1. 关键检查1:观察者是否处于"活跃"状态?
        //    对于 LifecycleBoundObserver 来说,这意味着组件至少是 STARTED
        if (!observer.mActive) {
            return;
        }
        
        // 2. 关键检查2:观察者的生命周期是否正常?
        if (!observer.shouldBeActive()) {
            observer.activeStateChanged(false); // 如果不正常,则将其置为非活跃
            return;
        }
    
        // 3. 关键检查3:版本号检查,防止重复通知
        if (observer.mLastVersion >= mVersion) {
            return;
        }
        
        // 4. 更新观察者版本号,并发送通知
        observer.mLastVersion = mVersion;
        observer.mObserver.onChanged((T) mData);
    }

发生了什么?

  1. setValue 必须在主线程,它会增加版本号 mVersion,然后触发分发。
  2. postValue 可以在任何线程,它只是把 setValue 的调用"扔"到了主线程去执行。
  3. 分发时,LiveData 会遍历所有的观察者,但不是无脑通知 ,而是通过 considerNotify 进行了三重过滤:
    • 组件不活跃?不通知! (避免了更新已停止的UI)
    • 生命周期已结束?不通知!
    • 已经收到过这个版本的数据了?不通知! (防止配置变更等场景下的重复调用)
  4. 只有通过所有检查的观察者,才会最终调用其 onChanged 方法。

场景三:生命周期感知 - 自动驾驶的实现

LifecycleBoundObserver 是如何知道自己是 active 还是 inactive 的?因为它监听了生命周期事件。

java 复制代码
// LiveData.java -> LifecycleBoundObserver
class LifecycleBoundObserver extends ObserverWrapper implements LifecycleEventObserver {
    @Override
    public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) {
        // 1. 获取当前生命周期状态
        Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
        if (currentState == DESTROYED) {
            // 如果已销毁,自动移除订阅
            removeObserver(mObserver);
            return;
        }
        
        // 2. 根据新状态,判断自己是否应该 active
        boolean newActiveState = currentState.isAtLeast(STARTED);

        // 3. 调用 activeStateChanged,更新自己的状态并通知 LiveData
        activeStateChanged(newActiveState);
    }

    @Override
    void activeStateChanged(boolean newActive) {
        if (newActive == mActive) {
            return;
        }
        mActive = newActive;
        
        // ... 这里会调用 LiveData.this.mActiveCount += mActive ? 1 : -1; ...
        // ... 并触发 LiveData 的 onActive / onInactive 回调 ...

        // 如果变为活跃,立即尝试分发一次数据
        if (mActive) {
            dispatchingValue(this);
        }
    }
}

发生了什么?

  1. 当 Activity onStart() 时,onStateChanged 被调用,newActiveState 变为 true
  2. activeStateChanged 被调用,mActive 变为 true,同时 LiveData 的活跃观察者计数器 mActiveCount 会加 1。
  3. 最重要的是,它会立即调用 dispatchingValue(this)尝试把最新的数据推送给这个刚刚变为活跃的观察者。这就是为什么你的界面一恢复,总能显示最新数据的原因。
  4. 当 Activity onStop() 时,流程相反,mActive 变为 falsemActiveCount 减 1。
  5. 当 Activity onDestroy() 时,onStateChanged自动调用 removeObserver ,彻底断开订阅关系,从根源上防止了内存泄漏

第三部分:精妙设计 - "防坑"机制

  • 1. 版本号机制 (mVersion & mLastVersion)

    • 解决了什么问题? 防止数据倒灌和重复消费。
    • 场景 :一个 Activity 在后台时,LiveData 的值变化了 5 次 (A -> B -> C -> D -> E)。当 Activity 恢复到前台时,它不应该 接收 A,B,C,D,E 五次 onChanged 回调,而只应该接收一次最新的值 E
    • 原理LiveData 有一个全局版本 mVersion,每个观察者有自己的 mLastVersion。只有当 mLastVersion < mVersion 时才会通知。当 Activity 恢复时,它的 mLastVersion 远小于最新的 mVersion,于是 onChanged 被调用一次,同时 mLastVersion 更新到最新,后续的重复检查就不会再通过了。
  • 2. onActive() / onInactive() 回调

    • 解决了什么问题? 资源管理。
    • 场景 :一个 LiveData 需要监听 GPS 位置变化。我们不希望在没有界面显示位置时,还在后台耗电监听 GPS。
    • 原理LiveData 内部维护了一个活跃观察者计数器 mActiveCount
      • mActiveCount0 变为 1 时,onActive() 被调用。此时你可以开始监听 GPS。
      • mActiveCount1 变为 0 时,onInactive() 被调用。此时你可以停止监听 GPS,节省资源。

流程总结:setValue 时序图

sequenceDiagram participant BG_Thread as 后台线程 participant Main_Thread as 主线程 participant LiveData participant LifecycleBoundObserver as ObserverWrapper participant YourObserver as 你的Observer BG_Thread->>LiveData: postValue("New Data") LiveData->>Main_Thread: ArchTaskExecutor.postToMainThread(...) Main_Thread->>LiveData: setValue("New Data") activate LiveData LiveData->>LiveData: mVersion++ LiveData->>LiveData: dispatchingValue(null) LiveData->>ObserverWrapper: considerNotify(wrapper) activate ObserverWrapper ObserverWrapper->>ObserverWrapper: if (mActive && shouldBeActive()) ObserverWrapper->>ObserverWrapper: if (mLastVersion < mVersion) ObserverWrapper->>YourObserver: onChanged("New Data") ObserverWrapper->>ObserverWrapper: mLastVersion = mVersion deactivate ObserverWrapper deactivate LiveData

如果同时调用 postValue("a")和setValue("b"),一定是值b被值a覆盖 是什么原因?

这个问题问得非常好,它直击了 LiveData 核心的线程调度机制,也是一个非常经典的 LiveData "陷阱"和面试题。

您观察到的现象 "postValue("a") 之后立即调用 setValue("b"),最终值是 a(即值b被值a覆盖)" 是完全正确的。

原因是:setValue 是同步的,而 postValue 是异步的。

我们来深入源码和执行流程,看看这背后到底发生了什么。


核心机制:主线程的 MessageQueue

要理解这个现象,首先必须理解 Android 主线程是如何工作的。主线程有一个 Looper,它不断地从一个 MessageQueue (消息队列) 中取出任务(MessageRunnable)来执行。这是一个先进先出(FIFO)的队列。

  • setValue(T value) : 这是一个同步 操作。它会立即在当前线程执行。如果当前不是主线程,它会直接抛出异常。
  • postValue(T value) : 这是一个异步 操作。它做的事情非常简单:把一个"更新值的任务"(一个 Runnable提交到主线程的 MessageQueue 的队尾,然后立即返回。它本身并不执行更新操作。

分步执行流程:一场注定会输的赛跑

假设我们当前就在主线程上执行以下代码:

kotlin 复制代码
// 假设 liveData 的初始值是 "initial"
Log.d("TEST", "1. 即将调用 postValue")
liveData.postValue("a") 
Log.d("TEST", "2. 即将调用 setValue")
liveData.setValue("b")
Log.d("TEST", "3. 所有调用已完成")

下面是代码执行的精确时间线:

  1. Log.d("TEST", "1. 即将调用 postValue"): 日志被打印。

  2. liveData.postValue("a") 被调用:

    • LiveData 内部创建了一个 Runnable,这个 Runnable 的任务是 setValue("a")
    • 这个 RunnableArchTaskExecutor 发送(post) 到了主线程的 MessageQueue 的末尾,排队等待执行。
    • postValue 方法立即返回 ,代码继续往下执行。此时 LiveData 的值仍然是 "initial"
  3. Log.d("TEST", "2. 即将调用 setValue"): 日志被打印。

  4. liveData.setValue("b") 被调用:

    • 这是一个同步调用。
    • 代码立即执行 mVersion++mData = "b"
    • LiveData 的值立刻、马上 变成了 "b"
    • dispatchingValue 被触发,所有活跃的观察者都会收到值 "b"。
    • setValue 方法执行完毕并返回。
  5. Log.d("TEST", "3. 所有调用已完成"): 日志被打印。

  6. 当前代码块执行完毕: 你的这段代码(比如一个点击事件的回调)执行完了。

  7. Looper 空闲下来 : 主线程的 Looper 说:"好了,我手头上的活干完了,看看我的消息队列里还有什么任务?"

  8. Looper 取出任务 : LooperMessageQueue 的队首取出了在第 2 步被放进去的那个 Runnable

  9. Runnable 被执行:

    • 这个 Runnable 的核心代码 setValue("a") 被执行。
    • LiveData 的值从 "b" 被覆盖成了 "a"
    • dispatchingValue 再次被触发,所有活跃的观察者现在会收到值 "a"。

结论

setValue("b") 就像是"插队",它在你当前的代码块里被立即执行了。而 postValue("a") 只是拿到了一个"排队号码",必须等到当前所有同步代码都执行完毕,主线程空闲下来之后,才轮到它执行。

因此,无论调用顺序如何,同步的 setValue 总是先于异步的 postValueRunnable 执行,但 postValueRunnable 执行得更晚,所以它的值会覆盖掉 setValue 的值


一个重要的补充:多次 postValue 会发生什么?

这也是一个常见的面试题。如果你在后台线程 连续、快速地调用 postValue,会发生什么?

kotlin 复制代码
// 在后台线程执行
liveData.postValue("a")
liveData.postValue("b")
liveData.postValue("c")

结果是:只有最后一个值 "c" 会被分发。

原因LiveData 内部做了一个优化。当 postValue 被调用时,它会向主线程 post 一个 Runnable。如果在这个 Runnable 还未被执行之前 ,你又调用了 postValue,它不会 post 一个新的 Runnable,而是会用新的值覆盖掉等待被执行的那个旧值。

这确保了在密集的后台更新中,只有最新的数据会被推送到 UI,避免了消息队列的拥堵和不必要的 UI 刷新。

LiveData 内部做了一个优化。当 postValue 被调用时,它会向主线程 post 一个 Runnable。如果在这个 Runnable 还未被执行之前,你又调用了 postValue,它不会 post 一个新的 Runnable,而是会用新的值覆盖掉等待被执行的那个旧值。 这一部分的源码在哪里?

当然,这个优化是 LiveData 设计中非常精妙的一部分。相关的源码主要集中在 LiveData.java 文件中的 postValue 方法本身,以及几个关键的成员变量。


源码位置

您可以在 androidx.lifecycle.LiveData 类的源码中找到这段逻辑。关键代码如下:

1. 关键的成员变量

java 复制代码
// LiveData.java

// 用于同步锁,确保多线程访问 mPendingData 时的安全
final Object mDataLock = new Object();

// 用于标记 mPendingData 是否为空,它是一个内部单例对象
static final Object NOT_SET = new Object();

// 存储等待被 post 到主线程的数据。
// volatile 关键字确保了多线程间的可见性。
volatile Object mPendingData = NOT_SET;

// 这是一个【单例】的 Runnable,整个 LiveData 实例只会创建一次。
// 它的作用就是去主线程调用 setValue。
private final Runnable mPostValueRunnable = new Runnable() {
    @Override
    public void run() {
        Object newValue;
        // 同步块,确保安全地取出数据并重置标记
        synchronized (mDataLock) {
            newValue = mPendingData;
            mPendingData = NOT_SET; // **关键:执行后立即重置标记**
        }
        // 在主线程调用 setValue
        setValue((T) newValue);
    }
};

2. postValue(T value) 方法的实现

java 复制代码
// LiveData.java

protected void postValue(T value) {
    boolean postTask;
    // 进入同步块,保证线程安全
    synchronized (mDataLock) {
        // 检查 mPendingData 是否是初始状态 NOT_SET
        // 如果是,说明当前没有等待执行的任务,需要 post 一个新的
        postTask = mPendingData == NOT_SET;
        
        // **无论如何,都用新值覆盖 mPendingData**
        // 这就是为什么只有最后一个值生效的原因
        mPendingData = value;
    }

    // 如果 postTask 是 false,意味着已经有一个 mPostValueRunnable 在主线程队列里排队了
    // 此时我们已经用新值覆盖了 mPendingData,所以直接 return 即可,无需重复 post。
    if (!postTask) {
        return;
    }
    
    // 如果 postTask 是 true,说明这是自上次执行完后的第一次 post
    // 于是将那个【单例的】mPostValueRunnable 提交到主线程
    ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
}

源码执行流程分析

让我们来模拟一下在后台线程连续调用两次 postValue 的场景:

场景:

java 复制代码
// 在后台线程
liveData.postValue("A");
liveData.postValue("B");

第 1 步: liveData.postValue("A") 被调用

  1. 进入 synchronized (mDataLock) 代码块。
  2. 此时 mPendingData 的值是 NOT_SET
  3. postTask = (mPendingData == NOT_SET),所以 postTask 被赋值为 true
  4. mPendingData = "A"mPendingData 的值被更新为 "A"。
  5. 退出同步块。
  6. if (!postTask) 条件不成立。
  7. ArchTaskExecutor...postToMainThread(mPostValueRunnable) 被调用。一个 mPostValueRunnable 任务被放到了主线程的消息队列末尾,等待执行

第 2 步: liveData.postValue("B") 被立即调用 (假设此时主线程繁忙,之前的 Runnable 还没执行)

  1. 再次进入 synchronized (mDataLock) 代码块。
  2. 此时 mPendingData 的值是 "A"。
  3. postTask = (mPendingData == NOT_SET),所以 postTask 被赋值为 false
  4. mPendingData = "B"mPendingData 的值从 "A" 被覆盖成了 "B"。
  5. 退出同步块。
  6. if (!postTask) 条件成立 (!false -> true)。
  7. return; 方法直接返回。没有新的 Runnable 被 post!

第 3 步: 主线程空闲,开始执行消息队列中的任务

  1. 主线程的 Looper 从队列中取出了在第 1 步被 post 的那个 mPostValueRunnable 任务。
  2. mPostValueRunnable.run() 方法被执行。
  3. 进入 synchronized (mDataLock) 代码块。
  4. newValue = mPendingData,此时 mPendingData 的值是 "B",所以 newValue 被赋值为 "B"。
  5. mPendingData = NOT_SET,标记被重置,为下一次 postValue 做好准备。
  6. 退出同步块。
  7. setValue((T) newValue),也就是 setValue("B") 被调用。
  8. 最终,观察者收到的值是 "B"。

总结: 这个设计的核心就在于mPendingData 变量同时承担了"存储待更新数据"和"判断是否有任务在排队"两个角色 ,并结合 synchronized 锁和 volatile 关键字,用一个可重用的 Runnable 实例,高效且线程安全地实现了 postValue 的合并与优化。

LiveData的粘性事件 原理

好的,我们来深入探讨 LiveData 的"粘性事件"这个话题。

首先,需要明确一点:"粘性事件"并不是 LiveData 设计时的一个"功能",而是它内在机制所带来的一个"现象"或"副作用" 。理解了我们之前讨论的 LiveData 源码,就能很自然地理解粘性事件的由来了。


1. 什么是粘性事件?

粘性事件指的是:一个观察者(Observer)在注册到 LiveData 之后,会立即收到 LiveData 中当前持有的、最新的那一份数据,即使这份数据是在这个观察者注册之前就已经发送的。

举个典型的例子:

  1. 用户在列表页点击了某个商品,然后App发送了一个网络请求去获取商品详情。
  2. 在网络请求发出后、结果返回前,用户跳转到了详情页
  3. 详情页Fragment 创建 (onCreate),并注册了一个 Observer 来监听商品详情 LiveData
  4. 一两秒后,网络请求成功,LiveData 的值被更新。
  5. 此时,由于详情页的 Observer 已经注册,它会正常收到商品数据并更新UI。(这是正常事件)

粘性事件的场景:

  1. 用户在列表页点击了某个商品,App发送网络请求。
  2. 网络请求很快 就成功返回了,LiveData 的值被更新为商品详情数据。
  3. 此时用户 跳转到详情页
  4. 详情页Fragment 创建,并注册了一个 Observer 来监听这个已经有值LiveData
  5. 就在 observe 方法被调用的那一刻Observer立即收到之前已经更新好的商品详情数据,并刷新UI。

这个"先更新数据,后注册监听,但监听者依然能收到数据"的现象,就是所谓的"粘性事件"。数据就像被"粘"在了 LiveData 上,等待着下一个新的观察者来消费。


2. 粘性事件的原理:源码再回顾

粘性事件的原理,就藏在我们之前分析过的 LiveData.observe()LifecycleBoundObserver.onStateChanged() 的源码里。

核心流程回顾:

  1. 调用 liveData.observe(lifecycleOwner, observer):

    • LiveDatalifecycleOwnerobserver 打包成一个 LifecycleBoundObserver (我们称之为 wrapper)。
    • wrapper 被添加到 LiveData 的观察者列表 mObservers 中。
    • wrapper 把自己注册为 lifecycleOwner 的一个生命周期观察者
  2. 生命周期变化,触发 onStateChanged:

    • FragmentActivity 的生命周期推进,例如执行到 onStart() 时,它的 Lifecycle 状态会变为 STARTED
    • 这个变化会通知到我们注册的 wrapper,其 onStateChanged() 方法被调用。
  3. activeStateChanged(true) 被调用:

    • onStateChanged() 内部,它会检查当前的生命周期状态。因为 STARTED 状态满足 isAtLeast(STARTED),所以它会调用 activeStateChanged(true) 来将自己标记为"活跃"。
  4. 关键代码:dispatchingValue(this)

    • activeStateChanged 方法的最后,有这样一段至关重要的代码:
    java 复制代码
    // LiveData.java -> LifecycleBoundObserver
    @Override
    void activeStateChanged(boolean newActive) {
        if (newActive == mActive) {
            return;
        }
        mActive = newActive;
        // ...
        
        // 如果是从非活跃变为活跃
        if (mActive) {
            // **立即触发一次数据分发!**
            dispatchingValue(this);
        }
    }
  5. considerNotify() 被调用:

    • dispatchingValue 会遍历观察者列表,并对我们这个刚刚激活的 wrapper 调用 considerNotify
    • considerNotify 内部,会进行版本号检查:if (observer.mLastVersion >= mVersion)
    • 因为我们这个 wrapper全新 的,它的 mLastVersion 初始值是 -1。而 LiveData 因为之前已经更新过数据,它的 mVersion 肯定大于 -1
    • 因此,版本检查通过
    • wrapperonChanged 方法被调用,粘性事件发生。

原理总结: LiveData 的粘性,源于其**"当观察者从非活跃状态变为活跃状态时,会立即尝试将当前持有的最新数据分发给它"**这一核心设计。这个设计确保了 UI 组件总能与最新的数据状态保持同步,但也因此产生了"粘性"的副作用。


3. 如何处理不想要的粘性事件?

在很多场景下,我们只希望 Observer 接收在它注册之后 才发生的事件,比如"显示一次性的 Toast"或"执行一次性的导航"。粘性事件在这种情况下会导致问题(例如,屏幕旋转后 Fragment 重建,会再次收到旧的 Toast 事件)。

处理这个问题通常有以下几种方案:

  1. SingleLiveEvent 模式 (官方推荐):

    • 创建一个继承自 LiveData 的自定义类,重写 observe 方法。
    • 内部使用一个 AtomicBoolean 来标记事件是否已经被消费过。
    • 只有在"未被消费"的状态下,才真正调用 onChanged,并且一旦调用就将事件标记为"已消费"。
    • 这是最常见、最稳健的解决方案。
  2. 包装类方案:

    • LiveData 中存储的不是原始数据 T,而是一个包装类 Event<T>
    • Event 类内部有一个 hasBeenHandled 的布尔标记。
    • Observer 收到 Event 对象后,先检查 hasBeenHandled 标记。如果未被处理,则处理数据并手动将标记置为 true
  3. 使用 Flow:

    • 在现代 Android 开发中,对于一次性事件,使用 Kotlin Coroutines 的 SharedFlowChannel 通常是更好的选择,因为它们在设计上就是为了处理这种"事件流"而不是"状态",能更优雅地解决粘性问题。

理解了粘性事件的原理,你就能更好地决定何时利用它,何时需要规避它了。

相关推荐
黄额很兰寿3 小时前
flow 的冷流和热流 是设么有什么区别?
android
Digitally3 小时前
如何将 Android 联系人备份到 Mac 的 4 种简单
android·macos
2501_915918414 小时前
iOS 混淆与 IPA 加固一页式行动手册(多工具组合实战 源码成品运维闭环)
android·运维·ios·小程序·uni-app·iphone·webview
不吃凉粉12 小时前
Android Studio USB串口通信
android·ide·android studio
zhangphil12 小时前
android studio设置大内存,提升编译速度
android·android studio
编程乐学13 小时前
安卓非原创--基于Android Studio 实现的天气预报App
android·ide·android studio·课程设计·大作业·天气预报·安卓大作业
大熊的瓜地14 小时前
Android automotive 框架
android·android car
私人珍藏库15 小时前
[Android] Alarm Clock Pro 11.1.0一款经典简约个性的时钟
android·时钟
消失的旧时光-194317 小时前
ScheduledExecutorService
android·java·开发语言