【iOS】Runloop

文章目录


前言

先前学习了Runtime的内容,现在来学习一下Runloop

一、Runloop的概念

一般来讲线程一次只能执行一个任务,执行完后就会退出,我们现在想实现一个功能:线程一直在处理事件并且不会退出,这就是我们Runloop的作用,通常的代码逻辑我们用伪代码来表示一下:

bash 复制代码
int main(void) {
    初始化();
    while (message != 退出) {
        处理事件(message);
        message = 获取下一个事件();
    }
    return 0;
}

这种模型也常常被称作Event Loop,这种机制在很多地方都用到了,例如windows的事件循环,iOS中的runloop

实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。

因此Runloop实际上就是一个对象,只不过这个对象是用来管理需要处理的消息与事件的,并且这个对象提供了一个入口函数 执行Event Loop,线程执行函数后会一直进行事件循环,直到接收到退出消息

在iOS中提供了两个EventLoop的具体实现:
NSRunLoopCFRunLoopRef

CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全 的。
NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

二、RunLoop 与线程的关系

我们在先前的多线程里讲过,基本上所有的线程操作的底层都是对pthread_t的封装

同时回到我们Runloop与线程的关系,苹果不允许直接创建Runloop,它只提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:

bash 复制代码
/// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;
 
/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
    OSSpinLockLock(&loopsLock);
    
    if (!loopsDic) {
        // 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
        loopsDic = CFDictionaryCreateMutable();
        CFRunLoopRef mainLoop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
    }
    
    /// 直接从 Dictionary 里获取。
    CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
    
    if (!loop) {
        /// 取不到时,创建一个
        loop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, thread, loop);
        /// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
        _CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
    }
    
    OSSpinLockUnLock(&loopsLock);
    return loop;
}
 
CFRunLoopRef CFRunLoopGetMain() {
    return _CFRunLoopGet(pthread_main_thread_np());
}
 
CFRunLoopRef CFRunLoopGetCurrent() {
    return _CFRunLoopGet(pthread_self());

从上面的代码可以看出,线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里

线程刚创建时并没有runloop,我们需要主动获取,系统才会自动帮我们创建runloop并加到字典中

三、Runloop对外的接口

我们在上文说了iOS并不允许我们直接创建Runloop,但其提供了两个函数给我们,CFRunLoopGetCurrent 和 CFRunLoopGetMain

  • CFRunLoopGetCurrent 函数便是获取当前线程的 CFRunLoop 对象,如果不存在的话会则会创建一个。
  • CFRunLoopGetMain 则是获取主线程的 CFRunLoop 对象。

同时我们需要明白我们这两个函数只是创建了线程对应的Runloop,但是创建之后的Runloop并没有运行起来,因此需要程序员让runloop运行起来

在研究如何使 run loop 运行起来和 run loop 运行起来后的行为,需要先了解 run loop 的一些具体结构。

在 CoreFoundation 里面关于 RunLoop 有5个类:

  • CFRunLoopRef
  • CFRunLoopModeRef
  • CFRunLoopSourceRef
  • CFRunLoopTimerRef
  • CFRunLoopObserverRef

一个Runloop中包含了若干个Mode, CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装,他们的关系如下:

同时通过这张图我们可以看到,每个Mode中游包含了Source/Timer/Observer的集合

每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode

如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

CFRunLoopSourceRef

CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。

这里我们首先要明确一个概念,不是所有代码都是通过Runloop处理的,RunLoop主要用于处理异步事件,如用户输入、定时器触发、网络响应等。这些事件通常被封装成事件源,然后由RunLoop在适当的时机调度和处理

比如NSLog函数就不会使用到Runloop进行事件循环

Source0

Source0 只包含了一个回调(函数指针)

它并不能主动触发事件

使用source0时,需要先调用CFRunLoopSourceSignal(source0)将 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件,按钮的点击滑动等都是在此进行处理的

bash 复制代码
// 假设有一个方法,用于处理按钮点击
- (void)buttonClicked {
    // 手动触发RunLoop的Source0
    CFRunLoopSourceSignal(source0);
    CFRunLoopWakeUp(CFRunLoopGetCurrent()); // 唤醒RunLoop来处理事件
}

// 配置Source0
- (void)setupSource0 {
    CFRunLoopSourceContext context = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL, NULL, NULL, &callout};
    source0 = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source0, kCFRunLoopDefaultMode);
}

