【iOS】GCD

参考文章:GCD函数和队列原理探索

之前写项目的时候,进行耗时的网络请求使用GCD处理过异步请求,但对一些概念都很模糊,这次就来系统学习一下GCD相关


相关概念

什么是GCD?

Grand Center Dispatch简称GCD,是苹果公司开发的技术,以优化应用程序支持多核心处理器

GCD的优势:

  • GCD 是苹果公司为多核的并⾏运算提出的解决⽅案
  • GCD 会⾃动利⽤更多的CPU内核(⽐如双核、四核)
  • GCD 会⾃动管理线程的⽣命周期(创建线程、调度任务、销毁线程)
  • 程序员只需要告诉GCD想要执⾏什么任务,不需要编写任何线程管理代码

GCD要做的事情就是:GCD将任务添加到队列,并指定执行任务的函数

「任务」

任务: 执行操作,就是在线程中执行的那段代码。在GCD中是放在Block中的。执行任务有两种方式:

  • 同步执行(sync):
    • 必须等待当前语句执⾏完毕,才会执⾏下⼀条语句
    • 不会开启线程
    • 只能在当前线程中执行任务,不具备开启新线程的能力
  • 异步执行(async):
    • 异步添加任务到指定的队列中,它不会做任何等待,可以继续执行任务
    • 可以在新的线程中执行任务,具备开启新线程的能力
    • 异步是多线程的代名词

注意:异步执行(async) 虽然具有开启新线程的能力,但是并不一定开启新线程,这跟任务指定的队列类型有关

两者主要的区别是:是否等待队列的任务执行结束,以及是否具备开启新线程的能力

「队列」

队列(Dispatch Queue): 这里的队列指执行任务的等待队列,即用来存放任务的队列。每读取一个任务,则从队列中释放一个任务,参考下图:

队列分为两种:串行队列和并发队列。不同的队列中,任务排列的方式是不一样的,任务通过队列的调度,由线程池安排的线程来执行

两者都符合FIFO(先进先出)的原则:

  • 串行队列(Serial Dispatch Queue):每次只有一个任务被执行,让任务一个接着一个地执行。(只开启一个线程,一个任务执行完毕后,再执行下一个任务)
  • 并发队列(Concurrent Dispatch Queue):可以让多个任务并发(同时)执行。(可以开启多个线程,并且同时执行任务)

注意:并发队列的并发功能只有在异步(dispatch_async)方法下才有效

两者的主要区别是:执行顺序不同,以及开启线程数不同。参考下图:

创建队列:

objectivec 复制代码
// 串行队列
dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);

// 并行队列
dispatch_queue_t concurrentQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_CONCURRENT);

MRC下,生成的队列需由程序员负责持有或释放,通过以下两个函数的引用计数来管理内存:

objectivec 复制代码
dispatch_retain(serialQueue);
dispatch_release(serialQueue);

队列与函数组合

队列用来调用任务,函数用来执行任务,那么队列和函数不同的配合会有怎样的运行效果呢?

  • 同步函数串行队列
    1. 不会开启线程,在当前线程中执行任务
    2. 任务串行执行,任务一个接着一个执行
    3. 会产生阻塞
  • 同步函数并发队列
    1. 不会开启线程,在当前线程中执行任务
    2. 任务一个接着一个执行
  • 异步函数串行队列
    1. 会开启一个线程
    2. 任务一个接着一个执行
  • 异步函数并发队列
    1. 开启线程,在当前线程执行任务
    2. 任务异步执行,没有顺序,CPU调度有关

GCD中的队列

主队列

  1. The main queue:系统自带的一个队列,放到这个队列中的代码会被系统分配到主线程中执行。Main queue可以调用dispatch_get_main_queue()来获得。因为main queue是与主线程相关的,所以这是一个串行队列, 交至其中的任务顺序执行(一个任务执行完毕后,再执行下一个任务)

    objectivec 复制代码
    dispatch_queue_t mainQueue = dispatch_get_main_queue();

全局队列

  1. Global queues:整个应用程序存在4个全局队列(系统已经创建好,只需获得即可):高、中(默认)、后台三个优先级队列,可以调用dispatch_get_global_queue函数传入有下级来访问队列。全局队列是并行队列,可以让多个任务并发(同时)执行(自动开启多个线程同时执行任务),并发功能只有在异步(dispatch_async)函数下才有效

    objectivec 复制代码
    dispatch_queue_t globalQueueHigh = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
    dispatch_queue_t globalQueueDefault = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_queue_t globalQueueLow = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
    dispatch_queue_t globalQueueBackground = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);

自定义队列

  1. 用户自己创建队列:dispatch_queue_create创建的队列可以是串行的,也可以是并行的,因为系统已经给我们供了并行、串行队列,所以一般情况下我们不再需要在创建自己的队列。用户创建的队列可以有任意多个

注意: 分线程不能刷新UI,刷新UI只能在主线程。如果每个线程都刷新UI,将会很容易造成UI冲突,会出现不同步的情况,所以只有主线程中能刷新UI系统是为了降低编程的复杂程度,最大程度的避免冲突

相关案例分析

耗时性

任务是耗时的,不同函数只要执行任务,都会耗时

异步函数会开启线程,执行的耗时相对较少,在实际开发中,异步可以用来解决并发、多线程等问题

六种情况示例

主队列添加同步任务

在当前的main队列中添加一个任务,并同步执行该任务:

objectivec 复制代码
void mainSyncTest(void) {
    /*
     主队列同步
     不会开启线程
     会崩溃!
     */
    NSLog(@"start");
    
    // dispatch_queue_t concurrentQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_CONCURRENT);
    
    dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
    dispatch_sync(serialQueue, ^{
        NSLog(@"a");
    });
    
    NSLog(@"b");
}

在当前流程中,默认队列就是主队列,也是一个串行队列,任务执行顺序应为:

而到了第二步的块任务,会向当前的主队列中添加一个任务NSLog(@"a");,因为主队列是一个串行队列,现在要执行b,必须要等a(任务块)执行完成,而a又必须等b执行完成,产生了相互等待问题,造成了死锁!见下图:

运行结果就是崩溃:

解决办法就是将主队列改成自定义的串行队列或并发队列:

objectivec 复制代码
NSLog(@"start");

// dispatch_queue_t concurrentQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
dispatch_sync(serialQueue, ^{
    NSLog(@"a");
});

NSLog(@"b");

运行结果:

主队列添加异步任务
objectivec 复制代码
void mainAyncTest(void) {
    NSLog(@"start");
    
    dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
    dispatch_async(serialQueue, ^{
        NSLog(@"a");
    });
    
    NSLog(@"b");
}

主队列添加异步任务不会阻塞,不会崩溃

运行结果:

并发队列添加异步任务
objectivec 复制代码
void concurrentAsyncTest(void) {
    dispatch_queue_t concurrentQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_CONCURRENT);
    NSLog(@"1 --- %@", [NSThread currentThread]);
    
    dispatch_async(concurrentQueue, ^{
        NSLog(@"2 --- %@", [NSThread currentThread]);
        
        dispatch_async(concurrentQueue, ^{
            NSLog(@"3 --- %@", [NSThread currentThread]);
        });
        
        NSLog(@"4 --- %@", [NSThread currentThread]);
    });
    
    NSLog(@"5 --- %@", [NSThread currentThread]);
}

并发队列,通道比较宽,不会导致任务的阻塞

每个任务复杂度基本一致,异步不会堵塞主线程,dispatch_async会开启一个新的线程去执行其中的任务块

运行结果:

并发队列添加同步任务
objectivec 复制代码
void concurrentSyncTest(void) {
    dispatch_queue_t concurrentQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_CONCURRENT);
    NSLog(@"1 --- %@", [NSThread currentThread]);
    
    dispatch_sync(concurrentQueue, ^{
        NSLog(@"2 --- %@", [NSThread currentThread]);
        
        dispatch_sync(concurrentQueue, ^{
            NSLog(@"3 --- %@", [NSThread currentThread]);
        });
        
        NSLog(@"4 --- %@", [NSThread currentThread]);
    });
    
    NSLog(@"5 --- %@", [NSThread currentThread]);
}

因为并发队列,所以不会导致队列任务的阻塞,同时因为是同步执行,所以不会开启新的线程,按照顺序去执行流:

串行队列添加异步任务
objectivec 复制代码
void serialAsyncTest(void) {
    // 串行队列
    NSLog(@"start --- %@", [NSThread currentThread]);
    
    dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
    for (int i = 0; i < 5; ++i) {
        dispatch_async(serialQueue, ^{
            NSLog(@"%d --- %@", i, [NSThread currentThread]);
        });
    }
    
    NSLog(@"hello queue");
    NSLog(@"end --- %@", [NSThread currentThread]);
}

运行结果:

串行队列添加异步任务,开启了一条新线程,但是任务还是串行,所以任务是一个一个执行

另一方面可以看出,所有任务是在打印的start和end之后才开始执行的。说明任务不是马上执行,而是将所有任务添加到队列之后才开始同步执行

