60s循环计时器 ios

2015-11-16 10:02:50 cjckkk 阅读数 634
  • 2017上半年上午真题55-57

    本视频课程紧跟系统集成项目管理工程师最新考试大纲,按照最新官方教程全新录制,对上午基础知识中的考点做了详细的讲解,对考试中重复考、重点考的知识点进行了专题强化培训,帮助考生掌握考试的重点,提升专业技能...

    112人学习 任铄
    免费试看
前段时间做一个闹钟类型的产品,使用localnotification不太符合要求。

对于ios7,苹果支持几种后台运行模式,backgroundTask,voip,后台播放音乐等,具体看官方文档就好。

我这边需要在后台跑一个长时间运行的计时器,所以就不能让app进入suspend状态。

很早以前听说可以通过后台播放音乐来实现,借鉴了一下,测试好几天,找出来了一个还比较靠谱的方案:

首先在

- (void)applicationDidEnterBackground:(UIApplication *)application{

}

里面申请backgroundTask

[[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil]

实现一个可以运行几分钟的权限。

然后写一个计时器实时检测 backgroundTimeRemaining

- (void)tik{

    if ([[UIApplication sharedApplication] backgroundTimeRemaining] < 61.0) {

       [[CKAudioTool sharedInstance] playSound];

        [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil];

    }

}

 [[CKAudioTool sharedInstance] playSound];这段代码是去播放了一个无声的音乐,很关键的一点是

[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:&error]

这样后台播放就不会影响到别的程序播放音乐了。

我这个计时器每分钟运行一次tik函数,如果发现后台运行时间小于一分钟了,就再去申请一个backgroundTask。

神奇的地方在于:backgroundTask不能在程序已经进入后台的时候申请,可以用一个播放音乐的假前台状态去申请,所以可以做到不断申请到权限,也就完成了长时间后台执行。

2.

 iOS为了让设备尽量省电,减少不必要的开销,保持系统流畅,因而对后台机制采用墓碑式的“假后台”。除了系统官方极少数程序可以真后台,一般开发者开发出来的应用程序后台受到以下限制:
1.用户按Home之后,App转入后台进行运行,此时拥有180s后台时间(iOS7)或者600s(iOS6)运行时间可以处理后台操作
2.180S或者600S时间过去之后,可以告知系统未完成任务,需要申请继续完成,系统批准申请之后,可以继续运行,但总时间不会超过10分钟。
3.10分钟时间到之后,无论怎么向系统申请继续后台,系统会强制挂起App,挂起所有后台操作、线程,直到用户再次点击App之后才会继续运行。

当然iOS为了特殊应用也保留了一些可以实现“真后台”的方法,摘取比较常用的:
1.VOIP
2.定位服务
3.后台下载
4.在后台一直播放无声音乐(容易受到电话或者其他程序影响,所以暂未考虑)
5….更多
其中VOIP需要绑定一个Socket链接并申明给系统,系统将会在后台接管这个连接,一旦远端数据过来,你的App将会被唤醒10s(或者更少)的时间来处理数据,超过时间或者处理完毕,程序继续休眠。
后台现在是iOS7引入的新API,网上实现的代码比较少,博主也没有细心去找。
由于博主要做的App需要在后台一直运行,每隔一段时间给服务器主动发送消息来保持帐号登陆状态,因而必须确保App不被系统墓碑限制。
博主最先尝试了很多方法,包括朋友发来的一个Demo,每180s后台时间过期就销毁自己然后再创建一个后台任务,但是实际测试只有10分钟时间。最后因为考虑到VOIP对服务端改动太大,时间又太紧,所以选择了定位服务的方法来保持后台。

要启动定位服务:
1.需要引入头文件:#import <CoreLocation/CoreLocation.h>
2.在AppDelegate.m中定义CLLocationManager * locationManager;作为全局变量方便控制
3.在程序启动初期对定位服务进行初始化:
1
2

locationManager = [[CLLocationManager alloc] init];
locationManager.delegate = self;//or whatever class you have for managing location</pre>

4.在程序转入后台的时候,启动定位服务
[locationManager startUpdatingLocation];(第一次运行这个方法的时候,如果之前用户没有使用过App,则会弹出是否允许位置服务,关于用户是否允许,后面代码中有判断)
这样在定位服务可用的时候,程序会不断刷新后台时间,实际测试,发现后台180s时间不断被刷新,达到长久后台的目的。

但是这样使用也有一些问题,在部分机器上面,定位服务即使打开也可能不能刷新后台时间,需要完全结束程序再运行。稳定性不知道是因为代码原因还是系统某些机制原因。

下面贴上代码:
注意:代码中包含朋友给的demo中,180s时间后销毁自己再创建自己的后台方法,我自己实现过程中加入了定位服务来确保后台能够一直在线。
源码参考部分来自网上,因为翻了Google,找了很多英文方面的博文,在此感谢原作者分享。

判断用户是否打开了定位服务,是否禁用了该程序的定位权限:
1
2
3
4
5

if(![CLLocationManager locationServicesEnabled] || ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusDenied))//判断定位服务是否打开
    {
        [InterfaceFuncation ShowAlertWithMessage:@"错误" AlertMessage:@"定位服务未打开\n保持在线需要后台定位服务\n请到 设置-隐私 中打开定位服务" ButtonTitle:@"我错了"];
        return;
    }

AppDelegate.m源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143

@property (assign, nonatomic) UIBackgroundTaskIdentifier bgTask;

@property (strong, nonatomic) dispatch_block_t expirationHandler;
@property (assign, nonatomic) BOOL jobExpired;
@property (assign, nonatomic) BOOL background;
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{

 UIApplication* app = [UIApplication sharedApplication];

 __weak NSUAAAIOSAppDelegate* selfRef = self;

 self.expirationHandler = ^{  //创建后台自唤醒,当180s时间结束的时候系统会调用这里面的方法
 [app endBackgroundTask:selfRef.bgTask];
 selfRef.bgTask = UIBackgroundTaskInvalid;
 selfRef.bgTask = [app beginBackgroundTaskWithExpirationHandler:selfRef.expirationHandler];
 NSLog(@"Expired");
 selfRef.jobExpired = YES;
 while(selfRef.jobExpired)
 {
 // spin while we wait for the task to actually end.
 NSLog(@"等待180s循环进程的结束");
 [NSThread sleepForTimeInterval:1];
 }
 // Restart the background task so we can run forever.
 [selfRef startBackgroundTask];
 };

 // Assume that we're in background at first since we get no notification from device that we're in background when
 // app launches immediately into background (i.e. when powering on the device or when the app is killed and restarted)
 [self monitorBatteryStateInBackground];
 locationManager = [[CLLocationManager alloc] init];
 locationManager.delegate = self;
 //[locationManager startUpdatingLocation];
 return YES;
}

- (void)monitorBatteryStateInBackground
{
 self.background = YES;
 [self startBackgroundTask];
}

- (void)applicationDidBecomeActive:(UIApplication *)application
{
 // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
 NSLog(@"App is active");
 [UIApplication sharedApplication].applicationIconBadgeNumber=0;//取消应用程序通知脚标
 [locationManager stopUpdatingLocation];
 self.background = NO;
}

- (void)applicationDidEnterBackground:(UIApplication *)application
{
 // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
 // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
 //if([self bgTask])
 if(isLogined)//当登陆状态才启动后台操作
 {
 self.bgTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:self.expirationHandler];
 NSLog(@"Entered background");
 [self monitorBatteryStateInBackground];
 }
}

- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error//当定位服务不可用出错时,系统会自动调用该函数
{
 NSLog(@"定位服务出错");
 if([error code]==kCLErrorDenied)//通过error的code来判断错误类型
 {
 //Access denied by user
 NSLog(@"定位服务未打开");
 [InterfaceFuncation ShowAlertWithMessage:@"错误" AlertMessage:@"未开启定位服务\n客户端保持后台功能需要调用系统的位置服务\n请到设置中打开位置服务" ButtonTitle:@"好"];
 }
}

- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations//当用户位置改变时,系统会自动调用,这里必须写一点儿代码,否则后台时间刷新不管用
{
 NSLog(@"位置改变,必须做点儿事情才能刷新后台时间");
 CLLocation *loc = [locations lastObject];
 //NSTimeInterval backgroundTimeRemaining = [[UIApplication sharedApplication] backgroundTimeRemaining];
 //NSLog(@"Background Time Remaining = %.02f Seconds",backgroundTimeRemaining);
 // Lat/Lon
 float latitudeMe = loc.coordinate.latitude;
 float longitudeMe = loc.coordinate.longitude;
}