// Source0的回调函数
void callout(void *info) {
    NSLog(@"Source0 event triggered.");
}

Source1

Source1 包含了一个 mach_port 和一个回调(函数指针)

被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

bash 复制代码
// 配置Source1
- (void)setupSource1 {
    CFRunLoopSourceContext1 context = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL, &perform, NULL};
    CFMessagePortRef localPort = CFMessagePortCreateLocal(kCFAllocatorDefault, CFSTR("com.example.app.port"), &callback, &context, false);
    CFRunLoopSourceRef source1 = CFMessagePortCreateRunLoopSource(kCFAllocatorDefault, localPort, 0);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source1, kCFRunLoopCommonModes);
}

// Source1的回调函数
CFDataRef callback(CFMessagePortRef local, SInt32 msgid, CFDataRef data, void *info) {
    NSLog(@"Received message: %d", msgid);
    return NULL;
}

// Source1的事件执行
void perform(void *info) {
    NSLog(@"Performing work in response to external event.");
}

使用场景:

• 处理来自其他进程的数据或信号。

• 监听系统级事件或网络事件。

CFRunLoopTimerRef

CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimertoll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调

CFRunLoopObserverRef

CFRunLoopObserverRefCore Foundation 框架中的一种对象,用于监视和响应 RunLoop 的特定活动。通过 CFRunLoopObserver,开发者可以在 RunLoop 的不同阶段插入自定义的代码来执行特定的任务

可以观测的时间点有以下几个:

bash 复制代码
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
};

我们在上面讲的 Source/Timer/Observer 被统称为 mode item,一个item被重复添加到同一个mode时不会多次执行,但是如果一个mode中一个item都没有,runloop会自动退出,不会进出循环

四、RunLoop 的 Mode

刚才在上文讲了mode item,modeitem是被加到mode中的,我们现在讲一下Runloop的Mode

CFRunLoopModeCFRunLoop 的结构大致如下:

bash 复制代码
struct __CFRunLoopMode {
    CFStringRef _name;            // Mode Name, 例如 @"kCFRunLoopDefaultMode"
    CFMutableSetRef _sources0;    // Set
    CFMutableSetRef _sources1;    // Set
    CFMutableArrayRef _observers; // Array
    CFMutableArrayRef _timers;    // Array
    ...
};
 
struct __CFRunLoop {
    CFMutableSetRef _commonModes;     // Set
    CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
    CFRunLoopModeRef _currentMode;    // Current Runloop Mode
    CFMutableSetRef _modes;           // Set
    ...
};

这里有个概念叫CommonModes,一个Mode可以把自己标记成"Common"属性(通过将其 ModeName 添加到 RunLoop 的 "commonModes" 中),每当Runloop的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 Common标记的所有Mode里。

应用场景举例

主线程的Runloop中有两个预置的Mode

kCFRunLoopDefaultModeUITrackingRunLoopMode。这两个 Mode 都已经被标记为"Common"属性

应用场景举例:

DefalutMode是App平时所处的状态,TrackingMode是当滑动时所处的状态,当我们创建NSTimer添加到DefalutMode中,Timer会得到重复回调 ,但是当我们滚动我们的TableView时,Runloop会切换Mode,由DefalutMode切换为TrackingMode,此时Timer会停止同时不会进行回调,也不会影响到滑动的操作