串行队列添加同步任务
objectivec 复制代码
void serialSyncTest(void) {
    NSLog(@"start --- %@", [NSThread currentThread]);
    
    dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
    for (int i = 0; i < 5; ++i) {
        dispatch_sync(serialQueue, ^{
            NSLog(@"%d --- %@", i, [NSThread currentThread]);
        });
    }
    
    NSLog(@"hello queue");
    NSLog(@"end --- %@", [NSThread currentThread]);
}

运行结果:

串行队列同步执行任务,所有任务都是在主线程中执行的,并没有开启新的线程。而且由于串行队列,所以按顺序一个一个执行

同时我们还可以看到,所有任务都在打印的start和end之间,这说明任务是添加到队列中马上执行的

串行队列添加同步和异步任务混合

objectivec 复制代码
void serialSyncAndAsyncTest(void) {
    NSLog(@"start --- %@", [NSThread currentThread]);
    
    dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
    dispatch_async(serialQueue, ^{
        NSLog(@"a --- %@", [NSThread currentThread]);
        dispatch_sync(serialQueue, ^{
            NSLog(@"b --- %@", [NSThread currentThread]);
        });
        NSLog(@"d --- %@", [NSThread currentThread]);
    });
    
    NSLog(@"hello queue");
    NSLog(@"end --- %@", [NSThread currentThread]);
}

在异步添加的线程中,情况类似主队列添加同步函数,b(任务块)和d相互等待了,导致死锁!

运行结果:

并发队列多任务

objectivec 复制代码
void concurrentSyncAndAsyncTest(void) {
    dispatch_queue_t concurrentQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_CONCURRENT);
    
    dispatch_async(concurrentQueue, ^{
        NSLog(@"1");
    });
    dispatch_async(concurrentQueue, ^{
        NSLog(@"2");
    });
    
    dispatch_sync(concurrentQueue, ^{
        NSLog(@"3");
    });
    
    NSLog(@"0");
    
    dispatch_async(concurrentQueue, ^{
        NSLog(@"7");
    });
    dispatch_async(concurrentQueue, ^{
        NSLog(@"8");
    });
    dispatch_async(concurrentQueue, ^{
        NSLog(@"9");
    });
}
// 3 0 1 2 7 9 8
// 3 0 1 7 9 8 2
// 3 0 1 2 7 8 9
// 3 1 0 2 7 8 9
// 3 2 1 0 7 8 9
// 3 1 2 0 7 9 8

结果分析:

  • 主队列共10个任务
  • 1、2、3的顺序不确定
  • 3、0是同步任务,所以3一定在0前面
  • 7、8、9一定在0后面

GCD线程间通信

在iOS开发过程中,我们一般在主线程里边进行UI刷新,例如:点击、滚动、拖拽等事件。我们通常把一些耗时的操作放在其他线程,比如说图片下载、文件上传等耗时操作。而当我们有时候在其他线程完成了耗时操作时,需要回到主线程,那么就用到了线程之间的通讯

objectivec 复制代码
void communicateAmongThread(void) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        for (int i = 0; i < 6; ++i) {
            NSLog(@"1 --- %@", [NSThread currentThread]);
        }
         
        // 回到主线程
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"2 --- %@", [NSThread currentThread]);
        });
    });
}

运行结果:

可以看到在其他线程中先执行操作,执行完了之后回到主线程执行主线程的相应操作

GCD的栅栏方法

我们有时需要异步执行两组操作,而且第一组操作执行完之后,才能开始执行第二组操作。这样我们就需要一个相当于栅栏一样的一个方法将两组异步执行的操作组给分割起来,当然这里的操作组里可以包含一个或多个任务。这就需要用到dispatch_barrier_async方法在两个操作组间形成栅栏

这是没添加栅栏函数之前的结果,顺序不确定:

objectivec 复制代码
void barrierFunc(void) {
    dispatch_queue_t queue = dispatch_queue_create("666", DISPATCH_QUEUE_CONCURRENT);
 
    dispatch_async(queue, ^{
        NSLog(@"1 --- %@", [NSThread currentThread]);
    });
    dispatch_async(queue, ^{
        NSLog(@"2 --- %@", [NSThread currentThread]);
    });
 
    dispatch_barrier_async(queue, ^{
        NSLog(@"----barrier-----%@", [NSThread currentThread]);
    });
 
    dispatch_async(queue, ^{
        NSLog(@"3 --- %@", [NSThread currentThread]);
    });
    dispatch_async(queue, ^{
        NSLog(@"4 --- %@", [NSThread currentThread]);
    });
}

栅栏函数可保证异步操作的执行顺序,这里保证了1或2先执行,3或4后执行:

GCD的延时方法

当我们需要延迟执行一段代码时,就需要用到GCD的dispatch_after方法

objectivec 复制代码
void delayExec(void) {
    NSLog(@"run -- 0");
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        // 3秒后异步执行这里的代码...
       NSLog(@"run -- 2");
    });
}

运行结果:

GCD的一次性代码(只执行一次)

我们在创建单例、或者有整个程序运行过程中只执行一次的代码时,我们就用到了GCD的dispatch_once方法。使用dispatch_once函数能保证某段代码在程序运行过程中只被执行1次

objectivec 复制代码
void onceExec(void) {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 只执行一次的代码(这里面是默认线程安全的)
        NSLog(@"Hhhhhhhh");
    });
}

onceExec();
onceExec();

调用两次只会执行一次:

GCD的队列组

有时候我们会有这样的需求:分别异步执行2个耗时操作,然后当2个耗时操作都执行完毕后再回到主线程执行操作。这时候我们可以用到GCD的队列组。

  • 我们可以先把任务放到队列中,然后将队列放入队列组中
  • 调用队列组的dispatch_group_notify回到主线程执行操作
objectivec 复制代码
void queueGroup(void) {
    // GCD的队列组
    dispatch_group_t group = dispatch_group_create();
    
    dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // 执行1个耗时的异步操作
        int i = 0;
        while (i < 100) {
            NSLog(@"1");
            i++;
        }
    });
    
    dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // 执行1个耗时的异步操作
        int i = 0;
        while (i < 100) {
            NSLog(@"2");
            i++;
        }
    });
    
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        // 等前面的异步操作都执行完毕后,回到主线程...
        NSLog(@"3");
    });
}

运行结果:

dispatch_set_target_queue

这个函数有两个作用:

  1. 改变队列的优先级
  2. 防止多个串行队列的并发执行

改变队列的优先级

dispatch_queue_create函数生成的串行队列和并发队列,都使用 与默认优先级的Global Dispatch Queue 相同执行优先级 的线程

objectivec 复制代码
void serialBackgroundQueue(void) {
    // 需求:生成一个后台的串行队列
    dispatch_queue_t serialQueue = dispatch_queue_create("bySelf", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t globalQueueBackground = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
    
    // 第1个参数:需要改变优先级的队列
    // 第2个参数:目标队列
    dispatch_set_target_queue(serialQueue, globalQueueBackground);
}

防止多个串行队列的并发执行

如果是将任务追加到3个串行队列中,那么这些任务就会并发执行。因为每个串行队列都会创建一个线程,这些线程会并发执行

如果将多个串行的queue使用dispatch_set_target_queue指定到了同一目标,那么这多个串行queue在目标queue上就是同步执行的,不再是并行执行

将串行队列加入指定优先级队列,会按照加入优先级队列的顺序依次执行串行队列

未加入优先级队列:

objectivec 复制代码
void abandonSerialQueuesToConcurrent(void) {
    dispatch_queue_t targetQueue = dispatch_queue_create("test.target.queue", DISPATCH_QUEUE_SERIAL);
 
    dispatch_queue_t queue1 = dispatch_queue_create("test.1", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t queue2 = dispatch_queue_create("test.2", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t queue3 = dispatch_queue_create("test.3", DISPATCH_QUEUE_SERIAL);
    
//    dispatch_set_target_queue(queue1, targetQueue);
//    dispatch_set_target_queue(queue2, targetQueue);
//    dispatch_set_target_queue(queue3, targetQueue);
        
    dispatch_async(queue1, ^{
        NSLog(@"1 in");
        [NSThread sleepForTimeInterval:3.f];
        NSLog(@"1 out");
    });
 
    dispatch_async(queue2, ^{
        NSLog(@"2 in");
        [NSThread sleepForTimeInterval:2.f];
        NSLog(@"2 out");
    });
    dispatch_async(queue3, ^{
        NSLog(@"3 in");
        [NSThread sleepForTimeInterval:1.f];
        NSLog(@"3 out");
    });
}

加入后:

dispatch_suspend/dispatch_resume

dispatch_suspend并不会立即暂停正在运行的block,而是在当前block执行完成后,暂停后续的block执行

挂起指定队列:dispatch_suspend(serialQueue);

恢复指定队列:dispatchp_resume(serialQueue);

objectivec 复制代码
void suspendOrResumeQueue(void) {
    dispatch_queue_t queue = dispatch_queue_create("com.test.gcd", DISPATCH_QUEUE_SERIAL);
    //提交第一个block,延时5秒打印。
    dispatch_async(queue, ^{
        sleep(5);
        NSLog(@"After 5 seconds...");
    });
    //提交第二个block,也是延时5秒打印
    dispatch_async(queue, ^{
        sleep(5);
        NSLog(@"After 5 seconds again...");
    });
    //延时一秒
    NSLog(@"sleep 1 second...");
    sleep(1);
    //挂起队列
    NSLog(@"suspend...");
    dispatch_suspend(queue);
    //延时10秒
    NSLog(@"sleep 17 second...");
    sleep(17);
    //恢复队列
    NSLog(@"resume...");
    dispatch_resume(queue);
}

线程安全

为了保证线程安全,我们之前了解过互斥锁,在书上给了我们dispatch_semaphore方法,我们总结为三点:

  1. synchronized加锁,属于互斥锁,当有线程执行锁住的代码的时候,其他线程会进入休眠,需要唤醒后才能继续执行,性能较低

    objectivec 复制代码
    - (void)synchronizedSecurity {
     dispatch_async(dispatch_get_global_queue(0, 0), ^{
         // 加锁保证block中执行完成才会执行其他的
             @synchronized (self) {
                 NSLog(@"1开始");
                 sleep(2);
                 NSLog(@"1结束");
             }
         });
         dispatch_async(dispatch_get_global_queue(0, 0), ^{
             @synchronized (self) {
                 NSLog(@"2开始");
                 sleep(2);
                 NSLog(@"2结束");
             }
         });
    }
  2. 信号量semaphore加锁,属于自旋锁,当有线程执行锁住的代码的时候,其他线程会进入死循环的等待,当解锁后会立即执行,性能较高

    提供了三种函数:

    • dispatch_semaphore_create:创建一个Semaphore并初始化信号的总量(同时有几个线程可以执行,一般是1)

    • dispatch_semaphore_wait:可以使总信号量减1,当信号总量小于0时就会一直等待(阻塞所在线程),否则就可以正常执行,这个放在要执行的代码的前面。

    • dispatch_semaphore_signal:发送一个信号,让信号总量加1,代码执行完成之后使用,使其他线程可以继续执行

      objectivec 复制代码
      void semaphoreSecurity(void) {
        dispatch_semaphore_t semalook = dispatch_semaphore_create(1);
      
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            dispatch_semaphore_wait(semalook, DISPATCH_TIME_FOREVER);
            NSLog(@"1开始");
            sleep(2);
            NSLog(@"1结束");
            dispatch_semaphore_signal(semalook);
        });
      
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            dispatch_semaphore_wait(semalook, DISPATCH_TIME_FOREVER);
            NSLog(@"2开始");
            sleep(2);
            NSLog(@"2结束");
            dispatch_semaphore_signal(semalook);
        });
      }
  3. NSLock

    objectivec 复制代码
    void lockSecurity(void) {
     NSLock *lock = [[NSLock alloc]init];
     dispatch_async(dispatch_get_global_queue(0, 0), ^{
         [lock lock];
         NSLog(@"1开始");
         sleep(2);
         NSLog(@"1结束");
         [lock unlock];
     });
      
     dispatch_async(dispatch_get_global_queue(0, 0), ^{
         [lock lock];
         NSLog(@"2开始");
         sleep(2);
         NSLog(@"2结束");
         [lock unlock];
     });
    }

    运行结果:

相关推荐
PC端游爱好者3 小时前
研究生如何远控实验室电脑?远程办公功能使用教程
macos·智能手机·电脑
B.-4 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
SoraLuna5 小时前
「Mac玩转仓颉内测版12」PTA刷题篇3 - L1-003 个位数统计
算法·macos·cangjie
SoraLuna13 小时前
「Mac玩转仓颉内测版10」PTA刷题篇1 - L1-001 Hello World
算法·macos·cangjie
iFlyCai13 小时前
Xcode 16 pod init失败的解决方案
ios·xcode·swift
_可乐无糖19 小时前
mac终端使用pytest执行iOS UI自动化测试方法
macos·pytest
后端常规开发人员21 小时前
在 Mac 上使用 Docker 安装宝塔并部署 LNMP 环境
macos·docker·容器·宝塔
郝晨妤1 天前
HarmonyOS和OpenHarmony区别是什么?鸿蒙和安卓IOS的区别是什么?
android·ios·harmonyos·鸿蒙
Hgc558886661 天前
iOS 18.1,未公开的新功能
ios
nukix1 天前
Mac Java 使用 tesseract 进行 ORC 识别
java·开发语言·macos·orc