iOS GCD

GCD

任务+队列

主队列: 任务在主线程执行,主队列是一个串行队列,它主要处理 UI 相关任务,也可以处理其他类型任务,但为了性能考虑,尽量让主队列执行 UI 相关或少量不耗时间和资源的操作。

系统全局并发队列:全局并发队列,存在 5 个不同的 QoS 级别,可以使用默认优先级,也可以单独指定

并行队列: 先进先出,多个任务可以并行执行

串行队列 : 先进先出,同一时间只能执行一个任务

基本操作:

sync 同步

任务一经提交就会阻塞当前线程(当前线程可以理解为下方代码示例中执行 sync 方法所在的线程 thread0),并请求队列立即安排其执行,执行任务的线程 thread1 默认等于 thread0,即同步任务直接在当前线程运行,任务完成后恢复线程原任务。

async 异步

任务提交后不会阻塞当前线程,会由队列安排另一个线程执行。

1. 同步(Synchronous)

​ • 定义:同步意味着任务是按顺序执行的,一个任务完成后,才会开始下一个任务。在执行过程中,程序会阻塞,等待当前任务完成之后才能继续执行下一步。

​ • 特征

​ • 阻塞:程序在执行某个任务时,会等待该任务完成,然后才会继续执行其他任务。

​ • 执行顺序:任务按照书写的顺序依次执行。

​ • 简单直观:容易理解,执行顺序清晰,但当任务较慢时会浪费时间,因为程序需要等待任务完成。

​ • 示例

假设你需要从数据库中获取数据,读取数据的过程是同步的:

复制代码
func fetchData() {
    let data = loadDataFromDatabase()  // 阻塞直到数据加载完成
    processData(data)  // 数据加载完成后才会执行
}

这意味着,如果 loadDataFromDatabase() 操作需要很长时间(比如网络请求、文件读取等),那么程序在此期间会被阻塞,不能执行其他任务。

2. 异步(Asynchronous)

​ • 定义:异步意味着任务的执行不阻塞主线程。任务在后台执行时,程序可以继续执行其他操作,直到任务完成时,通知程序来处理结果。

​ • 特征

​ • 非阻塞:程序不会等待当前任务完成,允许其他操作继续执行。

​ • 回调:当异步任务完成时,通常会通过回调函数(比如闭包、代理等)来通知主程序,处理结果。

​ • 并发:多个任务可以并行执行,从而提高效率,避免长时间的阻塞。

​ • 示例

假设你需要从数据库中获取数据,但希望不阻塞主线程,使用异步方法来加载数据:

复制代码
func fetchData() {
    loadDataFromDatabaseAsync { data in  // 异步加载数据,回调处理
        processData(data)  // 数据加载完成后执行回调
    }
}

在这个例子中,loadDataFromDatabaseAsync 会在后台加载数据,主线程不受阻塞,其他操作可以继续进行。当数据加载完成时,回调函数会被调用,随后执行 processData(data)。

同步和异步的区别总结

特性 同步 (Synchronous) 异步 (Asynchronous)
执行方式 按顺序执行,任务完成后再执行下一个 任务在后台执行,程序可以继续执行其他操作
程序等待 程序会等待任务完成,任务执行时会阻塞 程序不等待任务完成,可以继续执行其他任务
阻塞与非阻塞 阻塞,当前任务未完成无法继续执行其他任务 非阻塞,任务在后台执行,主线程不受阻塞
适用场景 适合任务简单、快速,或必须按顺序执行的场景 适合任务需要较长时间、可以并发处理的场景

举个例子:下载文件

假设你需要从互联网上下载一个文件:

​ 1. 同步下载:如果你使用同步方式下载文件,程序会等待文件下载完成后,才会继续执行后续的操作。在文件下载的过程中,程序会被"卡住",无法做其他事情,直到下载完成。

​ 2. 异步下载:如果你使用异步方式下载文件,程序会立即开始下载并跳转到下一步操作,文件下载则在后台进行。下载完成后,程序会通过回调或通知来处理下载结果,这样你可以在下载文件的同时执行其他任务。

优缺点对比

​ • 同步

​ • 优点:简单、直观,适合任务简单且必须顺序执行的场景。

​ • 缺点:如果一个任务耗时较长,会阻塞整个程序,导致性能瓶颈。

​ • 异步

​ • 优点:不会阻塞主线程,适合长时间运行的任务,可以提高程序效率和响应性。