- (void)startBackgroundTask
{
 NSLog(@"Restarting task");
 if(isLogined)//当登陆状态才进入后台循环
 {
 // Start the long-running task.
    NSLog(@"登录状态后台进程开启");
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
 // When the job expires it still keeps running since we never exited it. Thus have the expiration handler
 // set a flag that the job expired and use that to exit the while loop and end the task.
    NSInteger count=0;
    BOOL NoticeNoBackground=false;//只通知一次标志位
    BOOL FlushBackgroundTime=false;//只通知一次标志位
    locationManager.distanceFilter = kCLDistanceFilterNone;//任何运动均接受,任何运动将会触发定位更新
    locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters;//定位精度
    while(self.background && !self.jobExpired)
    {
       NSLog(@"进入后台进程循环");
       [NSThread sleepForTimeInterval:1];
       count++;
       if(count>60)//每60s进行一次开启定位,刷新后台时间
       {
          count=0;
          [locationManager startUpdatingLocation];
          NSLog(@"开始位置服务");
          [NSThread sleepForTimeInterval:1];
          [locationManager stopUpdatingLocation];
          NSLog(@"停止位置服务");
          FlushBackgroundTime=false;
       }
       if(!isLogined)//未登录或者掉线状态下关闭后台
       {
          NSLog(@"保持在线进程失效,退出后台进程");
          [InterfaceFuncation ShowLocalNotification:@"保持在线失效,登录已被注销,请重新登录"];
          [[UIApplication sharedApplication] endBackgroundTask:self.bgTask];
          return;//退出循环
       }
       NSTimeInterval backgroundTimeRemaining = [[UIApplication sharedApplication] backgroundTimeRemaining];
       NSLog(@"Background Time Remaining = %.02f Seconds",backgroundTimeRemaining);
       if(backgroundTimeRemaining<30&&NoticeNoBackground==false)
       {
          [InterfaceFuncation ShowLocalNotification:@"向系统申请长时间保持后台失败,请结束客户端重新登录"];
          NoticeNoBackground=true;
    }
    //测试后台时间刷新
       if(backgroundTimeRemaining>200&&FlushBackgroundTime==false)
       {
          [[NSNotificationCenter defaultCenter] postNotificationName:@"MessageUpdate" object:@"刷新后台时间成功\n"];
          FlushBackgroundTime=true;
          //[InterfaceFuncation ShowLocalNotification:@"刷新后台时间成功"];
       }
    }
    self.jobExpired = NO;
    });
 }
}
2018-03-19 21:02:50 u010262501 阅读数 5059
  • 2017上半年上午真题55-57

    本视频课程紧跟系统集成项目管理工程师最新考试大纲,按照最新官方教程全新录制,对上午基础知识中的考点做了详细的讲解,对考试中重复考、重点考的知识点进行了专题强化培训,帮助考生掌握考试的重点,提升专业技能...

    112人学习 任铄
    免费试看

前言

在实现需求的同时,能写出既优雅性能又高效的代码是每个开发者都在追求的目标,但是在实际开发中,随着每个版本需求的迭代,功能变得越来越复杂,加上开发者的意识不够或者一时疏忽,日渐复杂的工程很容易产生或多或少的问题。
比如,app随机丢失动画、用户反馈app卡死等等的问题,这些问题都严重影响使用,也会降低产品口碑,我们除了在开发过程中,通过instrument来检测这些问题,还可以借助一些第三方监控工具来解决这些问题,KMCGeigerCounter就是一个很好的卡顿检测器。

在分析KMCGeigerCounter这个第三方app卡顿检测工具之前,我们先来分析几种UI卡顿检测方案。

卡顿检测的分析

简单来说,主线程为了达到接近60fps的绘制效率,不能在UI线程有单个超过(1/60s≈16ms)的计算任务。

通过Instrument设置16ms的采样率可以检测出大部分这种费时的任务,但有以下缺点:

1、Instrument profile一次重新编译,时间较长。
2、只能针对特定的操作场景进行检测,要预先知道卡顿产生的场景。
3、每次猜测,更改,再猜测再以此循环,需要重新profile。

我们的目标方案是,检测能够自动发生,并不需要开发人员做任何预先配置或profile。运行时发现卡顿能即时通知开发人员导致卡顿的函数调用栈。

检测方案一:基于Runloop

主线程绝大部分计算或者绘制任务都是以Runloop为单位发生。单次Runloop如果时长超过16ms,就会导致UI体验的卡顿。那如何检测单次Runloop的耗时呢?
Runloop的生命周期及运行机制虽然不透明,但苹果提供了一些API去检测部分行为。我们可以通过如下代码监听Runloop每次进入的事件:

- (void)setupRunloopObserver{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        CFRunLoopRef runloop = CFRunLoopGetCurrent(); 
        CFRunLoopObserverRef enterObserver;
        enterObserver = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                               kCFRunLoopEntry | kCFRunLoopExit,
                                               true,
                                               -0x7FFFFFFF,
                                               BBRunloopObserverCallBack, NULL);
        CFRunLoopAddObserver(runloop, enterObserver, kCFRunLoopCommonModes);
        CFRelease(enterObserver);
    });
}
static void BBRunloopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    switch (activity) {
        case kCFRunLoopEntry: {
            NSLog(@"enter runloop...");
        }
            break;
        case kCFRunLoopExit: {
            NSLog(@"leave runloop...");
        }
            break;
        default: break;
    }
}

看起来kCFRunLoopExit的时间,减去kCFRunLoopEntry的时间,即为一次Runloop所耗费的时间,这样就能找出大于16ms的runloop。
但是demo实践结果是:kCFRunLoopExit的时间减去kCFRunLoopEntry,得到的时间差,貌似不准。
缺陷:但无法定位到具体的函数,只能起到预报的作用。

方案一是可以通过监测runloop计算每次主线程的任务执行时间是否超过16ms来判断是否有卡顿,但是缺点在于无法定位卡顿的位置,所以有了方案二。

检测方案二:基于线程

最理想的方案是让UI线程“主动汇报”当前耗时的任务,听起来简单做起来不轻松。

我们可以假设这样一套机制:每隔16ms让UI线程来报道一次,如果16ms之后UI线程没来报道,那就一定是在执行某个耗时的任务。这种抽象的描述翻译成代码,可以用如下表述:
我们启动一个worker线程,worker线程每隔一小段时间(delta)ping一下主线程(发送一个NSNotification),如果主线程此时有空,必然能接收到这个通知,并pong以下(发送另一个NSNotification),如果worker线程超过delta时间没有收到pong的回复,那么可以推测UI线程必然在处理其他任务了,此时我们执行第二步操作,暂停UI线程,并打印出当前UI线程的函数调用栈。

难点在这第二步,如何暂停UI线程,同时获取到callstack。

iOS的多线程编程一般使用NSOperation或者GCD,这两者都无法暂停每个正在执行的线程。
所谓的cancel调用也只能在目标线程空闲的时候,主动检测cancelled状态,然后主动sleep,这显然非我所欲。

如果我们从worker线程给UI线程发送signal,UI线程会被即刻暂停,并进入接收signal的回调,再将callstack打印就接近目标了。
iOS确实允许在主线程注册一个signal处理函数,类似这样:

signal(CALLSTACK_SIG, thread_singal_handler);

流程图

代码实现:

//在主线程注册signal handler
signal(CALLSTACK_SIG, thread_singal_handler);

//通过NSNotification完成ping pong流程
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPingFromWorkerThread) name:Notification_PMainThreadWatcher_Worker_Ping object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPongFromMainThread) name:Notification_PMainThreadWatcher_Main_Pong object:nil];

//如果ping超时,pthread_kill主线程。
pthread_kill(mainThreadID, CALLSTACK_SIG);

//主线程被暂停,进入signal回调,通过[NSThread callStackSymbols]获取主线程当前callstack。
static void thread_singal_handler(int sig) {
    NSLog(@"main thread catch signal: %d", sig);
    if (sig != CALLSTACK_SIG) {
        return;
    }
    NSArray* callStack = [NSThread callStackSymbols];
    id<PMainThreadWatcherDelegate> del = [PMainThreadWatcher sharedInstance].watchDelegate;
    if (del != nil && [del respondsToSelector:@selector(onMainThreadSlowStackDetected:)])  {
        [del onMainThreadSlowStackDetected:callStack];
    }
    else {
        NSLog(@"detect slow call stack on main thread! \n");
        for (NSString* call in callStack) {
            NSLog(@"%@\n", call);
        }
    }
    return;
}

说明:
值得一提的是上述代码不能调试,因为调试时gdb会干扰signal的处理,导致signal handler无法进,但UI线程在遇到卡顿的时候还是能正常被中断。
现阶段的实现,worker线程每隔1秒会ping一次UI线程,检测出运行超过16ms的调用栈。开发阶段可以将1s的间隔调至更短,可能会对app整体性能造成少许的负担,但能检测出更多的卡顿调用。

signal相关的知识点
iOS系统的signal可以被归为两类:

第一类内核signal,这类signal由操作系统内核发出,比如当我们访问VM上不属于自己的内存地址时,会触发EXC_BAD_ACCESS异常,内核检测到该异常之后会发出第二类signal:BSD signal,传递给应用程序。

第二类BSD signal,这类signal需要被应用程序自己处理。通常当我们的App进程运行时遇到异常,比如NSArray越界访问。产生异常的线程会向当前进程发出signal,如果这个signal没有别处理,我们的app就会crash了。

平常我们调试的时候很容易遇到第二类signal导致整个程序被中断的情况,gdb同时会将每个线程的调用栈呈现出来。

pthread_kill允许我们向目标线程(UI线程)发送signal,目标线程被暂停,同时进入signal回调,将当前线程的callstack获取并处理,处理完signal之后UI线程继续运行。将callstack打印即可精确定位产生问题的函数调用栈。

方案一监听RunLoop无疑会污染主线程,死循环在线程间通信会造成大量的不必要损耗,即便GCD的性能已经很好了,因此,第三种方案采用CADisplayLink的方式来处理。

检测方案三:CADisplayLink监控

CADisplayLink监控的思路是每个屏幕刷新周期,派发标记位设置任务到主线程中,如果多次超出16.7ms的刷新阙值,即可看作是发生了卡顿。

什么是CADisplayLink?
CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。
我们在应用中创建一个新的 CADisplayLink 对象,把它添加到一个runloop中,并给它提供一个 target 和selector 在屏幕刷新的时候调用。
一旦 CADisplayLink 以特定的模式注册到runloop之后,每当屏幕需要刷新的时候,runloop就会调用CADisplayLink绑定的target上的selector,这时target可以读到 CADisplayLink 的每次调用的时间戳,用来准备下一帧显示需要的数据。
例如一个视频应用使用时间戳来计算下一帧要显示的视频数据。在UI做动画的过程中,需要通过时间戳来计算UI对象在动画的下一帧要更新的大小等等。
在添加进runloop的时候我们应该选用高一些的优先级,来保证动画的平滑。可以设想一下,我们在动画的过程中,runloop被添加进来了一个高优先级的任务,那么,下一次的调用就会被暂停转而先去执行高优先级的任务,然后在接着执行CADisplayLink的调用,从而造成动画过程的卡顿,使动画不流畅。
duration属性提供了每帧之间的时间,也就是屏幕每次刷新之间的的时间。我们可以使用这个时间来计算出下一帧要显示的UI的数值。但是 duration只是个大概的时间,如果CPU忙于其它计算,就没法保证以相同的频率执行屏幕的绘制操作,这样会跳过几次调用回调方法的机会。
frameInterval属性是可读可写的NSInteger型值,标识间隔多少帧调用一次selector 方法,默认值是1,即每帧都调用一次。如果每帧都调用一次的话,对于iOS设备来说那刷新频率就是60HZ也就是每秒60次,如果将 frameInterval 设为2 那么就会两帧调用一次,也就是变成了每秒刷新30次。
我们通过pause属性开控制CADisplayLink的运行。当我们想结束一个CADisplayLink的时候,应该调用-(void)invalidate
从runloop中删除并删除之前绑定的 target跟selector
另外CADisplayLink 不能被继承。

#define LXD_RESPONSE_THRESHOLD 10
dispatch_async(lxd_fluecy_monitor_queue(), ^{
    CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget: self selector: @selector(screenRenderCall)];
    [self.displayLink invalidate];
    self.displayLink = displayLink;

    [self.displayLink addToRunLoop: [NSRunLoop currentRunLoop] forMode: NSDefaultRunLoopMode];
    CFRunLoopRunInMode(kCFRunLoopDefaultMode, CGFLOAT_MAX, NO);
});

- (void)screenRenderCall {
    __block BOOL flag = YES;
    dispatch_async(dispatch_get_main_queue(), ^{
        flag = NO;
        dispatch_semaphore_signal(self.semphore);
    });
    dispatch_wait(self.semphore, 16.7 * NSEC_PER_MSEC);
    if (flag) {
        if (++self.timeOut < LXD_RESPONSE_THRESHOLD) { return; }
        [LXDBacktraceLogger lxd_logMain];
    }
    self.timeOut = 0;
}

经过前面的分析,CADisplayLink监控是一个相对而言比较优的方案,KMCGeigerCounter就是一个借助CADisplayLink进行卡顿检测的第三方工具,接下来分析一下KMCGeigerCounter源码。

KMCGeigerCounter介绍

KMCGeigerCounter是一个iOS帧速计算器,像盖革计数器那样,当动画丢失一帧时它就记录一次。
掉帧通常是不可见的,但是很难区分55fps和60fps之间的不同,而KMCGeigerCounter可以让你观测到掉落5帧的情况,可以通过这个来检测app的卡顿程度。
KMCGeigerCounter弄了一个FPS监控条,通过CADisplayLink来获取屏幕刷新频率,在使用过程中就能即时知道什么页面流畅什么页面会卡顿。

因为官方的demo在didFinishLaunchingWithOptions方法中写了比较复杂的代码 而在Xcode7及以上的SDK不允许在设置rootViewController之前做过于复杂的操作 所以程序一直无法正常启动。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    [KMCGeigerCounter sharedGeigerCounter].enabled = YES;
});

KMCGeigerCounter源码分析

程序里面,通过CADisplayLink来检测CPU的卡顿,CADisplayLink 是一个计时器对象,可以使用这个对象来保持应用中的绘制与显示刷新的同步。更通俗的讲,电子显示屏都是由一个个像素点构成,要让屏幕显示的内容变化,需要以一定的频率刷新这些像素点的颜色值,系统会在每次刷新时触发 CADisplayLink。

#define kNormalFrameDuration    (1/60)      //流畅的屏幕刷新时,每帧之间的间隔时间
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkWillDraw:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

- (void)displayLinkWillDraw:(CADisplayLink *)displayLink {
    //当前屏幕刷新回调的时间
    CFTimeInterval currentFrameTime = displayLink.timestamp;
    //这次屏幕刷新和上一次屏幕刷新的时间间隔
    CFTimeInterval frameDuration = currentFrameTime - [self lastFrameTime];
    //如果界面不卡顿,那么屏幕刷新频率应该是1秒钟60帧,那么帧间间隔时间应该是1/60秒,如果当前刷新和上一次屏幕刷新的时间间隔,超过这个时间间隔,那么就属于卡顿,则系统响一下,这里设定,屏幕刷新如果是1秒钟少于40帧(60/1.5)则响一下。
    if (1.5 < frameDuration / kNormalFrameDuration) {
        AudioServicesPlaySystemSound(self.tickSoundID);
    }
    //记录每次屏幕刷新时的时间(60次)
    [self recordFrameTime:currentFrameTime];
    //显示帧率和丢帧数
    [self updateMeterLabel];
}
- (void)recordFrameTime:(CFTimeInterval)frameTime {
    ++self.frameNumber;
    //通过一个数组(60个元素),来记录屏幕每次刷新时的时间(60次刷新的时间)
    _lastSecondOfFrameTimes[self.frameNumber % kHardwareFramesPerSecond] = frameTime;
}
//获取上一秒丢失的帧数
- (NSInteger)droppedFrameCountInLastSecond {
    NSInteger droppedFrameCount = 0;
    CFTimeInterval lastFrameTime = CACurrentMediaTime() - kNormalFrameDuration;
    for (NSInteger i = 0; i < kHardwareFramesPerSecond; ++i) {
        //_lastSecondOfFrameTimes数组记录了前60次屏幕刷新的时间,如果当前时间与这个数组中60次刷新时间超过了1秒钟,那么都会被丢弃而不显示,累加则知道1秒钟丢了多少帧
        if (1.0 <= lastFrameTime - _lastSecondOfFrameTimes[i]) {
            ++droppedFrameCount;
        }
    }
    return droppedFrameCount;
}
- (void)updateMeterLabel {
    //一秒钟屏幕刷新时的丢帧数
    NSInteger droppedFrameCount = self.droppedFrameCountInLastSecond;
    //一秒钟屏幕刷新时的显示帧数
    NSInteger drawnFrameCount = self.drawnFrameCountInLastSecond;
    //...显示代码
}

将CADisplayLink添加到主线程runloop中,一旦屏幕需要刷新时,就会回调CADisplayLink对应的selector方法,在 selector 中可以通过 CADisplayLink 对象的属性 duration、frameInterval 和 timestamp 获取帧率和时间信息。

总结:
上面CADisplayLink是检测CPU的卡顿,但是GPU的卡顿需要用到SKView,库里面用到一个 1×1 的 SKView 来进行监视。

2019-09-25 15:38:37 qq_43804080 阅读数 672
  • 2017上半年上午真题55-57

    本视频课程紧跟系统集成项目管理工程师最新考试大纲,按照最新官方教程全新录制,对上午基础知识中的考点做了详细的讲解,对考试中重复考、重点考的知识点进行了专题强化培训,帮助考生掌握考试的重点,提升专业技能...

    112人学习 任铄
    免费试看

前言:最近Android开发需要做一个弹出框进度条,经过几天的学习调研,现在在这里总结一下。

Android中一开始对进度条的实现是通过ProgressDialog,可以弹出一个对话框,对话框里显示进度条。但是ProgressDialog在8.0以后被遗弃了,虽然也可以用,但是官方不推荐使用。相应的替代品就是ProgressBar。ProgressBar是一个布局,只能写在xml文件中,而ProgressDialog可以在java代码中实现。
下面先来看一下ProgressDialog的用法,我一共设置了四种dialog,最后一种就是我需要的弹出框进度条,并且是计时进度条。

在这四中里插入图片描述
先看activity_main.xml,内容比较简单,就是设置了几个button,用来弹出相应的弹出框。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <Button
        android:onClick="putong"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="普通对话框"
        />
    <Button
        android:onClick="danxuan"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="单选对话框"
        />
    <Button
        android:onClick="duoxuan"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="多选对话框"
        />
    <Button
        android:onClick="jindutiao"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="进度条对话框"
        />

</LinearLayout>

下面看MainActivity中的代码:每个弹出框的布局都在相应的函数中实现,四个函数对应四个弹出框。进度条弹出框用的是ProgressDialog,如何计时用的系统时间,最下面写了一个StartTimer函数和EndTimer函数来进行计时操作,到达规定的时间则完成进度条,如果中途退出通过ProgressDialog的dismiss()来清除弹出框。

package com.example.hello;

import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;

import java.util.Timer;
import java.util.TimerTask;

public class MainActivity extends AppCompatActivity {

    private int progress = 0;
    private Timer timer;
    private TimerTask timerTask;
    private ProgressDialog dialog;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    public void putong(View view) {

        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("普通对话框");
        builder.setMessage("这是一个空白的对话框");
        builder.setPositiveButton("确定", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                System.out.println("点了确定");
            }
        });
        builder.setNegativeButton("取消", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                System.out.println("点了取消");
            }
        });
        //调用show才能显示出来
        builder.show();

    }

    //点击按钮弹出一个单选对话框
    public void danxuan(View view) {
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("请选择您喜欢的课程");
        final String items[] = {"web开发", "android开发", "web前段", "ios开发", "咸鱼"};
        //-1代表没有条目被选中
        builder.setSingleChoiceItems(items, -1, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                //1.把选中的条目取出来
                String item = items[which];
                Toast.makeText(getApplicationContext(),item.toString(), Toast.LENGTH_LONG).show();
                //2.然后把对话框关闭
                dialog.dismiss();
            }
        });
        builder.show();
    }


    //多选对话框
    public void duoxuan(View view) {
        System.out.println("点击了");
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("你喜欢哪个明星");
        final String items[] = {"cxk", "赵丽颖", "“霸道总裁”黄晓明", "黄渤", "徐峥", "胡歌"};
        final boolean [] checkedItems ={true,false,false,false,false,true};
        builder.setMultiChoiceItems(items, checkedItems, new DialogInterface.OnMultiChoiceClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which, boolean isChecked) {
            }
        });

        //把选中的挑选出来
        builder.setPositiveButton("确定", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                StringBuffer sb = new StringBuffer();
                //把选中的条目的数据取出来
                for (int i = 0; i <checkedItems.length ; i++) {
                    //判断下选中的
                    if(checkedItems[i]){
                        String fruit = items[i];
                        sb.append(fruit+"");
                    }
                    Toast.makeText(getApplicationContext(),sb.toString(),Toast.LENGTH_LONG).show();
                    //2.然后把对话框关闭
                    dialog.dismiss();
                }
            }
        });
        builder.show();
    }

    //进度加载框
    public void jindutiao(View view) {
        dialog = new ProgressDialog(this);
        dialog.setTitle("请长按");
        dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);

                dialog.setMax(10);
                dialog.setProgress(0);
                StartTimer();
                dialog.dismiss();

        dialog.show();
    }

    //activity启动后开始计时
    @Override
    protected void onResume() {
        super.onResume();
        //StartTimer();
    }

    //进入后台后计时器暂停
    @Override
    protected void onPause() {
        super.onPause();
        //EndTimer();
    }

    public void StartTimer() {
        //如果timer和timerTask已经被置null了
        if (timer == null&&timerTask==null) {
            //新建timer和timerTask
            timer = new Timer();
            timerTask = new TimerTask() {
                @Override
                public void run() {
                    //每次progress加一
                    progress++;
                    //如果进度条满了的话就再置0,实现循环
                    if (progress == 11) {
                        dialog.dismiss();
                    }
                    //设置进度条进度
                    dialog.setProgress(progress);
                }
            };
            timer.schedule(timerTask, 1000, 1000);
        }
    }

    public void EndTimer()
    {
        timer.cancel();
        timerTask.cancel();
        timer=null;
        timerTask=null;
    }
}

下面看一下ProgressBar的效果,可以看到他是在系统界面展示一个进度条,并不能以弹出对话框的形式来实现进度条的功能。

在这里插入图片描述
这里是ProgressBar的相关代码,就是在xml文件中加了一个ProgressBar的布局,然后在MainActivity中进行展示出来。

<ProgressBar
        android:id="@+id/progress"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="@style/Widget.AppCompat.ProgressBar.Horizontal"
        />
    <TextView
        android:id="@+id/tvProgress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        />

MainActivity中的相关代码:

  public void putongjindutiao(View view) {
        final ProgressBar  bar= (ProgressBar) findViewById(R.id.progress);
        final TextView textView= (TextView) findViewById(R.id.tvProgress);
        new Thread(){
            @Override
            public void run() {
                int i=0;
                while(i<100){
                    i++;
                    try {
                        Thread.sleep(80);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    final int j=i;
                    bar.setProgress(i);
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            textView.setText(j+"%");
                        }
                    });
                }
            }
        }.start();

    }

好了,到这里弹出框已经讲完了,其实结果还是不那么令人满意,我原本是计划通过ProgressBar来实现弹出框进度条的,但是最后发现实现不了,虽然ProgressDialog也可以实现,但是毕竟现在已经不推荐使用这个控件了,谁知道什么时候就会取消呢。如果小伙伴有更好的方法可以实现弹出框进度条,可以留言。

2019-07-31 15:41:58 pkorochi 阅读数 35
  • 2017上半年上午真题55-57

    本视频课程紧跟系统集成项目管理工程师最新考试大纲,按照最新官方教程全新录制,对上午基础知识中的考点做了详细的讲解,对考试中重复考、重点考的知识点进行了专题强化培训,帮助考生掌握考试的重点,提升专业技能...

    112人学习 任铄
    免费试看

前言

定时器的使用是软件开发基础技能,用于延时执行或重复执行某些方法。

我相信大部分人接触iOS的定时器都是从这段代码开始的:

[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(action:) userInfo:nil repeats:YES]

但是你真的会用吗?

正文

iOS定时器


首先来介绍iOS中的定时器

iOS中的定时器大致分为这几类:

  • NSTimer
  • CADisplayLink
  • GCD定时器

NSTimer

使用方法

NSTime定时器是我们比较常使用的定时器,比较常使用的方法有两种:

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

这两种方法都是创建一个定时器,区别是用timerWithTimeInterval:方法创建的定时器需要手动加入RunLoop中。

// 创建NSTimer对象
NSTimer *timer = [NSTimer timerWithTimeInterval:3 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
// 加入RunLoop中
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];

需要注意的是: UIScrollView 滑动时执行的是 UITrackingRunLoopModeNSDefaultRunLoopMode被挂起,会导致定时器失效,等恢复为滑动结束时才恢复定时器。其原因可以查看我这篇《Objective-C RunLoop 详解》中的 “RunLoop 的 Mode“章节,有详细的介绍。

举个例子:

- (void)startTimer{
    NSTimer *UIScrollView = [NSTimer timerWithTimeInterval:0.5 target:self selector:@selector(action:) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)action:(NSTimer *)sender {
    static int i = 0;
    NSLog(@"NSTimer: %d",i);
    i++;
}

timer添加到NSDefaultRunLoopMode中,没0.5秒打印一次,然后滑动UIScrollView.

打印台输出:

可以看出在滑动UIScrollView时,定时器被暂停了。

所以如果需要定时器在 UIScrollView 拖动时也不影响的话,有两种解决方法

  1. timer分别添加到 UITrackingRunLoopModeNSDefaultRunLoopMode
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
[[NSRunLoop mainRunLoop] addTimer:timer forMode: UITrackingRunLoopMode]; 
  1. 直接将timer添加到NSRunLoopCommonModes 中:
[[NSRunLoop mainRunLoop] addTimer:timer forMode: NSRunLoopCommonModes]; 

但并不是都timer所有的需要在滑动UIScrollView时继续执行,比如使用NSTimer完成的帧动画,滑动UIScrollView时就可以停止帧动画,保证滑动的流程性。

若没有特殊要求的话,一般使用第二种方法创建完timer,会自动添加到NSDefaultRunLoopMode中去执行,也是平时最常用的方法。

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(action:) userInfo:nil repeats:YES];

参数:

TimeInterval:延时时间

target:目标对象,一般就是self本身

selector:执行方法

userInfo:传入信息

repeats:是否重复执行

以上创建的定时器,若repeats参数设为NO,执行一次后就会被释放掉;

repeats参数设为YES重复执行时,必须手动关闭,否则定时器不会释放(停止)。

释放方法:

// 停止定时器
[timer invalidate];

实际开发中,我们会将NSTimer对象设置为属性,这样方便释放。

iOS10.0 推出了两个新的API,与上面的方法相比,selector换成Block回调以、减少传入的参数(那几个参数真是鸡肋)。不过开发中一般需要适配低版本,还是尽量使用上面的方法吧。

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

特点

  • 必须加入Runloop

    上面不管使用哪种方法,实际最后都会加入RunLoop中执行,区别就在于是否手动加入而已。

  • 存在延迟

    不管是一次性的还是周期性的timer的实际触发事件的时间,都会与所加入的RunLoop和RunLoop Mode有关,如果此RunLoop正在执行一个连续性的运算,timer就会被延时出发。重复性的timer遇到这种情况,如果延迟超过了一个周期,则会在延时结束后立刻执行,并按照之前指定的周期继续执行,这个延迟时间大概为50-100毫秒.

    所以NSTimer不是绝对准确的,而且中间耗时或阻塞错过下一个点,那么下一个点就pass过去了.

  • UIScrollView滑动会暂停计时

    添加到NSDefaultRunLoopModetimerUIScrollView滑动时会暂停,若不想被UIScrollView滑动影响,需要将 timer 添加再到 UITrackingRunLoopMode 或 直接添加到NSRunLoopCommonModes

CADisplayLink


CADisplayLink官方介绍:

A CADisplayLink object is a timer object that allows your application to synchronize its drawing to the refresh rate of the display

CADisplayLink对象是一个和屏幕刷新率同步的定时器对象。每当屏幕显示内容刷新结束的时候,runloop就会向CADisplayLink指定的target发送一次指定的selector消息, CADisplayLink类对应的 selector 就会被调用一次。

从原理上可以看出,CADisplayLink适合做界面的不停重绘,比如视频播放的时候需要不停地获取下一帧用于界面渲染,或者做动画。

使用方法

创建:

@property (nonatomic, strong) CADisplayLink *displayLink;

self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];  

// 每隔1帧调用一次
self.displayLink.frameInterval = 1;  

[self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];

释放方法:

[self.displayLink invalidate];  

self.displayLink = nil;

当把CADisplayLink对象添加到runloop中后,selector就能被周期性调用,类似于重复的NSTimer被启动了;执行invalidate操作时,CADisplayLink对象就会从runloop中移除,selector调用也随即停止,类似于NSTimer的invalidate方法。

CADisplayLink中有两个重要的属性:

  • frameInterval

    NSInteger类型的值,用来设置间隔多少帧调用一次selector方法,默认值是1,即每帧都调用一次。

  • duration

    CFTimeInterval值为readOnly,表示两次屏幕刷新之间的时间间隔。需要注意的是,该属性在target的selector被首次调用以后才会被赋值。selector的调用间隔时间计算方式是:调用间隔时间 = duration × frameInterval

特点

  • 刷新频率固定

    正常情况iOS设备的屏幕刷新频率是固定60Hz,如果CPU过于繁忙,无法保证屏幕60次/秒的刷新率,就会导致跳过若干次调用回调方法的机会,跳过次数取决CPU的忙碌程度。

  • 屏幕刷新时调用

    CADisplayLink在正常情况下会在每次刷新结束都被调用,精确度相当高。但如果调用的方法比较耗时,超过了屏幕刷新周期,就会导致跳过若干次回调调用机会

  • 适合做界面渲染

    CADisplayLink可以确保系统渲染每一帧的时候我们的方法都被调用,从而保证了动画的流畅性。

GCD定时器


GCD定时器和NSTimer是不一样的,NSTimer受RunLoop影响,但是GCD的定时器不受影响,因为通过源码可知RunLoop也是基于GCD的实现的,所以GCD定时器有非常高的精度。关于GCD的使用可一看看这篇博客

使用方法

创建GCD定时器定时器的方法稍微比较复杂,看下面的代码:

单次的延时调用

NSObject中的performSelector:withObject:afterDelay:以及 performSelector:withObject:afterDelay:inModes: 这两个方法在调用的时候会设置当前 runloop 中 timer ,前者设置的 timerNSDefaultRunLoopMode 运行,后者则可以指定 NSRunLoopmode 来执行。我们上面介绍过 runloop 中 timerUITrackingRunLoopMode 被挂起,就导致了代码就会一直等待 timer 的调度,解决办法在上面也有说明。

不过我们可以用另一套方案来解决这个问题,就是使用GCD中的 dispatch_after 来实现单次的延时调用:

double delayInSeconds = 2.0;
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
        [self someMethod];
    });

循环调用

// 创建GCD定时器
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_source_t _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);

dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), 1.0 * NSEC_PER_SEC, 0); //每秒执行

// 事件回调
dispatch_source_set_event_handler(_timer, ^{
        
    dispatch_async(dispatch_get_main_queue(), ^{
        // 在主线程中实现需要的功能
        
    }
}
    
});

// 开启定时器
dispatch_resume(_timer);

// 挂起定时器(dispatch_suspend 之后的 Timer,是不能被释放的!会引起崩溃)
dispatch_suspend(_timer);

// 关闭定时器
dispatch_source_cancel(_timer);
    

上面代码中要注意的是:

  1. dispatch_source_set_event_handler()中的任务实在子线程中执行的,若需要回到主线程,要调用dispatch_async(dispatch_get_main_queue(), ^{}.
  • dispatch_source_set_timer 中第二个参数,当我们使用 dispatch_time 或者 DISPATCH_TIME_NOW 时,系统会使用默认时钟来进行计时。然而当系统休眠的时候,默认时钟是不走的,也就会导致计时器停止。使用 dispatch_walltime 可以让计时器按照真实时间间隔进行计时.
  • 第三个参数, 1.0 * NSEC_PER_SEC 为每秒执行一次,对应的还有毫秒,分秒,纳秒可以选择.
  • dispatch_source_set_event_handler 这个函数在执行完之后,block 会立马执行一遍,后面隔一定时间间隔再执行一次。而 NSTimer 第一次执行是到计时器触发之后。这也是和 NSTimer 之间的一个显著区别。
  • 挂起(暂停)定时器, dispatch_suspend 之后的 Timer,不能被释放的,会引起崩溃.
  • 创建的timer一定要有dispatch_suspend(_timer)dispatch_source_cancel(_timer)这两句话来指定出口,否则定时器将不执行,若我们想无限循环可将 dispatch_source_cancel(_timer) 写在一句永不执行的if判断语句中。

使用场景


介绍完iOS中的各种定时器,接下来我们来说说这几种定时器在开发中的几种用法。

短信重发倒计时

短信倒计时使我们登录注册常用的功能,一般设置为60s,实现方法如下:

// 计时时间
@property (nonatomic, assign) int timeout;

/** 开启倒计时 */
- (void)startCountdown {
    
    if (_timeout > 0) {
        return;
    }
    
    _timeout = 60;
    
    // GCD定时器
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
    dispatch_source_t _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), 1.0 * NSEC_PER_SEC, 0); //每秒执行
    
    dispatch_source_set_event_handler(_timer, ^{
        
        if(_timeout <= 0 ){// 倒计时结束
            
            // 关闭定时器
            dispatch_source_cancel(_timer);
            
            dispatch_async(dispatch_get_main_queue(), ^{
                
                //设置界面的按钮显示 根据自己需求设置
                [self.sendMsgBtn setTitle:@"发送" forState:UIControlStateNormal];
                
                self.sendMsgBtn.enabled = YES;
                
            });
            
        }else{// 倒计时中
            
            // 显示倒计时结果
            
            NSString *strTime = [NSString stringWithFormat:@"重发(%.2d)", _timeout];
            
            dispatch_async(dispatch_get_main_queue(), ^{
                
                //设置界面的按钮显示 根据自己需求设置
                
                [self.sendMsgBtn setTitle:[NSString stringWithFormat:@"%@",strTime] forState:UIControlStateNormal];
                
                self.sendMsgBtn.enabled = NO;
                
            });
            
            _timeout--;
        }
    });
    
    // 开启定时器
    dispatch_resume(_timer);
    
}

在上面代码中,我们设置了一个60s循环倒计时,当我们向服务器获取短信验证码成功时 调用该方法开始倒计时。每秒刷新按钮的倒计时数,倒计时结束时再将按钮 Title 恢复为“发送”.

有一点需要注意的是,按钮的样式要设置为 UIButtonTypeCustom,否则会出现刷新 Title 时闪烁.

我们可以把这个方法封装一下,方便调用,否则在控制器中写这么一大段代码确实也不优雅。

效果如下:

代码链接

每个几分钟向服务器发送数据

在有定位服务的APP中,我们需要每个一段时间将定位数据发送到服务器,比如每5s定位一次每隔5分钟将再统一将数据发送服务器,这样会处理比较省电。
一般程序进入后台时,定时器会停止,但是在定位APP中,需要持续进行定位,APP在后台时依旧可以运行,所以在后台定时器也是可以运行的。

注:关于iOS后台常驻,可以查看这篇博客

在使用GCD定时的时候发现GCD定时器也可以在后代运行,后来很多人反映GCD定时器不能在后台运行,我测试了下,确实是这样。但是我在项目中的使用的GCD定时器确实是能在后台运行的,是因为我开启 Background Modes 中的后台持续定位,程序在后台依旧可以运行。

创建方法同上面的短信倒计时.

这里我们使用NSTimer来创建一个每个5分钟执行一次的定时器.

#import <Foundation/Foundation.h>

typedef void(^TimerBlock)();

@interface BYTimer : NSObject

- (void)startTimerWithBlock:(TimerBlock)timerBlock;

- (void)stopTimer;

@end

#import "BYTimer.h"

@interface BYTimer ()

@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) TimerBlock timerBlock;

@end

@implementation BYTimer

- (void)startTimerWithBlock:(TimerBlock)timerBlock {

     self.timer = [NSTimer timerWithTimeInterval:300 target:self selector:@selector(_timerAction) userInfo:nil repeats:YES];
     
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    _timerBlock = timerBlock;
    
}

- (void)_timerAction {
    if (self.timerBlock) {
        self.timerBlock();
    }
}

- (void)stopTimer {
    [self.timer invalidate];
}

@end

该接口的实现很简单,就是 NSTimer 创建了一个300s执行一次的定时器,但是要注意定时器需要加入NSRunLoopCommonModes中。

要使定时器在后台能运行,app 就需要在 后台常驻

结语

最后总结一下:

NSTimer 使用简单方便,但是应用条件有限。

CADisplayLink 刷新频率与屏幕帧数相同,用于绘制动画。具体使用可看我封装好的一个 水波纹动画

GCD定时器 精度高,可控性强,使用稍复杂。




转载自:https://www.jianshu.com/p/c167ca4d1e7e
 

2019-11-05 11:28:00 kunpengapple 阅读数 144
  • 2017上半年上午真题55-57

    本视频课程紧跟系统集成项目管理工程师最新考试大纲,按照最新官方教程全新录制,对上午基础知识中的考点做了详细的讲解,对考试中重复考、重点考的知识点进行了专题强化培训,帮助考生掌握考试的重点,提升专业技能...

    112人学习 任铄
    免费试看

基础知识

像素

图像的基本元素。举个例子:将一张图片放到PS中尽可能的放大,那么我们可以看到一个个的小格子,其中每个小格子就是一个像素点,每个像素点有且仅有一个颜色。
像素由四种不同的向量组成,即我们熟悉的RGBA(red,green,blue,alpha)。

位图

位图就是一个像素数组,数组中的每个像素都代表图片中的一个点。我们经常用到的JPEG和PNG图片就是位图。(压缩过的图片格式)。

帧缓冲区

帧缓冲区(显存):是由像素组成的二维数组,每一个存储单元对应屏幕上的一个像素,整个帧缓冲对应一帧图像即当前屏幕画面。我们知道iOS设备屏幕是一秒刷新60次,如果帧缓冲区的内容有改变,那么我们看到的屏幕显示内容就会改变。

图片处理的过程知识

从图片文件把 图片数据的像素拿出来(RGBA), 对像素进行操作, 进行一个转换(Bitmap (GPU))
修改完之后,还原(图片的属性 RGBA,RGBA (宽度,高度,色值空间,拿到宽度和高度,每一个画多少个像素,画多少行))

iOS图片显示的流程

一张图片从磁盘中显示到屏幕上过程大致如下:从磁盘加载图片信息、解码二进制图片数据为位图、通过 CoreAnimation 框架处理最终绘制到屏幕上

  1. 假设我们使用 +imageWithContentsOfFile: 方法从磁盘中加载一张图片,这个时候的图片并没有解压缩;
  2. 然后将生成的 UIImage 赋值给 UIImageView ;
  3. 接着一个隐式的 CATransaction 捕获到了 UIImageView 图层树的变化;
  4. 在主线程的下一个 run loop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,而受图片是否字节对齐等因素的影响,这个 copy 操作可能会涉及以下部分或全部步骤:
  5. 分配内存缓冲区用于管理文件 IO 和解压缩操作;
  6. 将文件数据从磁盘读到内存中;
  7. 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;
  8. 最后 Core Animation 使用未压缩的位图数据渲染 UIImageView 的图层。
    在上面的步骤中,我们提到了图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。
加载优化

对于加载过程,若文件过大或加载频繁影响了帧率(比如列表展示大图),可以使用异步方式加载图片,减少主线程的压力,代码大致如下:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
UIImage *image = [UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"testImage" ofType:@"jpeg"]];
dispatch_async(dispatch_get_main_queue(), ^{
//业务
});
});

ImageIO 核心

ImageIO框架提供了读取与写入图片数据的基本方法,使用它可以直接获取到图片文件的内容数据,ImageIO框架中包含6个头文件,其中完成主要功能的是前两个头文件中定义的方法:

1.CGImageSource.h:负责读取图片数据。

2.CGImageDestination.h:负责写入图片数据。

3.CGImageMetadata.h:图片文件元数据类。

4.CGImageProperties:定义了框架中使用的字符串常量和宏。

5.ImageIOBase.h:预处理逻辑,无需关心。

  1. CGImageSource.h:负责读取图片数据。

CGImageSource类的主要作用是用来读取图片数据,在平时开发中,关于图片我们使用的最多的可能是UIImage类,UIImage是iOS系统UI系统中用于构建图像对象的类,但是其中只有图像数据,实际上一个图片文件中存储的除了图片数据外,还有一些地理位置、设备类型、时间等信息,除此之外,一个图片文件中可能存储的也不只一张图像(例如gif文件)。CGImageSource就是这样的一个抽象图片数据示例,从其中可以获取到我们所关心的所有数据。
读取图片文件数据,并将其展示在视图的简单代码示例如下:

//获取图片文件路径
NSString * path = [[NSBundle mainBundle]pathForResource:@"timg" ofType:@"jpeg"];
NSURL * url = [NSURL fileURLWithPath:path];
CGImageRef myImage = NULL;
CGImageSourceRef myImageSource;
//通过文件路径创建CGImageSource对象
myImageSource = CGImageSourceCreateWithURL((CFURLRef)url, NULL);
//获取第一张图片
myImage = CGImageSourceCreateImageAtIndex(myImageSource,
0,
NULL);
CFRelease(myImageSource);
UIImageView * image = [[UIImageView alloc]initWithFrame:CGRectMake(0, 0, 200, 200)];
image.image = [UIImage imageWithCGImage:myImage];
[self.view addSubview:image];

上面的示例代码采用的是本地的一个素材文件,当然通过网络图片链接也是可以创建CGImageSource独享的。除了通过URL链接的方式创建对象,ImageIO框架中还提供了两种方法,解析如下:

//通过数据提供器创建CGImageSource对象
/*
CGDataProviderRef是CoreGraphics框架中的一个数据读取类,其也可以通过Data数据,URL和文件名来创建
*/
CGImageSourceRef __nullable CGImageSourceCreateWithDataProvider(CGDataProviderRef __nonnull provider, CFDictionaryRef __nullable options);
//通过Data数据创建CGImageSource对象
CGImageSourceRef __nullable CGImageSourceCreateWithData(CFDataRef __nonnull data, CFDictionaryRef __nullable options);
  1. CGImageDestination.h:负责写入图片数据
    CGImageSource是图片文件数据的抽象对象,而CGImageDestination的作用则是将抽象的图片数据写入指定的目标中。将图片写成文件示例如下:
//创建存储路径
NSArray *paths=NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES);
NSString *newPath = [paths.firstObject stringByAppendingPathComponent:[NSString stringWithFormat:@"image.png"]];
CFURLRef URL =  CFURLCreateWithFileSystemPath (
kCFAllocatorDefault,
(CFStringRef)newPath,
kCFURLPOSIXPathStyle, 
false);
//创建CGImageDestination对象
CGImageDestinationRef myImageDest = CGImageDestinationCreateWithURL(URL,CFSTR("public.png"), 1, NULL);
UIImage * image = [UIImage imageNamed:@"timg.jpeg"];
//写入图片
CGImageDestinationAddImage(myImageDest, image.CGImage, NULL);
CGImageDestinationFinalize(myImageDest);
CFRelease(myImageDest);

更多详情查看ImageIO更多的详情

YYImage 结构

通过 YYImage 源码可以按照其与 UIKit 的对应关系划分为三个层级:

层级: UIKit YYImage
图像层 UIImage YImage,YYFrameImage,YYSpriteSheetImage
视图层 UIImageView YYAnimatedImageView
编/解码层 ImageIO.framework YYImageCoder
  • 图像层,把不同类型的图像信息封装成类并提供初始化和其他便捷接口。
  • 视图层,负责图像层内容的显示(包含动态图像的动画播放)工作。
  • 编/解码层,提供图像底层支持,使整个框架得以支持市场主流的图片格式。
YYImage 结构图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8we4MVk4-1572924259122)(evernotecid://DC8466E2-282C-4B26-91B9-7D010D5B0CAB/appyinxiangcom/6357986/ENResource/p873)]

YYImage 类

该类对UIImage进行拓展,支持 WebP、APNG、GIF 格式的图片解码,为了避免产生全局缓存,重载了imageNamed:方法:

+ (YYImage *)imageNamed:(NSString *)name {
...
NSArray *exts = ext.length > 0 ? @[ext] : @[@"", @"png", @"jpeg", @"jpg", @"gif", @"webp", @"apng"];
NSArray *scales = _NSBundlePreferredScales();
for (int s = 0; s < scales.count; s++) {
scale = ((NSNumber *)scales[s]).floatValue;
NSString *scaledName = _NSStringByAppendingNameScale(res, scale);
for (NSString *e in exts) {
path = [[NSBundle mainBundle] pathForResource:scaledName ofType:e];
if (path) break;
}
if (path) break;
}
...
return [[self alloc] initWithData:data scale:scale];
}

initWithData 核心代码

YYImageDecoder *decoder = [YYImageDecoder decoderWithData:data scale:scale];  //用来获取图片组里每个图片的属性:每张图片停留的时间等其他属性、循环次数、图片方向、图片宽、高,并保存到frames这么个数组里

YYImageFrame *frame = [decoder frameAtIndex:0 decodeForDisplay:YES];  //图片解压
YYImageCoder 编解码

该文件中主要包含了YYImageFrame图片帧信息的类、YYImageDecoder解码器、YYImageEncoder编码器。

1、解码核心代码

GImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
...
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// BGRA8888 (premultiplied) or BGRX8888
// same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
if (!context) return NULL;
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
CGImageRef newImage = CGBitmapContextCreateImage(context);
CFRelease(context);
return newImage;
...
}

解码核心代码是将CGImageRef数据转化为位图数据:

使用CGBitmapContextCreate()创建图片上下文。

使用CGContextDrawImage()将图片绘制到上下文中。

使用CGBitmapContextCreateImage()通过上下文生成图片。

APNG的处理

PNG的文件结构

PNG文件结构很简单,主要有数据块(Chunk Block)组成,最少包含4个数据块。PNG标识符 PNG数据块(IHDR) PNG数据块(其他类型数据块) … PNG结尾数据块(IEND)

PNG标识符,其文件头位置总是由位固定的字节来描述的:
十进制数
137 80 78 71 13 10 26 10
十六进制数
89 50 4E 47 0D 0A 1A 0A

一个标准的PNG文件结构应该如下:

内容 内容 内容 内容
PNG文件标志 PNG数据块 …… PNG数据块

PNG数据块 …… PNG数据块

PNG文件格式中的数据块

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-r09lBk40-1572924259124)(evernotecid://DC8466E2-282C-4B26-91B9-7D010D5B0CAB/appyinxiangcom/6357986/ENResource/p871)]

PNG 由 4 部分组成,首先以 PNG Signature(PNG签名块)开头,紧接着一个 IHDR(图像头部块),然后是一个或多个的 IDAT(图像数据块),最终以 IEND(图像结束块)结尾。

数据块结构

PNG文件中,每个数据块由4个部分组成,如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tiUFHiic-1572924259131)(evernotecid://DC8466E2-282C-4B26-91B9-7D010D5B0CAB/appyinxiangcom/6357986/ENResource/p872)]

APNG 的组成

APNG 规范引入了三个新大块,分别是:acTL(动画控制块)、fcTL(帧控制块)、fdAT(帧数据块),下图是三个独立的 PNG 文件组成 APNG 的示意图。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9s0RXo9u-1572924259132)(evernotecid://DC8466E2-282C-4B26-91B9-7D010D5B0CAB/appyinxiangcom/6357986/ENResource/p870)]

  • acTL 块必须在第一个 IDAT 块之前,用于告诉解析器这是一个动画 PNG,包含动画帧总数和循环次数的信息
  • fcTL 块是每一帧都必须的,出现在 IDAT 或 fdAT 之前,包含顺序号、宽高、帧位置、延时等信息
  • fdAT 块与 IDAT 块有着相同的结构,除了 fcTL 中的顺序号

从图中可以发现第一帧与后面两帧不同,那是因为第一帧 APNG 文件存储的为一个正常的 PNG 数据块,对于不支持 APNG 的浏览器或软件,只会显示 APNG 文件的第一帧,忽略后面附加的动画块,这也是为什么 APNG 能向下兼容 PNG 的原因。

APNG

更多详细的请参考
http://web.jobbole.com/88847/

APNG 代码逻辑

通过以下方法对图片数据进行解压获取apng的信息
yy_png_info *apng = yy_png_info_create(_data.bytes, (uint32_t)_data.length); //data 图片压缩的数据
首先读取apng的信息

// parse png chunks
uint32_t offset = 8;
uint32_t chunk_num = 0;  //数据块数量
uint32_t chunk_capacity = chunk_realloc_num;  //内存区域容量
uint32_t apng_loop_num = 0;   //循环次数
int32_t apng_sequence_index = -1;   //序号
int32_t apng_frame_index = 0;   //frame的编号
int32_t apng_frame_number = -1;   //frame的数量

然后遍历所有的数据块,只针对IDAT、fcTL、acTL、FdAT数据块进行处理,最终这个for 循环得出了info->apng_frames(这么一个指针),它指向所有的frame数据.

for (int32_t i = 0; i < info->chunk_num; i++) {  
yy_png_chunk_info *chunk = info->chunks + i;
switch (chunk->fourcc) {
case YY_FOUR_CC('I', 'D', 'A', 'T'): {
if (info->apng_shared_insert_index == 0) {
info->apng_shared_insert_index = i;
}
if (first_frame_is_cover) {
yy_png_frame_info *frame = info->apng_frames + frame_index;
frame->chunk_num++;
frame->chunk_size += chunk->length + 12;
}
} break;
case YY_FOUR_CC('a', 'c', 'T', 'L'): {
} break;
case YY_FOUR_CC('f', 'c', 'T', 'L'): {
frame_index++;
yy_png_frame_info *frame = info->apng_frames + frame_index;
frame->chunk_index = i + 1;
yy_png_chunk_fcTL_read(&frame->frame_control, data + chunk->offset + 8);
} break;
case YY_FOUR_CC('f', 'd', 'A', 'T'): {
yy_png_frame_info *frame = info->apng_frames + frame_index;
frame->chunk_num++;
frame->chunk_size += chunk->length + 12;
} break;
default: {
*shared_chunk_index = i;
shared_chunk_index++;
info->apng_shared_chunk_size += chunk->length + 12;
info->apng_shared_chunk_num++;
} break;
}
}

通过YYImageDecoder 来读取图片组里每个图片的属性:每张图片停留的时间等其他属性、循环次数、图片方向、图片宽、高,并保存到frames这么个数组里 通过CGImageSourceRef 解压出图片数据生成UIImage

YYAnimatedImageView

YYAnimatedImageView类通过YYImage、YYFrameImage、YYSpriteSheetImage实现的协议方法拿到帧图片数据和相关信息进行动画展示。

1.初始化流程

该类重写了一系列方法让它们都走自定义配置:

- (void)setImage:(UIImage *)image {
if (self.image == image) return;
[self setImage:image withType:YYAnimatedImageTypeImage];
}
- (void)setHighlightedImage:(UIImage *)highlightedImage {
if (self.highlightedImage == highlightedImage) return;
[self setImage:highlightedImage withType:YYAnimatedImageTypeHighlightedImage];
}

setImage:withType:方法就是将这些图片数据赋值给super.image等,该方法最后会走imageChanged方法,这才是主要的初始化配置:

- (void)imageChanged {
YYAnimatedImageType newType = [self currentImageType];
id newVisibleImage = [self imageForType:newType];
NSUInteger newImageFrameCount = 0;
BOOL hasContentsRect = NO;
... //省略判断是否是 SpriteSheet 类型来源

/*1、若上一次是 SpriteSheet 类型而当前显示的图片不是,
归位 self.layer.contentsRect */
if (!hasContentsRect && _curImageHasContentsRect) {
if (!CGRectEqualToRect(self.layer.contentsRect, CGRectMake(0, 0, 1, 1)) ) {
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.layer.contentsRect = CGRectMake(0, 0, 1, 1);
[CATransaction commit];
}
}
_curImageHasContentsRect = hasContentsRect;

/*2、SpriteSheet 类型时,通过`setContentsRect:forImage:`方法
配置self.layer.contentsRect */
if (hasContentsRect) {
CGRect rect = [((UIImage*) newVisibleImage) animatedImageContentsRectAtIndex:0];
[self setContentsRect:rect forImage:newVisibleImage];
}

/*3、若是多帧的图片,通过`resetAnimated`方法初始化显示多帧动画需要的配置;
然后拿到第一帧图片调用`setNeedsDisplay `绘制出来 */
if (newImageFrameCount > 1) {
[self resetAnimated];
_curAnimatedImage = newVisibleImage;
_curFrame = newVisibleImage;
_totalLoop = _curAnimatedImage.animatedImageLoopCount;
_totalFrameCount = _curAnimatedImage.animatedImageFrameCount;
[self calcMaxBufferCount];
}
[self setNeedsDisplay];
[self didMoved];
}
2.动画启动和结束的时机
- (void)didMoved {
if (self.autoPlayAnimatedImage) {
if(self.superview && self.window) {
[self startAnimating];
} else {
[self stopAnimating];
}
}
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self didMoved];
}
- (void)didMoveToSuperview {
[super didMoveToSuperview];
[self didMoved];
}

在didMoveToWindow和didMoveToSuperview周期方法中尝试启动或结束动画,不需要在组件内部特意的去调用就能实现自动的播放和停止。而didMoved方法中判断是否开启动画写了个self.superview && self.window,意味着YYAnimatedImageView光有父视图还不能开启动画,还需要展示在window上才行。

3.异步解压

YYAnimatedImageView有个队列_requestQueue = [[NSOperationQueue alloc] init];
_requestQueue.maxConcurrentOperationCount = 1;变量NSOperationQueue *_requestQueue;

_requestQueue = [[NSOperationQueue alloc] init];
_requestQueue.maxConcurrentOperationCount = 1;

可以看出_requestQueue是一个串行的队列,用于处理解压任务。

YAnimatedImageViewFetchOperation继承自NSOperation,重写了main方法自定义解压任务。它是结合变量_requestQueue;来使用的:

- (void)main {
...
for (int i = 0; i < max; i++, idx++) {
@autoreleasepool {
...
if (miss) {
UIImage *img = [_curImage animatedImageFrameAtIndex:idx];
img = img.yy_imageByDecoded;
if ([self isCancelled]) break;
LOCK_VIEW(view->_buffer[@(idx)] = img ? img : [NSNull null]);
view = nil;
}
}
}
}

关键代码中,animatedImageFrameAtIndex方法便会调用解码,后面yy_imageByDecoded属性是对解码成功的第二重保证,view->_buffer[@(idx)] = img是做缓存。

可以看到作者经常使用if ([self isCancelled]) break(return);判断返回,因为在执行NSOperation任务的过程中该任务可能会被取消。
for循环中使用@autoreleasepool避免同一 RunLoop 循环中堆积过多的局部变量。
由此,基本可以保证解压过程是在_requestQueue串行队列执行的,不会影响主线程。

4.缓存机制

YYAnimatedImageView有如下几个变量:

NSMutableDictionary *_buffer; ///< frame buffer
BOOL _bufferMiss; ///< whether miss frame on last opportunity
NSUInteger _maxBufferCount; ///< maximum buffer count
NSInteger _incrBufferCount; ///< current allowed buffer count (will increase by step)

_buffter就是缓存池,在_YYAnimatedImageViewFetchOperation私有类的main函数中有给_buffer赋值,作者还限制了最大缓存数量。

缓存限制计算
- (void)calcMaxBufferCount {
int64_t bytes = (int64_t)_curAnimatedImage.animatedImageBytesPerFrame;
if (bytes == 0) bytes = 1024;

int64_t total = _YYDeviceMemoryTotal();
int64_t free = _YYDeviceMemoryFree();
int64_t max = MIN(total * 0.2, free * 0.6);
max = MAX(max, BUFFER_SIZE);
if (_maxBufferSize) max = max > _maxBufferSize ? _maxBufferSize : max;
double maxBufferCount = (double)max / (double)bytes;
if (maxBufferCount < 1) maxBufferCount = 1;
else if (maxBufferCount > 512) maxBufferCount = 512;
_maxBufferCount = maxBufferCount;
}

该方法并不复杂,通过_YYDeviceMemoryTotal()拿到内存总数乘以 0.2,通过_YYDeviceMemoryFree()拿到剩余的内存乘以 0.6,然后取它们最小值;之后通过最小的缓存值BUFFER_SIZE和用户自定义的_maxBufferSize属性综合判断。

动画的核心方法

该类使用CADisplayLink做计时任务,显示系统每帧回调都会触发,所以默认大致是 60 次/秒。CADisplayLink的特性决定了它非常适合做和帧率相关的 UI 逻辑。

- (void)step:(CADisplayLink *)link {

UIImage <YYAnimatedImage> *image = _curAnimatedImage;
NSMutableDictionary *buffer = _buffer;
UIImage *bufferedImage = nil;
NSUInteger nextIndex = (_curIndex + 1) % _totalFrameCount;
BOOL bufferIsFull = NO;

if (!image) return;
if (_loopEnd) { // view will keep in last frame
[self stopAnimating];
return;
}

NSTimeInterval delay = 0;
if (!_bufferMiss) {  //下一张图片缺失,那么此时_bufferMiss=YES
_time += link.duration;
delay = [image animatedImageDurationAtIndex:_curIndex];  //第一张图片的停留时间

if (_time < delay) return;  //如果累积时间小于停留时间 啥也不做,返回

_time -= delay;  //累积时间大于停留时间了,那么换下一张图片;

if (nextIndex == 0) {
_curLoop++;
if (_curLoop >= _totalLoop && _totalLoop != 0) {
_loopEnd = YES;
[self stopAnimating];
[self.layer setNeedsDisplay]; // let system call `displayLayer:` before runloop sleep
return; // stop at last frame
}
}
delay = [image animatedImageDurationAtIndex:nextIndex];  //获取到下一帧的图片停留时间

if (_time > delay) _time = delay; // do not jump over frame  下一帧图片的停留时间小于累积时间
}
LOCK(

//获取到下一张图片
bufferedImage = buffer[@(nextIndex)];
if (bufferedImage) {
if ((int)_incrBufferCount < _totalFrameCount) {
[buffer removeObjectForKey:@(nextIndex)];
}
[self willChangeValueForKey:@"currentAnimatedImageIndex"];
_curIndex = nextIndex;
[self didChangeValueForKey:@"currentAnimatedImageIndex"];
_curFrame = bufferedImage == (id)[NSNull null] ? nil : bufferedImage;
if (_curImageHasContentsRect) {
_curContentsRect = [image animatedImageContentsRectAtIndex:_curIndex]; //sprite sheet image里的contentsRect数组里的第_curIndex个数据
[self setContentsRect:_curContentsRect forImage:_curFrame];
}
nextIndex = (_curIndex + 1) % _totalFrameCount;
_bufferMiss = NO;
if (buffer.count == _totalFrameCount) {
bufferIsFull = YES;
}
} else {
_bufferMiss = YES;  //下一张图片缺少了
}
)//LOCK

//若图片存在
if (!_bufferMiss) {
[self.layer setNeedsDisplay]; // let system call `displayLayer:` before runloop sleep
}

//此时线程池里没有线程开启
if (!bufferIsFull && _requestQueue.operationCount == 0) { // if some work not finished, wait for next opportunity
_YYAnimatedImageViewFetchOperation *operation = [_YYAnimatedImageViewFetchOperation new];
operation.view = self;
operation.nextIndex = nextIndex;
operation.curImage = image;
[_requestQueue addOperation:operation];
}
}

具体思路

  1. 创建一个CADisplayLink 定时器,并添加到RunloopCommonMode下, 跟屏幕刷新相关 调用时间:1/60S = 16.7ms,到了时间点就调用一个方法:step方法
  2. 看CADisplayLink时间的累积,是不是达到临界值,达到了我就可以更新下一张图片(开辟一个线程,获取到下一张解压缩的图片)
  3. 更新图片的时候(获取到下一张图片,图片不存在,开启线程来获取,直到获取到,然后展示),只要把2步骤的解压缩图片赋值到界面上,就能展示出来了

网络技术之BGP

阅读数 5220