Android相关线程基础

线程基础

进程与线程

进程:可以被看做是程序的实体, 是系统进行资源分配和调度的基本单位.

线程:是操作系统调度的最小单元, 也叫轻量级进程

使用多线程的优点

可以减少程序的响应时间。如果某个操作很耗时, 能够避免陷入长时间的等待, 从而有着更好的交互性.

线程较之进程, 创建和切换的开销更小, 在共享数据方面的效率非常高.更高利用多CPU或多核设备的性能

简化程序结构, 便于理解维护.

线程的状态

  • New
  • Runnable(可运行状态, 不是立即运行, 取决于系统的决定)
  • Blocked(当调用同步方法而未获取锁时会进入阻塞状态)
  • Waiting(暂时不活动, 不运行任何代码, 消耗最少的资源)
  • Timed waiting(与waiting相比, 能在指定的时间自行返回)
  • Terminated(终止状态,已经执行完毕或者异常退出)

阻塞状态是线程因为某些条件不满足(如等待I/O操作或等待获取锁),而暂时停止执行,直至条件得到满足。处于等待的线程不会尝试获取锁,它是在等待其他线程显式地通知或中断,才返回到可运行状态。

创建线程

创建线程的方法一般有三种:

继承Thread类, 重写run方法(本质为实现Runnable接口的一个实例)

1.创建继承Thread类的子类, 并重写run方法(执行体).

2.创建子类的实例

3.调用实例对象的start方法启动线程

实现Runnable接口, 并实现接口的run方法

1.自定义一个实现Runnable接口的类, 实现run方法

2.创建上面的类的对象, 并将其作为参数去创建一个Thread子类的实例.Thread mThread = new Thread(参数)

3.调用Thread.start().

实现Callable接口, 重写call方法

是Executor框架中的功能类, 与Runnable接口的功能类似, 但提供了比Runnable更强大的功能, 主要表现在以下3点:

1.任务结束后提供一个返回值,

2.call方法可以抛出异常

3.运行了Callback后可以得到一个Future对象, 该对象利用Future.get方法监视目标线程调用call方法的情况, 但会一直阻塞直到call方法返回结果

进程中断

线程中断是一种协作机制,它允许一个线程告知另一个线程希望它停止当前正在做的事情。中断是一种软件协议,而不是强迫线程停止的方法。当一个线程中断另一个线程时,被中断的线程并不会立即停止运行,而是由这个线程决定如何响应中断。

理解线程中断的几个关键点如下:

  1. 中断标志位:每个线程都有一个中断状态,它表示线程是否被中断。通过调用线程实例的 interrupt() 方法可以设置这个线程的中断状态。如果线程在执行时没有检查中断状态,则调用 interrupt() 不会立刻停止线程。
  2. 检查中断:线程可以通过调用静态方法 Thread.interrupted() 或实例方法 isInterrupted() 来检查它自己是否被中断。Thread.interrupted() 会清除当前线程的中断状态,而 isInterrupted() 则不会。
  3. 响应中断:当线程检测到中断请求时,可以通过多种方式来响应:
    1. 完全忽略中断请求,这通常不是好的做法,因为这会让发送中断的线程很难控制这个线程。
    2. 清理资源,停止当前任务并优雅退出。
    3. 抛出 InterruptedException,这通常发生在线程阻塞操作(如 sleep(), wait(), join())中被中断时。在抛出 InterruptedException 之前,JVM会先将这个线程的中断状态清除,然后抛出异常。
  4. 不应该忽视中断:一个设计良好的线程任务在检测到中断时,应当尽快清理资源,保存状态,并且合理地结束执行,以便程序整体可以正确反应中断请求,如停止或重启任务。
Java 复制代码
class Task implements Runnable {
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            // 在这里执行任务工作
            try {
                // 假设此处有可能阻塞的操作
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 当线程在sleep时被中断,会进入这个catch块
                System.out.println("Thread was interrupted, safely stopping.");
                break; // 退出循环,结束线程的执行
            }
        }
        // 清理资源和状态
    }
}