​ • 缺点:实现较为复杂,通常需要回调、闭包、Promise、异步/并发框架等机制来处理。

实际应用

​ • 同步:适合计算密集型操作或那些不需要等待的任务(如简单的计算)。

​ • 异步:适合网络请求、文件 I/O、数据库查询等耗时操作,这样可以避免程序界面卡顿或响应迟缓。

理解同步与异步的差异有助于提高程序的效率,尤其是在面对网络请求或处理大规模数据时。

创建队列:

提供了两种队列,系统队列(全局并发队列),自定义队列

对于自定义队列,默认是串行的

  • 串行队列同一时间只会使用同一线程、运行同一任务,并严格按照任务顺序执行。

  • 并行队列同一时间可以使用多个线程、运行多个任务,执行顺序不分先后。

  • 同步任务会阻塞当前线程,并在当前线程执行。

  • 异步任务不会阻塞当前线程,并在与当前线程不同的线程执行。

  • 如何避免死锁:不要在串行或主队列中嵌套执行同步任务。

栏栅任务

栅栏任务的主要特性是可以对队列中的任务进行阻隔,执行栅栏任务时,它会先等待队列中已有的任务全部执行完成,然后它再执行,在它之后加入的任务也必须等栅栏任务执行完后才能执行。

这个特性更适合并行队列,而且对栅栏任务使用同步或异步方法效果都相同。

  • 创建方式,先创建 WorkItem,标记为:barrier,再添加至队列中:
swift 复制代码
let queue = DispatchQueue(label: "queueName", attributes: .concurrent)
let task = DispatchWorkItem(flags: .barrier) {
    // do something
}
queue.async(execute: task)
queue.sync(execute: task) // 与 async 效果一样

迭代任务

并行队列利用多个线程执行任务,可以提高程序执行的效率。而迭代任务可以更高效地利用多核性能,它可以利用 CPU 当前所有可用线程进行计算(任务小也可能只用一个线程)。如果一个任务可以分解为多个相似但独立的子任务,那么迭代任务是提高性能最适合的选择。

使用 concurrentPerform 方法执行迭代任务,迭代任务的后续任务需要等待它执行完成才会继续。本方法类似于 Objc 中的 dispatch_apply 方法,创建方式如下:

复制代码
DispatchQueue.concurrentPerform(iterations: 10) {(index) -> Void in // 10 为迭代次数,可修改。
    // do something
}

迭代任务可以单独执行,也可以放在指定的队列中:

swift 复制代码
let queue = DispatchQueue.global() // 全局并发队列
queue.async {
    DispatchQueue.concurrentPerform(iterations: 100) {(index) -> Void in
        // do something
    }
    //可以转至主线程执行其他任务
    DispatchQueue.main.async {
        // do something
    }
}

延迟加入队列

swift 复制代码
class AsyncAfter {

    typealias ExchangableTask = (_ newDelayTime: TimeInterval?,
        _ anotherTask:@escaping (() -> ())
        ) -> Void

    /// 延迟执行一个任务,并支持在实际执行前替换为新的任务,并设定新的延迟时间。
    ///
    /// - Parameters:
    ///   - time: 延迟时间
    ///   - yourTask: 要执行的任务
    /// - Returns: 可替换原任务的闭包
    static func delay(_ time: TimeInterval, yourTask: @escaping ()->()) -> ExchangableTask {
        var exchangingTask: (() -> ())? // 备用替代任务
        var newDelayTime: TimeInterval? // 新的延迟时间

        let finalClosure = { () -> Void in
            if exchangingTask == nil {
                DispatchQueue.main.async(execute: yourTask)
            } else {
                if newDelayTime == nil {
                    DispatchQueue.main.async {
                        print("任务已更改,现在是:\(Date())")
                        exchangingTask!()
                    }
                }
                print("原任务取消了,现在是:\(Date())")
            }
        }

        dispatch_later(time) { finalClosure() }

        let exchangableTask: ExchangableTask =
        { delayTime, anotherTask in
            exchangingTask = anotherTask
            newDelayTime = delayTime

            if delayTime != nil {
                self.dispatch_later(delayTime!) {
                    anotherTask()
                    print("任务已更改,现在是:\(Date())")
                }
            }
        }

        return exchangableTask
    }
}

delay 方法接收两个参数,并返回一个闭包:

  • TimeInterval:延迟时间
  • @escaping () -> (): 要延迟执行的任务
  • 返回:可替换原任务的闭包,我们去了一个别名:ExchangableTask

ExchangableTask 类型定义的闭包,接收一个新的延迟时间,和一个新的任务。

如果不执行返回的闭包,则在delay 方法内部,通过 dispatch_later 方法会继续执行原任务。

如果执行了返回的 ExchangableTask 闭包,则会选择执行新的任务。

线程安全和线程同步

读写操作

swift 复制代码
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)
var array = [Int]()

// 写操作
queue.async(flags: .barrier) {
    array.append(1)
    array.append(2)
}

// 读操作
queue.async {
    print(array)
}

什么时候用串行队列?什么时候用并发队列 + 栅栏?

​ • 用串行队列:

​ • 当你的任务量不大,或对性能要求不高时。

​ • 任务的顺序性更重要,所有操作按严格顺序执行。

​ • 用并发队列 + 栅栏:

​ • 当你的任务中有大量的读操作,并且需要同时保证写操作的安全性时。

​ • 适用于性能要求较高的场景,例如数据库、多线程缓存读写操作。

所以,串行队列的实现虽然简单,但并发队列 + 栅栏在性能和灵活性上更有优势,特别是在读多写少的场景下。

多线程典型场景

  • 一个页面有三个网络请求,需要在三个网络请求都返回的时候刷新页面

    使用dispatch group

  • 实现一个线程安全的Array的读和写

    swift 复制代码
    import Foundation
    
    class ThreadSafeArray<T> {
        private var array: [T] = []
        private let queue = DispatchQueue(label: "com.example.ThreadSafeArray", attributes: .concurrent)
        
        // 写操作:追加元素
        func append(_ item: T) {
            queue.async(flags: .barrier) {
                self.array.append(item)
            }
        }
        
        // 写操作:移除元素
        func remove(at index: Int) {
            queue.async(flags: .barrier) {
                guard index < self.array.count else { return }
                self.array.remove(at: index)
            }
        }
        
        // 读操作:通过索引访问
        func get(at index: Int) -> T? {
            var result: T?
            queue.sync {
                guard index < self.array.count else { return }
                result = self.array[index]
            }
            return result
        }
        
        // 读操作:获取数组的所有内容
        func getAll() -> [T] {
            var result: [T] = []
            queue.sync {
                result = self.array
            }
            return result
        }
        
        // 读操作:获取数组的大小
        var count: Int {
            var result = 0
            queue.sync {
                result = self.array.count
            }
            return result
        }
    }

    代码的线程安全性

    ​ • 写操作

    ​ • 使用 async(flags: .barrier) 保证写操作互斥。

    ​ • 当有一个写操作在执行时,队列会阻止其他读和写操作,确保数据一致性。

    ​ • 读操作

    ​ • 通过 queue.sync 保证读操作线程安全。

    ​ • 即使多个读操作同时进行,也不会干扰,因为并发队列允许多个 sync 操作并行。

    代码执行逻辑示例

    1. 写操作的独占性

    假设同时有以下两个任务:

    ​ • array.append(1)

    ​ • array.remove(at: 0)

    因为写操作使用了 .barrier,它们不会同时执行,队列会保证第一个写操作完成后再执行下一个写操作。

    2. 读写互斥

    假设同时有以下任务:

    ​ • 读取元素 array.getAll()

    ​ • 添加新元素 array.append(1)

    在这种情况下:

    ​ 1. 读操作通过 sync 提交到队列中,可以立即并发执行。

    ​ 2. 写操作通过 barrier 提交时,会等待所有正在进行的读操作完成后再执行。

    3. 并发读

    假设同时有以下任务:

    ​ • array.getAll()

    ​ • array.get(at: 0)

    因为读操作是通过 sync 提交到并发队列中,它们可以并行执行,不会互相阻塞。

同步(Synchronous)和异步(Asynchronous)是编程中两种不同的执行方式,主要区别在于任务的执行顺序和程序的等待行为。

信号量

DispatchSemaphore,通常称作信号量,顾名思义,它可以通过计数来标识一个信号,这个信号怎么用呢,取决于任务的性质。通常用于对同一个资源访问的任务数进行限制。

例如,控制同一时间写文件的任务数量、控制端口访问数量、控制下载任务数量等。

信号量的使用非常的简单:

  • 首先创建一个初始数量的信号对象
  • 使用 wait 方法让信号量减 1,再安排任务。如果此时信号量仍大于或等于 0,则任务可执行,如果信号量小于 0,则任务需要等待其他地方释放信号。
  • 任务完成后,使用 signal 方法增加一个信号量。
  • 等待信号有两种方式:永久等待、可超时的等待。