但是如果我们想在滑动的时候NSTimer能够继续运作

  • 一种方法就是将Timer分别加入到两个Mode
  • 另一种方法就是将NSTimer加到最顶层的RunLoopcommonModeItems,加入后的ModeItems类型会被Runloop加到具有common属性的Mode中去,也就是直接将Timer同时加到defaultModeTrackMode中去

上面既然讲到了RunLoop中的Mode,我们来分析一下iOS中到底有几种Mode:

苹果公开的三种 RunLoop Mode

  • NSDefaultRunLoopMode(kCFRunloopDefaultMode):默认状态,app通常在这个mode下运行
  • UITrackingRunLoopMode:界面跟踪mode(例如滑动scrollview时不被其他mode影响)
  • NSRunLoopCommonModes(kCFRunLoopCommonModes):是 前两个mode的集合,可以把自定义modeCFRunLoopAddCommonMode函数加入到集合中

还有两种mode,只需做了解即可:

  • GSEventReceiveRunLoopMode:接收系统内部mode,通常用不到
  • UIInitializationRunLoopMode:私有,只在app启动时使用,使用完就不在集合中了

五、Runloop的内部逻辑

来看一张经典的Runloop逻辑图

Runloop代码整理如下,后面会专门讲一下Runloop的源码

bash 复制代码
/// 用DefaultMode启动
void CFRunLoopRun(void) {
    CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
}
 
/// 用指定的Mode启动,允许设置RunLoop超时时间
int CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean stopAfterHandle) {
    return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled);
}
 
/// RunLoop的实现
int CFRunLoopRunSpecific(runloop, modeName, seconds, stopAfterHandle) {
    
    /// 首先根据modeName找到对应mode
    CFRunLoopModeRef currentMode = __CFRunLoopFindMode(runloop, modeName, false);
    /// 如果mode里没有source/timer/observer, 直接返回。
    if (__CFRunLoopModeIsEmpty(currentMode)) return;
    
    /// 1. 通知 Observers: RunLoop 即将进入 loop。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
    
    /// 内部函数,进入loop
    __CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled) {
        
        Boolean sourceHandledThisLoop = NO;
        int retVal = 0;
        do {
 
            /// 2. 通知 Observers: RunLoop 即将触发 Timer 回调。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
            /// 3. 通知 Observers: RunLoop 即将触发 Source0 (非port) 回调。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
            /// 执行被加入的block
            __CFRunLoopDoBlocks(runloop, currentMode);
            
            /// 4. RunLoop 触发 Source0 (非port) 回调。
            sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle);
            /// 执行被加入的block
            __CFRunLoopDoBlocks(runloop, currentMode);
 
            /// 5. 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
            if (__Source0DidDispatchPortLastTime) {
                Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
                if (hasMsg) goto handle_msg;
            }
            
            /// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)。
            if (!sourceHandledThisLoop) {
                __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
            }
            
            /// 7. 调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
            /// • 一个基于 port 的Source 的事件。
            /// • 一个 Timer 到时间了
            /// • RunLoop 自身的超时时间到了
            /// • 被其他什么调用者手动唤醒
            __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
                mach_msg(msg, MACH_RCV_MSG, port); // thread wait for receive msg
            }
 
            /// 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);
            
            /// 收到消息,处理消息。
            handle_msg:
 
            /// 9.1 如果一个 Timer 到时间了,触发这个Timer的回调。
            if (msg_is_timer) {
                __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
            } 
 
            /// 9.2 如果有dispatch到main_queue的block,执行block。
            else if (msg_is_dispatch) {
                __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
            } 
 
            /// 9.3 如果一个 Source1 (基于port) 发出事件了,处理这个事件
            else {
                CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
                sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
                if (sourceHandledThisLoop) {
                    mach_msg(reply, MACH_SEND_MSG, reply);
                }
            }
            
            /// 执行加入到Loop的block
            __CFRunLoopDoBlocks(runloop, currentMode);
            
 
            if (sourceHandledThisLoop && stopAfterHandle) {
                /// 进入loop时参数说处理完事件就返回。
                retVal = kCFRunLoopRunHandledSource;
            } else if (timeout) {
                /// 超出传入参数标记的超时时间了
                retVal = kCFRunLoopRunTimedOut;
            } else if (__CFRunLoopIsStopped(runloop)) {
                /// 被外部调用者强制停止了
                retVal = kCFRunLoopRunStopped;
            } else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
                /// source/timer/observer一个都没有了
                retVal = kCFRunLoopRunFinished;
            }
            
            /// 如果没超时,mode里没空,loop也没被停止,那继续loop。
        } while (retVal == 0);
    }
    
    /// 10. 通知 Observers: RunLoop 即将退出。
    __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
}