public class InterruptExample {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Task());
        thread.start();
        // 给线程一些时间来启动并执行任务
        Thread.sleep(3000);
        // 发出中断请求
        thread.interrupt();
    }
}

线程同步

  • 同步 Java中的同步指的是通过人为的控制和调度,保证共享资源多线程访问成为线程安全,来保证结果的准确。

ReentrantLock(重入锁)

ReentrantLock定义了一个单独的布尔值,标记锁是否被任何线程锁定。如果是,则持有锁的线程可以再次获得,但必须释放它同样多的次数才能解锁。

Java 复制代码
class SharedObject {
    private ReentrantLock lock = new ReentrantLock();

    void sharedMethod() {
        lock.lock();  // 在访问临界区之前获取锁
        try {
            // 临界区:只有一个线程在同一时间可以访问
            // 像这样的代码...
        } finally {
            lock.unlock();  //总是在finally块中释放锁
        }
    }
}

关于sleep与wait的不同点:

  • sleep方法没有释放锁,而wait方法释放了锁,使得其他线程可以使用同步控制块或者方法。
  • wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用
  • sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常
  • sleep是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用sleep不会释放对象锁。
  • wait是Object类的方法,对此对象调用wait方法导致本线程放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象发出notify方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态。

重入锁(ReentrantLock)与条件对象

支持重进入的锁, 表示该锁支持一个线程对锁本身的重复加锁(其他的线程此时不能对该锁加锁)

加锁的次数要和解锁的次数要相同, 否则其他线程对此锁无法加锁

条件对象condition中提供了condition.await()方法(进入阻塞状态并放弃锁)和condition.signalAll()方法(将该条件对象上等待的线程唤醒, 解除阻塞, 这里说明一下并不是立即激活)

Java 复制代码
//对重入锁和条件对象的测试
public class HelloWorld {

        static ReentrantLock lock = new ReentrantLock();
        static Condition condition = lock.newCondition();
        public static void main(String[] args) throws InterruptedException {

        lock.lock();
        new Thread(new SignalThread()).start();
        System.out.println("主线程等待通知");
        try {
                System.out.println("主线程的前try");
            condition.await(); //释放锁, 等唤醒后需要重新获取锁
            System.out.println("主线程的后try");
        } finally {
                System.out.println("主线程的final");
            lock.unlock();
        }
        System.out.println("主线程恢复运行");
    }
    static class SignalThread implements Runnable {

        @Override
        public void run() {
            lock.lock();
            try {
                    System.out.println("子线程的前try");
                condition.signal();
                System.out.println("子线程通知");
            } finally {
                    System.out.println("子线程的final");
                lock.unlock();
            }
        }
    }
}

同步方法

使用synchronized修饰的方法叫做同步方法,java中每一个对象都有一个锁,使用synchronized声明一个方法,那么对象的锁将会保护一整个方法,使用同步方法是为了防止多个线程在更新相同的资源时导致数据出错。

  • 实例方法锁定当前对象的实例(使用this作为锁对象)。
  • 静态方法锁定当前对象所属的Class对象(使用class对象作为锁对象)

同步代码块

使用synchronized关键字加上一个锁对象来创建一个执行区域,这个区域内的代码在同一时刻只能被一个线程执行。

Java 复制代码
synchronized(锁对象) {
    // 需要同步的代码
}

锁对象的选择

  • 使用特定对象作为锁对象:通常是共享资源或者与共享资源紧密相关的对象。
  • 使用当前实例this作为锁对象:适用于多个线程访问某个对象实例的同步问题。
  • 使用类对象ClassName.class作为锁对象:适用于静态方法或者静态资源的同步。

volatile

volatile是Java提供的一种特殊的变量类型修饰符,主要用于确保多线程环境中共享变量的可见性(线程A在修改了本地内存的值后,可能还没来得及同步回主内存,线程B就已经读取了该变量,这时候线程B读取到的就是旧的值,也就实现了线程A修改的值对线程B不可见,这就是所谓的可见性问题)和顺序性。