基础源码解析

队列和线程之间的关系

一个重要概念是overcommit,overcommit的队列在队列创建时会新建一个线程,非overcommit队列创建队列则未必创建线程。另外width=1意味着是串行队列,只有一个线程可用,width=0xffe则意味着并行队列,线程则是从线程池获取,可用线程数是64个。

可以看到全局队列是非overcommit的(flat保留字只能传0,如果默认优先级则是com.apple.root.default-qos,但是width=0xffe是并行队列);主队列是overcommit的com.apple.root.default-qos.overcommit,不过它是串行队列,width=1,并且运行的这个线程只能是主线程;自定义串行队列是overcommit的,默认优先级则是 com.apple.root.default-qos.overcommit,并行队列则是非overcommit的。

串行并行的判断

首先通过width判定是串行队列还是并发队列,如果是并发队列则调用_dispatch_sync_invoke_and_complete,串行队列则调用_dispatch_barrier_sync_f

死锁的判断
复制代码
_dispatch_lock_is_locked_by(dispatch_lock lock_value, dispatch_tid tid)
{
	// equivalent to _dispatch_lock_owner(lock_value) == tid
	return ((lock_value ^ tid) & DLOCK_OWNER_MASK) == 0;
}

通过判断队列的状态和线程的状态,把这两个进行异或操作,如果状态相同的话,就返回yes,产生死锁。

如何执行调度
复制代码
DISPATCH_NOINLINE
static void
_dispatch_sync_invoke_and_complete_recurse(dispatch_queue_class_t dq,
		void *ctxt, dispatch_function_t func, uintptr_t dc_flags
		DISPATCH_TRACE_ARG(void *dc))
{
	_dispatch_sync_function_invoke_inline(dq, ctxt, func);
	_dispatch_trace_item_complete(dc);
	_dispatch_sync_complete_recurse(dq._dq, NULL, dc_flags);
}

// 看一下 _dispatch_sync_function_invoke_inline

DISPATCH_ALWAYS_INLINE
static inline void
_dispatch_sync_function_invoke_inline(dispatch_queue_class_t dq, void *ctxt,
		dispatch_function_t func)
{
	dispatch_thread_frame_s dtf;
	_dispatch_thread_frame_push(&dtf, dq);
	_dispatch_client_callout(ctxt, func);
	_dispatch_perfmon_workitem_inc();
	_dispatch_thread_frame_pop(&dtf);
}

// 看一下 _dispatch_client_callout

void
_dispatch_client_callout(void *ctxt, dispatch_function_t f)
{
	@try {
		return f(ctxt);
	}
	@catch (...) {
		objc_terminate();
	}
}
典型流程示例

假设通过 dispatch_sync(queue, ^{ ... }) 提交任务:

  1. 任务入队
    • 如果队列空闲,直接调用 _dispatch_sync_invoke_and_complete_recurse
  2. 执行任务
    • _dispatch_sync_function_invoke_inline 调用用户代码。
    • 线程帧压栈,记录当前队列。
  3. 异常处理
    • 用户代码若抛出异常,进程终止。
  4. 清理资源
    • 线程帧弹栈,递归更新队列状态,唤醒其他任务。
异步操作:
复制代码
static void
_dispatch_root_queue_poke_slow(dispatch_queue_global_t dq, int n, int floor)
{
    int remaining = n;
    int r = ENOSYS;
    
    _dispatch_root_queues_init();
    _dispatch_debug_root_queue(dq, __func__);
    _dispatch_trace_runtime_event(worker_request, dq, (uint64_t)n);
    #if !DISPATCH_USE_INTERNAL_WORKQUEUE
    #if DISPATCH_USE_PTHREAD_ROOT_QUEUES
    if (dx_type(dq) == DISPATCH_QUEUE_GLOBAL_ROOT_TYPE)
    #endif
    {
    	_dispatch_root_queue_debug("requesting new worker thread for global "
    			"queue: %p", dq);
    	r = _pthread_workqueue_addthreads(remaining,
    			_dispatch_priority_to_pp_prefer_fallback(dq->dq_priority));
    	(void)dispatch_assume_zero(r);
    	return;
    }
    #endif // !DISPATCH_USE_INTERNAL_WORKQUEUE
    #if DISPATCH_USE_PTHREAD_POOL
    dispatch_pthread_root_queue_context_t pqc = dq->do_ctxt;
    if (likely(pqc->dpq_thread_mediator.do_vtable)) {
    	while (dispatch_semaphore_signal(&pqc->dpq_thread_mediator)) {
    		_dispatch_root_queue_debug("signaled sleeping worker for "
    				"global queue: %p", dq);
    		if (!--remaining) {
    			return;
    		}
    	}
    }
    
    bool overcommit = dq->dq_priority & DISPATCH_PRIORITY_FLAG_OVERCOMMIT;
    if (overcommit) {
        // 串行队列
    	os_atomic_add2o(dq, dgq_pending, remaining, relaxed);
    } else {
    	if (!os_atomic_cmpxchg2o(dq, dgq_pending, 0, remaining, relaxed)) {
    		_dispatch_root_queue_debug("worker thread request still pending for "
    				"global queue: %p", dq);
    		return;
    	}
    }
    
    // floor 为 0,remaining 是根据队列任务的情况处理的
    int can_request, t_count;
    // 获取线程池的大小
    t_count = os_atomic_load2o(dq, dgq_thread_pool_size, ordered);
    do {
        // 计算可以请求的数量
        can_request = t_count < floor ? 0 : t_count - floor;
        if (remaining > can_request) {
    	    _dispatch_root_queue_debug("pthread pool reducing request from %d to %d",
    	    		remaining, can_request);
    	    os_atomic_sub2o(dq, dgq_pending, remaining - can_request, relaxed);
    	    remaining = can_request;
        }
        if (remaining == 0) {
            // 线程池满了,就会报出异常的情况
            _dispatch_root_queue_debug("pthread pool is full for root queue: "
    				"%p", dq);
                return;
    	}
    } while (!os_atomic_cmpxchgvw2o(dq, dgq_thread_pool_size, t_count,
    		t_count - remaining, &t_count, acquire));
    
    pthread_attr_t *attr = &pqc->dpq_thread_attr;
    pthread_t tid, *pthr = &tid;
    #if DISPATCH_USE_MGR_THREAD && DISPATCH_USE_PTHREAD_ROOT_QUEUES
    if (unlikely(dq == &_dispatch_mgr_root_queue)) {
    	pthr = _dispatch_mgr_root_queue_init();
    }
    #endif
    do {
    	_dispatch_retain(dq); 
    	// 开辟线程
    	while ((r = pthread_create(pthr, attr, _dispatch_worker_thread, dq))) {
    		if (r != EAGAIN) {
    			(void)dispatch_assume_zero(r);
    		}
    		_dispatch_temporary_resource_shortage();
    	}
    } while (--remaining);
    #else
    (void)floor;
    #endif // DISPATCH_USE_PTHREAD_POOL
}

这段代码是 GCD(Grand Central Dispatch)中用于管理全局队列(Global Queue)线程池 的核心函数 _dispatch_root_queue_poke_slow,其主要职责是根据任务需求动态创建或唤醒线程。以下是对代码的分层解析:


一、函数背景与核心目标
  • 调用场景 :当全局队列(dispatch_queue_global_t)中有新任务提交,但当前线程池中没有足够空闲线程处理任务时,触发此函数。
  • 核心目标:通过创建新线程或唤醒休眠线程,确保任务能够被及时执行。
  • 关键参数
    • dq:目标全局队列(可能是并发队列或特定优先级的队列)。
    • n:需要唤醒或创建的线程数量。
    • floor:线程池的最小保留线程数(避免过度销毁)。

二、代码逻辑分层解析
1. 初始化与调试(Initialization & Debugging)
c 复制代码
_dispatch_root_queues_init();       // 确保全局根队列初始化
_dispatch_debug_root_queue(dq, __func__);  // 调试日志
_dispatch_trace_runtime_event(worker_request, dq, (uint64_t)n); // 性能追踪
  • 作用:确保根队列已初始化,记录调试信息和性能事件。

2. 内核级 Workqueue 处理(XNU Kernel Workqueue)
c 复制代码
#if DISPATCH_USE_PTHREAD_ROOT_QUEUES
r = _pthread_workqueue_addthreads(remaining, _dispatch_priority_to_pp_prefer_fallback(dq->dq_priority));
  • 条件 :当使用 pthread 工作队列(如 macOS 或 iOS 的默认配置)。
  • 行为 :直接通过 _pthread_workqueue_addthreads 向 XNU 内核请求线程。
  • 机制
    • 内核根据优先级(dq_priority 转换为 pthread_priority_t)动态调度线程。
    • 内核管理的线程生命周期更高效,避免用户态频繁创建/销毁。

3. 用户态线程池管理(User-Level Thread Pool)

当不使用内核 Workqueue 时(如某些定制化配置),进入用户态线程池管理逻辑:

3.1 唤醒休眠线程
c 复制代码
while (dispatch_semaphore_signal(&pqc->dpq_thread_mediator)) {
    if (!--remaining) return;
}
  • 目的 :尝试通过信号量唤醒线程池中休眠的线程
  • 信号量机制 :休眠线程通过 dispatch_semaphore_wait 挂起,信号量 signal 会唤醒一个线程。
  • 优化:避免创建新线程的开销,优先复用休眠线程。
3.2 处理 Overcommit(超发线程)
c 复制代码
if (overcommit) {
    os_atomic_add2o(dq, dgq_pending, remaining, relaxed);
} else {
    os_atomic_cmpxchg2o(dq, dgq_pending, 0, remaining, relaxed);
}
  • Overcommit 标志:表示队列允许超出发起线程数限制(如串行队列的特殊处理)。
  • 原子操作
    • os_atomic_add2o:直接增加挂起的线程请求数。
    • os_atomic_cmpxchg2o:通过 CAS(Compare-and-Swap)确保线程请求数正确。
3.3 动态调整线程请求数
c 复制代码
do {
    can_request = t_count < floor ? 0 : t_count - floor;
    if (remaining > can_request) {
        remaining = can_request;
    }
} while (!os_atomic_cmpxchgvw2o(dq, dgq_thread_pool_size, t_count, t_count - remaining, &t_count, acquire));
  • 目标 :根据当前线程池大小(dgq_thread_pool_size)和 floor 值,计算实际可创建的线程数。
  • 策略
    • 若当前线程数 t_count 小于 floor,不允许创建新线程。
    • 否则,可创建 t_count - floor 个线程。
  • 原子操作 :通过 os_atomic_cmpxchgvw2o 更新线程池大小,避免竞态条件。
3.4 创建新线程
c 复制代码
while ((r = pthread_create(pthr, attr, _dispatch_worker_thread, dq))) {
    if (r != EAGAIN) (void)dispatch_assume_zero(r);
    _dispatch_temporary_resource_shortage();
}
  • 入口函数_dispatch_worker_thread 是线程的入口,负责从队列中获取并执行任务。
  • 错误处理
    • pthread_create 返回 EAGAIN(资源不足),调用 _dispatch_temporary_resource_shortage 等待后重试。
    • 其他错误直接触发断言(dispatch_assume_zero)。

到了这里可以清楚的看到对于全局队列使用_pthread_workqueue_addthreads开辟线程,对于其他队列使用pthread_create开辟新的线程。


三、代码流程图
plaintext 复制代码
开始
│
├─ 初始化根队列 & 记录调试信息
│
├─ 如果使用内核 Workqueue:
│   ├─ 调用 _pthread_workqueue_addthreads 请求内核创建线程
│   └─ 返回
│
├─ 否则(用户态线程池):
│   ├─ 尝试通过信号量唤醒休眠线程
│   ├─ 处理 Overcommit 标志
│   ├─ 动态调整线程请求数(基于线程池大小和 floor)
│   ├─ 循环创建新线程(处理 EAGAIN 错误)
│   └─ 更新线程池状态
│
└─ 结束
相关推荐
Digitally1 小时前
如何通过 5 种方式将照片从 iPad 传输到电脑
ios·电脑·ipad
Sugobet4 小时前
【安卓][Mac/Windows】永久理论免费 无限ip代理池 - 适合临时快速作战
android·tcp/ip·macos·网络安全·渗透测试·ip代理池·接入点
光头才能变强5 小时前
mac安装pycharm
ide·macos·pycharm
归辞...13 小时前
「iOS」————SideTable
macos·ios·cocoa
你好龙卷风!!!14 小时前
mac 安装pytho3 和pipx
macos
最幸伏的人15 小时前
Mac电脑安装HomeBrew
macos·homebrew
林大鹏天地18 小时前
iOS 父对象dealloc时触发子对象懒加载导致出现崩溃原因探究和解决
ios
光头才能变强20 小时前
Mac安装WebStorm 2025版本
macos
无知的前端20 小时前
一文精通-Combine 框架详解及使用示例
ios·swift
阳光明媚sunny20 小时前
Mac电脑基本功能快捷键
macos·电脑