可以看到Runloop内部实际就是这样一个函数,其内部一直在进行do-while循环,当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。

六、Runloop应用

事件响应

当一个硬件事件(触屏/锁屏/摇晃/加速)发生之后,首先在触摸时会生成一个IOHIDEvent事件,之后由mach port端口转发给App进程

苹果同时也注册了一个source1来接受系统事件,通过回调函数出发Source0,(所以UIEvent实际上基于Source0)的,调用_UIApplicationHandleEventQueue()进行应用内部的分发,_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发

,其实包括点击事件,手势处理等

界面更新

  • 当UI发生改变时(Frame变化,UIView/CALayer结构变化),或手动调用了UIView/CALayer的setNeedsLayout/setNeedsDisplay方法之后,就将这个UIView、CALayer就被标记为待处理
  • 苹果注册了一个用来监听BeforeWaiting和Exit的Observer,在他的回调函数里会遍历所有待处理的UIView/CALayer来执行实际的绘制和调整,并更新UI界面。

AutoreleasePool

主线程注册了两个Observers,他们用来创建与释放AutoreleasePool

  • Observers1 监听Entry事件: 优先级最高,确保在所有的回调前创建释放池 ,回调内调用 _objc_autoreleasePoolPush()创建自动释放池

  • Observers2监听BeforeWaitingExit事件: 优先级最低,保证在所有回调后释放释放池。BeforeWaiting事件:调用_objc_autoreleasePoolPop()和_objc_autoreleasePoolPush()释放旧池并创建新池,Exit事件: 调用_objc_autoreleasePoolPop(),释放自动释放池

tableView延迟加载图片,保证流畅

我们在快速滑动的图片滑动过的图片会一直加载,但是快速滑动过的图片并不是我们想要的图片,如果进行加载就十分浪费CPU资源,我们现在有一种思路,让我们的tableview滑动时不加载图片

给ImgaeView的加载图片的方法指定只有在DefalutMode下才能加载,滑动时不加载图片,为实现这个功能我们用到了

bash 复制代码
[self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"imgName.png"] afterDelay:0 inModes:@[NSDefaultRunLoopMode]];

Timer不被ScrollView的滑动影响

我们在上面说了两种方法,一种是将定时器加到trackingMode中,另一种是加到CommonMode集合中,我们来介绍另一种方法

  • GCD创建定时器,GCD创建的定时器不会受RunLoop影响
bash 复制代码
// 获得队列
    dispatch_queue_t queue = dispatch_get_main_queue();
    
    // 创建一个定时器(dispatch_source_t本质还是个OC对象)
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    // 设置定时器的各种属性(几时开始任务,每隔多长时间执行一次)
    // GCD的时间参数,一般是纳秒(1秒 == 10的9次方纳秒)
    // 比当前时间晚1秒开始执行
    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC));
    
    //每隔一秒执行一次
    uint64_t interval = (uint64_t)(1.0 * NSEC_PER_SEC);
    dispatch_source_set_timer(self.timer, start, interval, 0);
    
    // 设置回调
    dispatch_source_set_event_handler(self.timer, ^{
        NSLog(@"------------%@", [NSThread currentThread]);

    });
    
    // 启动定时器
    dispatch_resume(self.timer);