java内存模型

Java中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此,它存在内存可见性的问题。而局部变量、方法定义的参数则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。Java内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。需要注意的是本地内存是Java内存模型的一个抽象概念,Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。Java内存模型的抽象示意图如图所示。

线程A与线程B之间若要通信的话,则必须要经历下面两个步骤:

(1)线程A把线程A本地内存中更新过的共享变量刷新到主存中去。

(2)线程B到主存中去读取线程A之前已更新过的共享变量。由此可见,如果我们执行下面的语句:

Java 复制代码
int i=3;

执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存中,而不是直接将数值3写入主存中。

原子性,可见性和有序性

  1. 原子性

    1. 原子性指的是一个或多个操作要么全部执行且在执行过程中不会被任何因素打断,要么就全部都不执行。在Java中,原子性操作通常是指不可分割的操作,比如对基本数据类型(除了long和double之外)的读取和赋值操作。例如,当一个线程正在执行一个原子操作时,其他线程不能中断它,直到该操作完全完成。synchronized可以帮助确保方法或者代码块在执行时不会被其他线程干扰,保证了操作的原子性。
  2. 可见性 可见性是指一个线程对共享变量的修改能够及时地被其他线程见到。缺乏可见性时,一个线程可能无法看到另一个线程对共享变量所做的修改。在Java中,可见性问题通常是通过使用volatile关键字或者同步机制(如synchronizedLock)来解决。volatile保证了一个线程修改的变量值对其他线程来说立刻变得可见,而synchronized保证了一个线程在访问共享资源时,先清空工作内存,然后从主内存加载资源。

  3. 有序性 有序性是指程序执行的顺序按照代码的先后顺序执行。由于编译器优化,处理器可能会对指令进行重排序,使得程序执行的顺序与代码书写的顺序不同。在多线程环境中,这可能导致严重问题。为了预防指令重排序的问题,可以使用volatile关键字或者synchronized关键字。它们都可以提供一定程度的有序性保证。volatile变量规则可以确保对volatile变量的写操作不会和之前的读写操作重排序;相似地,synchronized可以确保获取和释放monitor会形成一个内存屏障,避免指令重排序。

volatile关键字

前面提到的三个特性volatile关键字可以保证可见性(一个线程修改了volatile变量的值,新值对于其他线程来说是立即可见的)以及有序性(当一个变量声明为volatile后,会有一个"屏障"限制重排序,确保在volatile变量写操作之前的所有操作都不会被编译器重排序到写操作之后),但是它是不保证原子性的,如果由原子性相关需求还是考虑synchronized

使用它有俩个条件

  • , 即变量真正独立于(不能使用)其他变量和自己以前的值, 即不能自增, 自减, 因为不能保证原子性.
  • 没有包含在其他变量的不变式中(两个线程都会通过用于保护不变式的检查导致错误), 比如 0, 10, 一个线程修改最大值为5, 同时另一个线程修改最小值为6, 此时区间就变成了6, 5. 很明显就错了

使用场景

1.状态标志, 使用volatile修饰Boolean类型的变量, 不依赖于程序内的任何其他状态 2.双重检查锁定模式(DCL)

阻塞队列

阻塞队列简介

阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。一个线程可以从队列中获取元素,如果队列为空,那么线程会阻塞直到队列中有元素;同样地,一个线程可以向队列中插入元素,如果队列已满,那么该线程会阻塞直到队列中有空间。

  1. 阻塞队列就是生产者存放元素的容器, 常见的阻塞场景: 1.队列没有数据, 消费者端的所有线程都会自动挂起(阻塞), 直到有数据放入队列,线程被唤醒 2.队列中数据填满了, 生产者端的所有线程都会自动挂起, 直到队列中有空的位置, 线程被唤醒

支持这两种阻塞场景的队列叫做阻塞队列

BlockingQueue的核心方法:

放入数据

  1. offer(anObject): 如果阻塞队列可以容纳, 返回true, 否则返回false, 并且将anObject放入阻塞队列. (本方法不会阻塞当前执行方法的线程)
  2. offer(E, long, TimeUnit): 在指定时间内如果不能往队列中添加, 则返回false
  3. put(anObject): 如果阻塞队列没有空间, 则调用此方法的线程被阻断, 直到有空间再继续.

获取数据

  1. poll(time): 在等待的时间内都不能取到排在队列首位的对象就返回null.
  2. poll(long, TimeUnit): 在指定时间内不能取出数据, 返回false
  3. take(): 为空则阻断
  4. drainTo(): 取出所有的可用数据对象(还可以指定个数), 可以提高数据获取效率, 无需多次分批加锁. 释放锁
Java 复制代码
public class ProducerConsumerExample {
    public static void main(String[] args) {
        BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);

        // 生产者线程
        Thread producer = new Thread(() -> {
            try {
                queue.put("Product");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                e.printStackTrace();
            }
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            try {
                String product = queue.take();
                System.out.println("Consumed: " + product);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        producer.start();
        consumer.start();
    }
}

java中的阻塞队列

  • ArrayBlockingQueue: 由数据结构组成的有界阻塞队列
  • LinkedBlockingQueue: 由链表结构组成的有界阻塞队列
  • PriorityBlockingQueue: 支持优先级排序的无界阻塞队列
  • DelayQueue: 使用优先级队列实现的无界阻塞队列
  • SynchronousQueue: 不储存元素的阻塞队列
  • LinkedTransferQueue: 由链表结构组成的无界阻塞队列
  • LinkedBlockingQueue: 由链表结构组成的双向阻塞队列

线程池

ThreadPoolExecutor

ThreadPoolExecutor的构造方法

Java 复制代码
public ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize, 
                            long keepAliveTime, 
                            TimeUnit unit, 
                            BlockingQueue<Runnable> workQueue, 
                            ThreadFactory threadFactory
                            RejectedExecutionHandler handler)

corePoolSize

线程池的核心线程数,默认情况下,核心线程会在线程池中一直存活,即使它们处于闲置状态。如果将ThreadPoolExecutor的allowCoreThreadTimeOut属性设置为true,那么闲置的核心线程在等待新任务到来时会有超时策略,这个时间间隔由keepAliveTime所指定,当等待时间超过keepAliveTime所指定的时长后,核心线程就会被终止。

maximumPoolSize

线程池所能容纳的最大线程数,当活动线程数达到这个数值后,后续的新任务将会被阻塞。

keepAliveTime

非核心线程闲置时的超时时长,超过这个时长,非核心线程就会被回收。当ThreadPool-Executor的allowCoreThreadTimeOut属性设置为true时,keepAliveTime同样会作用于核心线程。

unit

用于指定keepAliveTime参数的时间单位,这是一个枚举,常用的有TimeUnit. MILLISECONDS(毫秒)、TimeUnit.SECONDS(秒)以及TimeUnit.MINUTES(分钟)等。

workQueue

线程池中的任务队列,通过线程池的execute方法提交的Runnable对象会存储在这个参数中。

threadFactory

线程工厂,为线程池提供创建新线程的功能。ThreadFactory是一个接口,它只有一个方法:Thread newThread(Runnable r)。

RejectedExecutionHandler

饱和策略,当任务队列和线程池都满了的时候采取的应对策略,有四种拒绝策略

  1. AbortPolicy (默认) : 这种策略会直接抛出RejectedExecutionException异常,中止任务的执行。这是默认的策略,如果没有设置拒绝策略,线程池会采用这种策略。
  2. CallerRunsPolicy: 这种策略不会抛弃任务,也不会抛出异常,而是将任务回退给调用者线程来直接执行。这意味着如果线程池无法处理任务,那么执行任务的将会是提交任务的线程本身。
  3. DiscardPolicy: 这种策略将静默地忽略无法处理的任务,不抛出异常也不执行任务。如果应用程序可以容忍任务丢失,这种策略会很有用。
  4. DiscardOldestPolicy: 这个策略将丢弃队列中最老的一个任务,然后尝试提交当前的任务(也就是再次提交,而不保证一定会执行)。这样做至少可以保证新提交的任务被执行。

ThreadPoolExecutor执行任务时大致遵循如下规则:

  1. 应用程序向线程池提交一个任务。
  2. 如果当前活跃的线程数小于核心线程数,线程池会创建一个新的工作者线程来执行提交的任务。
  3. 如果核心线程都在忙,新任务就会被放入工作队列中等待。
  4. 如果工作队列已满,而且活跃的线程数小于最大线程数限制,线程池会创建额外的工作者线程来处理队列中的任务。
  5. 如果达到了线程数上限,新提交的任务将根据饱和策略来处理。

(1)提交任务后,线程池先判断线程数是否达到了核心线程数(corePoolSize)。如果未达到核心线程数,则创建核心线程处理任务;否则,就执行下一步操作。

(2)接着线程池判断任务队列是否满了。如果没满,则将任务添加到任务队列中;否则,就执行下一步操作。

(3)这时,因为任务队列满了,所以线程池就判断线程数是否达到了最大线程数。如果未达到最大线程数,则创建非核心线程处理任务;否则,就执行饱和策略,默认会抛出RejectedExecutionException异常。

上面介绍了线程池的处理流程,但还不是很直观。下面结合图4-7,我们就能更好地了解线程池的原理了。

总结,先核心线程,条件不允许,任务队列,条件不允许,非核心线程,条件不允许,饱和策略(这个过程中,核心线程会从任务队列中提取任务)

线程池种类

  1. FixedThreadPool

通过Executors的newFixedThreadPool方法来创建。它是一种线程数量固定的线程池,当线程处于空闲状态时,它们并不会被回收,除非线程池被关闭了。当所有的线程都处于活动状态时,新任务都会处于等待状态,直到有线程空闲出来。由于FixedThreadPool只有核心线程并且这些核心线程不会被回收,这意味着它能够更加快速地响应外界的请求。newFixedThreadPool方法的实现如下,可以发现FixedThreadPool中只有核心线程并且这些核心线程没有超时机制,另外任务队列也是没有大小限制的。

Java 复制代码
public static ExecutorService newFixedThreadPool(int nThreads) { 
    return new ThreadPoolExecutor(nThreads,nThreads, 0L,TimeUnit.MILLISECONDS, 
    new LinkedBlockingQueue<Runnable>()); 
}
  1. CachedThreadPool

通过Executors的newCachedThreadPool方法来创建。它是一种线程数量不定的线程池,它只有非核心线程,并且其最大线程数为Integer.MAX_VALUE。由于Integer.MAX_VALUE是一个很大的数,实际上就相当于最大线程数可以任意大。当线程池中的线程都处于活动状态时,线程池会创建新的线程来处理新任务,否则就会利用空闲的线程来处理新任务。线程池中的空闲线程都有超时机制,这个超时时长为60秒,超过60秒闲置线程就会被回收。和FixedThreadPool不同的是,CachedThreadPool的任务队列其实相当于一个空集合,这将导致任何任务都会立即被执行,因为在这种场景下SynchronousQueue是无法插入任务的。SynchronousQueue是一个非常特殊的队列,在很多情况下可以把它简单理解为一个无法存储元素的队列,由于它在实际中较少使用,这里就不深入探讨它了。从CachedThreadPool的特性来看,这类线程池比较适合执行大量的耗时较少的任务。当整个线程池都处于闲置状态时,线程池中的线程都会超时而被停止,这个时候CachedThreadPool之中实际上是没有任何线程的,它几乎是不占用任何系统资源的。newCachedThreadPool方法的实现如下所示

Java 复制代码
public static ExecutorService newCachedThreadPool() { 
    return new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.SECONDS, 
    new SynchronousQueue<Runnable>()); 
}
  1. ScheduledThreadPool

通过Executors的newScheduledThreadPool方法来创建。它的核心线程数量是固定的,而非核心线程数是没有限制的,并且当非核心线程闲置时会被立即回收。ScheduledThreadPool这类线程池主要用于执行定时任务和具有固定周期的重复任务,newScheduledThreadPool方法的实现如下所示。

Java 复制代码
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { 
    return new ScheduledThreadPoolExecutor(corePoolSize); 
} 
public ScheduledThreadPoolExecutor(int corePoolSize) { 
    super(corePoolSize,Integer.MAX_VALUE,0,NANOSECONDS, 
    new DelayedWorkQueue()); 
}
  1. SingleThreadExecutor

通过Executors的newSingleThreadExecutor方法来创建。这类线程池内部只有一个核心线程,它确保所有的任务都在同一个线程中按顺序执行。SingleThreadExecutor的意义在于统一所有的外界任务到一个线程中,这使得在这些任务之间不需要处理线程同步的问题。newSingleThreadExecutor方法的实现如下所示。

Java 复制代码
public static ExecutorService newSingleThreadExecutor() { 
    return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1,1, 
    0L,TimeUnit.MILLISECONDS, 
    new LinkedBlockingQueue<Runnable>())); 
}

AsyncTask

这部分内容在前一本书中处理过,这里总结一下他的过程

  1. 定义AsyncTask :首先需要继承AsyncTask类,并定义相关的泛型参数以及重写必要的方法,例如doInBackground
  2. 执行execute方法 :在UI线程启动AsyncTask时,通过执行execute(Params...)方法开始异步操作。该方法内部调用了executeOnExecutor方法,并传递sDefaultExecutor作为执行器,默认情况下,这是一个串行执行的线程池。
  3. 状态检查与预操作 :AsyncTask首次执行前,会检查其状态。如果任务尚未执行(PENDING状态),那么设置其状态为RUNNING,并调用onPreExecute()方法来进行初始化操作。
  4. 任务封装与线程池调用 :传递给execute方法的参数会被封装成FutureTask对象,提交给Executor(例如SerialExecutor或THREAD_POOL_EXECUTOR)来执行。
  5. 执行doInBackground方法 :在工作线程中,doInBackground(Params...)方法被执行,该方法负责处理后台计算,其返回值会作为结果传递给onPostExecute方法。
  6. 结果的消息传递:doInBackground方法的结果会被封装在Message中,并通过Handler发送到主线程处理。这个Handler在AsyncTask类加载时已初始化在主线程,以确保消息能够被主线程接收和处理。
  7. 结果处理 :在UI线程中,Handler会接收到包含结果的Message,并根据消息的类型调用onPostExecuteonProgressUpdate方法。
  8. 任务完成 :在onPostExecute方法中进行UI更新等操作。如果任务在执行中被取消,则onCancelled方法会被调用。
相关推荐
曲幽2 小时前
Termux里的二进制和脚本,到底怎么运行才不踩坑?Termux-service 保活妙招!
android·termux·nohup·services·wake-lock
plainGeekDev2 小时前
单例模式 → object 声明
android·java·kotlin
程序员陆业聪3 小时前
读者点单·03|Compose 与传统 View 混用的 12 个真实坑
android
程序员陆业聪3 小时前
读者点单·02|Android 启动优化实战:Trace 抓取→Application 编排→冷启动全流程拆解
android
Coffeeee3 小时前
帮你快速理解AI Agent之我想招个Android实习生
android·人工智能·agent
用户298698530143 小时前
Java 实现 Word 文档文本与图片提取的方法
java·后端
SimonKing4 小时前
铁子,IntelliJ IDEA 2026.1.3来了,升不升?
java·后端·程序员
恋猫de小郭4 小时前
苹果 AirPods 协议,Android 也可以使用完整版 AirPods 能力
android·前端·flutter
黄林晴5 小时前
告别无效重建:Gradle 9.6.0 解决 CI 构建缓存失效痛点告别无效重建:Gradle 9.6.0 解决 CI 建筑缓存失效痛点
android·gradle