AFNetworking

在多线程中,线程常常执行完任务就会退出,这意味着如果我们需要反复执行任务例如网络请求,网络监听等,必须要频繁地创建与销毁线程,这样不仅效率低下,而且增加了系统的开销,因此我们希望实现一个常驻线程专门处理这些任务

常驻线程

我们在上面说过,一个RunLoop中如果没有Observer/Timer/Source等items,Runloop会自动退出,因此我们创建一个空的port发送消息给Runloop,以至于Runloop不会退出而是一直常驻

首先创建一个线程属性

其次验证是否我们的点击事件是在loop中执行且线程不销毁

bash 复制代码
- (void)viewDidLoad {
    [super viewDidLoad];
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(runThread) object:nil];
    [self.thread start];}

- (void)runThread {
    NSLog(@"开启子线程:%@", [NSThread currentThread]);
// 子线程的RunLoop创建出来需要手动添加事件输入源和定时器 因为runloop如果没有CFRunLoopSourceRef事件源输入或者定时器,就会立马消亡。
    //下面的方法给runloop添加一个NSport,就是添加一个事件源,也可以添加一个定时器,或者observer,让runloop不会挂掉
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
    
    // 测试开始RunLoop
    // 未进入循环就会执行该代码
    NSLog(@"failed");
}

// 同时在我们自己新建立的这个线程中写一下touchesBegan这个方法测试点击空白处会不会在子线程相应方法
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event  {
    [self performSelector:@selector(runTest) onThread:self.thread withObject:nil waitUntilDone:NO];
}
- (void)runTest {
    NSLog(@"子线程点击空白:%@", [NSThread currentThread]);
}

可以看到点击之后出现如下情况

PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,其内部会自动创建一个Timer加到Runloop中,当时间到了执行回调,如果当前线程没有Runloop,此方法也会失效

总结

本文介绍玩Runloop,应该懂得:

Runloop事件上就是一个事件循环,也可以当作一个对象,这个对象实际上就是用来处理消息与事件的,其提供了一个入口函数去执行EventLoop

同时Runloop中又包含五种Mode,其中最常用的是CommonMode,DefaultMode,TrackingMode,Mode中又有ModeItems的集合,如果一个Mode中没有items,那么runloop就会退出,items是指Timer/Source/Observer等,Source中又包括Source0与Source1,Source0主要处理点击事件,Source1主要处理线程之间发送消息等操作,但是Source0必须要手动出发Runloop

同时知道了Runloop的基本逻辑,我们还可以在日常开发中使用它,例如解决定时器不准,实现ImageView延迟加载,AFNetWorking中实现常驻线程等功能,同时在iOS的底层实现界面更新以及事件响应都用到了Runloop

同时Runloop十分重要,后面还会分析Runloop的源码

相关推荐
开心就好20252 天前
iOS App 安全加固流程记录,代码、资源与安装包保护
后端·ios
开心就好20252 天前
iOS App 性能测试工具怎么选?使用克魔助手(Keymob)结合 Instruments 完成
后端·ios
zhongjiahao3 天前
面试常问的 RunLoop,到底在Loop什么?
ios
wvy4 天前
iOS 26手势返回到根页面时TabBar的动效问题
ios
RickeyBoy4 天前
iOS 图片取色完全指南:从像素格式到工程实践
ios
aiopencode4 天前
使用 Ipa Guard 命令行版本将 IPA 混淆接入自动化流程
后端·ios
二流小码农5 天前
鸿蒙开发:路由组件升级,支持页面一键创建
android·ios·harmonyos
iceiceiceice5 天前
iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段
前端·人工智能·ios
ssshooter7 天前
Tauri 踩坑 appLink 修改后闪退
前端·ios·rust
二流小码农7 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos