0%

使用系统设置,在iOS11.4上会出现点击时,文字变大,颜色变为蓝色.所以使用按钮还是靠谱些.

1
2
3
UIBarButtonItem *allReadItem = [[UIBarButtonItem alloc] initWithTitle:@"全部已读" style:UIBarButtonItemStylePlain target:self action:@selector(allReadItemDidClicked:)];
[allReadItem setTitleTextAttributes:@{NSFontAttributeName : [UIFont pingFangSCWithSize:14], NSForegroundColorAttributeName : [UIColor colorWithHexString:@"#333333"]} forState:UIControlStateNormal];
self.navigationItem.rightBarButtonItem = allReadItem;

解决办法使用自定义按钮:

1
2
3
4
5
6
7
8
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.titleLabel.font = [UIFont pingFangSCWithSize:14];
[btn setTitle:@"全部已读" forState:UIControlStateNormal];
[btn setTitleColor:[UIColor colorWithHexString:@"#333333"] forState:UIControlStateNormal];
[btn addTarget:self action:@selector(allReadItemDidClicked:) forControlEvents:UIControlEventTouchUpInside];
[btn sizeToFit]; //需要设置一下否则在iOS10会没有大小.
UIBarButtonItem *allReadItem = [[UIBarButtonItem alloc] initWithCustomView:btn];
self.navigationItem.rightBarButtonItem = allReadItem;

实验设备:iPhone5s,10.3.3系统.

应用状态:

1
2
3
4
5
typedef NS_ENUM(NSInteger, UIApplicationState) {
UIApplicationStateActive, //0
UIApplicationStateInactive, //1
UIApplicationStateBackground //2
} NS_ENUM_AVAILABLE_IOS(4_0);

APP未启动,点击图片执行顺序:

1
2
3
默认	23:07:51.443320 +0800	pictureInMemory	-[AppDelegate application:didFinishLaunchingWithOptions:]  应用状态:1

默认 23:07:51.515796 +0800 pictureInMemory -[AppDelegate applicationDidBecomeActive:] 应用状态:0

在应用处于UIApplicationStateActive时,分别进行如下操作:

1.按下home键

1
2
默认	23:09:50.067690 +0800	pictureInMemory	-[AppDelegate applicationWillResignActive:]  应用状态:0
默认 23:09:50.602224 +0800 pictureInMemory -[AppDelegate applicationDidEnterBackground:] 应用状态:2

1.1处于后台时,再点击图标

1
2
默认	23:11:34.897752 +0800	pictureInMemory	-[AppDelegate applicationWillEnterForeground:]  应用状态:2
默认 23:11:35.218295 +0800 pictureInMemory -[AppDelegate applicationDidBecomeActive:] 应用状态:0
阅读全文 »

问题:APP进入后台后大概三分钟就被系统kill掉了,再次进入都是重新启动,对用户体验有一定影响.

为了解决这个问题,首先来了解一下一个APP的生命周期.

一、iOS APP生命周期

一个iOS APP生命周期大概有五种状态.

如下图:

  • Not running:app还没运行.
  • Inactive:app运行在前台但不能接收事件.
  • Active:app运行在前台并且能够接收事件.
  • Backgroud:程序在后台并且能够执行代码,大多数程序进入这个状态后会在这个状态上停留一会(一般就几秒钟,如果有申请后台运行大概会停留180s).时间到之后会进入挂起状态(Suspended).但有几种特定类型的APP可以长期处于Backgroud状态.
  • Suspended:程序在后台不能执行代码.系统会自动把程序变成这个状态而且不会发出通知.当挂起时,程序还是停留在内存中的,当系统内存低时,系统就把挂起的程序清除掉,为前台程序提供更多的内存,并且不会发出任何通知.

从上面的图可以看出如果挂起的应用所占用的内存还没有被系统回收,则点击APP图标时,应用会进入后台,然后进入前台,此时看到的仍然是上一次的界面.但当挂起的应用所占用的内存已经被系统回收,则点击APP图标时,相当于重新启动.

了解了APP的生命周期后,可以看到挂起的应用只有在内存紧张的情况下才会被系统清理,这种情况是我们无法控制的.因此出现上述bug极有可能是程序还在运行时就被系统kill掉了.系统主动kill掉一个APP的情况并不多见,主要有如下几种:

  • APP出现闪退
  • APP内存峰值过高
  • APP长时间没有响应事件
  • 某些特殊API的错误使用
阅读全文 »

清理缓存

主要是删除/Caches目录下的内容.如果其他路径上的文件可以删除也可以包含进来.

iOS 文件系统也得了解一下
iOS 文件系统

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
+ (NSUInteger)getCachesSize {
NSUInteger size = 0;
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
NSDirectoryEnumerator *fileEnumerator = [fileManager enumeratorAtPath:cachePath]; //枚举指定目录的所有文件包括文件夹.
for (NSString *fileName in fileEnumerator) {
if ([fileName isEqualToString:@"Snapshots"]) { //"/Snapshots"是系统生成的,删了也会再自动创建,所以就不计算了.
continue;
}
NSString *filePath = [cachePath stringByAppendingPathComponent:fileName];
NSDictionary *attrs = [fileManager attributesOfItemAtPath:filePath error:nil];
// NSLog(@"fileType:%@, fileName:%@", [attrs objectForKey:NSFileType], fileName);
// NSLog(@"filePath:%@", filePath);
size += [attrs fileSize];
}
return size;
}

+ (void)clearCachesWithCompletionHandler:(void(^)(void))completionHandler {

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString * cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
NSDirectoryEnumerator *fileEnumerator = [fileManager enumeratorAtPath:cachePath];
for (NSString *fileName in fileEnumerator) {
if ([fileName isEqualToString:@"Snapshots"]) {
continue;
}
NSString *filePath = [cachePath stringByAppendingPathComponent:fileName];
BOOL removeRs = [fileManager removeItemAtPath:filePath error:nil];
if (!removeRs) {
NSDictionary *attrs = [fileManager attributesOfItemAtPath:filePath error:nil];
DLog(@"移除文件失败:fileType:%@, fileName:%@", [attrs objectForKey:NSFileType], fileName);
}
}

dispatch_async(dispatch_get_main_queue(), ^{
if (completionHandler) {
completionHandler();
}
});
});
}

/Library/Caches/Snapshots/com.AppName/UIApplicationAutomaticSnapshotDefault-LandscapeLeft.png
“/Snapshots”该目录保存的是按下home键时系统对当前屏幕生成的一张快照.

参考:IOS BACKGROUND SCREEN CACHING

- (BOOL)removeItemAtPath:(NSString *)path error:(NSError * _Nullable *)error;会移除文件或目录

iOS崩溃日志堆栈解析

本文只为自己查看方便.

.dSYM 文件

我们调试的 symbols 会包含在这个文件中。

每次编译项目的时候都会生成一个新的 dSYM 文件,我们应该保存每个正式发布版本的 dSYM 文件,以备我们更好的调试问题。一般是在我们 Archives 时保存对应的版本文件的,里面也有对应的 .dSYM 和 .app 文件。

符号解析

注意:.dSYM 、.crash文件,二者的UUID必须一致解析才有意义.

获取 UUID

.crash UUID

grep --after-context=2 "Binary Images:" *crash

阅读全文 »

实验设备:iPhone7plus iOS12.1

问题:发现市面上很多APP进入后台后,过了16min甚至20min后,再次点击APP,都能够保持在原来的页面.而我们自己的APP,进入后台过个三分钟就被kill掉了,再次进入都是重新启动.
网易新闻APP,主站APP,喜马拉雅APP都不会.为什么我们的APP在挂起后会这么快被系统从内存中清理?而别的APP却不会.

目前app,没有做任何特别处理,申请的权限也很常规,如下:

APP后台模式

解决:经过不断的分析,最终发现是代码中在向系统申请后台运行时间错误使用导致,在调用beginBackgroundTaskWithExpirationHandler后,没有配对调用endBackgroundTask:.

吐槽:有时候工程一大,出现一些BUG真的很难分析原因.

向系统申请后台运行时间

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
- (void)backgroundDoSomethings {
UIApplication *application = [UIApplication performSelector:@selector(sharedApplication)];
__block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
// Clean up any unfinished task business by marking where you
// stopped or ending the task outright.
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];

// Start the long-running task and return immediately.
[self doSomethingWithCompletionBlock:^{
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];
}

- (void)doSomethingWithCompletionBlock:(void (^)(void))completion
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

NSLog(@"开始睡");
sleep(175); //200x 170√ 180x 175√
NSLog(@"睡醒");

if (completion) {
completion();
}
});
}

实测运行的时间大致在175s左右,小于180s.即通过beginBackgroundTaskWithExpirationHandler:方法可以向系统申请大约180s的后台运行时间.

相关方法:

阅读全文 »

有时候我们需要该元素不在该数组中时才添加.

由于使用场景还算比较多,所以自己简单封装了一下.这里用block作为过滤条件,使用还是很方便的.

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
@interface NSMutableArray (Tool)


/**
数组去重添加单个元素

@param anObject 被添加的元素
@param filterBlk 过滤条件.element为原数组的元素,anObject为被添加的元素.
*/
- (void)addObject:(id)anObject filter:(BOOL (^)(id element, id anObject))filterBlk;


/**
数组去重添加一个数组里的元素

@param otherArray 被添加的数组
@param filterBlk 过滤条件.element为原数组的元素,anObject为被添加的数组的元素.
*/
- (void)addObjectsFromArray:(NSArray *)otherArray filter:(BOOL (^)(id element, id anObject))filterBlk;

@end

@implementation NSMutableArray (Tool)

- (void)addObject:(id)anObject filter:(BOOL (^)(id, id))filterBlk
{
if (anObject == nil) {
return;
}

BOOL isExist = NO;
for (id obj in self) {
if (!filterBlk(obj, anObject)) {
isExist = YES;
break;
}
}

if (!isExist) {
[self addObject:anObject];
}
}

- (void)addObjectsFromArray:(NSArray *)otherArray filter:(BOOL (^)(id, id))filterBlk
{
if (otherArray.count == 0) {
return;
}

for (id element in otherArray) {
[self addObject:element filter:filterBlk];
}
}

@end

使用如下:

1
2
3
4
5
6
7
8
9
10
11
NSMutableArray *mArr = [@[@"3", @"4", @"5", @"33", @"7"] mutableCopy];
[mArr addObject:@"57" filter:^BOOL(NSString *element, NSString *anObject) {
return ![element isEqualToString:anObject]; //不等于其中任何一个元素才添加
}];
NSLog(@"%@", mArr);

NSArray *arr = @[@"1", @"2", @"3", @"13", @"7"];
[mArr addObjectsFromArray:arr filter:^BOOL(NSString *element, NSString *anObject) {
return ![element isEqualToString:anObject]; //不等于其中任何一个元素才添加
}];
NSLog(@"%@", mArr);

打印如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2018-09-28 20:52:39.060469+0800 testDemo[94995:18270135] (
3,
4,
5,
33,
7,
57
)
2018-09-28 20:52:39.060571+0800 testDemo[94995:18270135] (
3,
4,
5,
33,
7,
57,
1,
2,
13
)

也可以使用模型中的某个属性作为判断条件:

1
2
3
[audienceList addObjectsFromArray:self.audienceList filter:^BOOL(ZAEAudienceModel *element, ZAEAudienceModel *anObject) {
return ![element.userID isEqualToString:anObject.userID];
}];

早些时候看过一些分析定时器内存方面的文章,但在遇到这个bug前我是不屑的。不就是定时器强引用ViewController,而ViewController再用strong属性去引用定时器,必然会导致循环引用么。解决办法也很简单,只需使用weak属性去引用定时器即可。然而这一次的经历却证明我还是图样,我单知道使用weak属性不会导致循环引用,我没注意到此时的定时器在无形中延长了ViewController的生命周期。这就为这个bug埋下了隐患。

记录一下bug的解决总是有必要的。

下午的一次自测中,偶尔发现观众端听不到主播端的声音。我先是诧异,接着是很不安,因为墨菲定律告诉我们:如果你担心某种情况发生,那么它就更有可能发生。最开始以为是主播端的问题,便仔细检查了主播端的代码,又加入了另一台手机设置为观众,作为对照。运行之后发现对照组是好的,但刚才那台手机的问题仍然偶现。这就说明主播端的推流是没问题的。接下来的工作便是在问题手机上尽可能找到复现的操作,以便根据操作路径定位大致原因。在某次频繁进入离开直播房间时,APP直接卡死了,再无任何交互的响应。问题开始变得严重,时间也一分一秒的流逝在这一次次的调试中,一晃下班时间快到了,周围开始变的嘈杂,安卓兄弟开始催我下班还说要带我上王者但我是不信的。我整理了下东西,但又不想在节前留下些许问题,便又坐了回去。等到周围开始安静时,夕阳已经西下。我努力回想之前的操作,发现只在直播预约状态下,问题才会重现。于是在页面的dealloc函数中打好断点,点击返回,果然函数没有被调用。这说明页面依然被某个对象持有而没有释放。在检查了所有Block回调都使用的是weakSelf后,最后只剩下定时器了。

问题的根源算是找到了。原来在直播预约状态下会启动一个定时器,但在点击返回时忘记invalid定时器了。这让定时器延长了ViewController的生命周期。加上invalid后,问题搞定,收工。回家的路上碰巧遇到了K君,便给K君讲述了这个问题,K君听后哈哈大笑说:加上invalid只能解决这一次的bug,却不能避免下一次又忘记,而且根据页面dealloc函数里逻辑的不同,bug的外在表现形式也必然不同,到时候又得花费不少的时间找bug啊。闻道于朝,不禁感叹K君的身经百战。

回到家后,打开谷歌又搜到了早些时候看过的那些文章,感慨颇多。系统的NSTimer简单却又不那么简单:

  1. 不注意使用的话有循环引用的隐患。因为NSTimer会强引用target,如果target再强引用NSTimer那么就会发生循环引用。比如:

    1
    2
    3
    4
    5
    - (void)test_normal_timer1 {
    self.normalStrongTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(doSome) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.normalStrongTimer forMode:NSRunLoopCommonModes];
    [self.normalStrongTimer fire];
    }

    上面的代码就会发生循环引用。此时即使页面返回,但是因为循环引用,ViewController的dealloc将不会被执行,因此invalidate不能放在dealloc里,只能在其他时机比如viewDidDisappear里调用。所以一般使用weak来弱引用定时器,这样就可以在dealloc里调用invalidate。

  2. 需要在合适的地方invalid定时器,否则定时器会一直强引用target从而延长target的生命周期。

    在开发中很容易忘记invalid定时器,一旦忘记invalid定时器,定时器就会延长target的生命周期,比如页面返回了但实际还没有被销毁,从而产生一些诡异问题。这也是使用NSTimer必须注意的地方。

  3. 使用时必须保证有一个活跃的runloop,并且需要指定mode。在子线程中使用不是很方便。

  4. 精度可能不够。

  5. 网上的一个说法:创建和撤销必须在同一个线程上,在多线程环境下使用不便。(这一条存疑,经过验证在子线程创建一个定时器,在另一个子线程invalidate并没有发现什么问题)

  6. iOS10开始支持block使用,同样在使用block时一定要注意循环引用。

为了从根本上避免上述问题,一个弱引用target的、能够在自身销毁时自动invalid的定时器想必是极好的,但又该如何实现呢?好在互联网在经过这么多年的发展,第三方开源库从未像现在这般丰富,唾手可得。不多时,便在GitHub上找到了MSWeakTimer

MSWeakTimer提供了和系统NSTimer一致的接口,好的代码就该这样美美与共,和而不同:

1
2
3
4
5
6
7
8
9
@property (nonatomic, strong) MSWeakTimer *weakTimer;

self.weakTimer = [MSWeakTimer scheduledTimerWithTimeInterval:3 target:self selector:@selector(doSome) userInfo:nil repeats:YES dispatchQueue:dispatch_get_main_queue()];
//立即触发回调方法
[self.weakTimer fire];

- (void)doSome {
NSLog(@"++++++%@", self);
}

至于MSWeakTimer的实现原理自然是和NSTimer不同的:通过封装GCD定时器实现NSTimer的功能,但内部却是弱引用target,不仅如此MSWeakTimer还支持在其他线程中执行回调函数。

阅读全文 »

不小心重写了父类的某个方法导致的崩溃

ZAEButton类里有一个commonInit方法,该方法会注册KVO “enabled”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}

return self;
}

- (void)commonInit
{
[self addObserver:self forKeyPath:@"enabled" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:nil];
}

- (void)dealloc
{
[self removeObserver:self forKeyPath:@"enabled"];
}

ZAECountDownButton类为ZAEButton的子类,如果不仔细看父类的实现的话,可能也会定义一个commonInit方法.此时就是重写了该方法,虽然这并不是你的意愿.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}

return self;
}

- (void)commonInit
{
_duration = 30;
_autoCountDown = YES;
_timerStartState = ZAECountDownButtonTimerStartStateFromBegining;
[self addTarget:self action:@selector(buttonDidClicked:) forControlEvents:UIControlEventTouchUpInside];
_activityView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
[self addSubview:_activityView];
_activityView.center = CGPointMake(self.bounds.size.width/2.0, self.bounds.size.height/2.0);
}

崩溃就这样产生了:

当实例化一个ZAECountDownButton对象,调用到[super initWithFrame:frame]时,由于子类和父类都有一个commonInit方法,子类会覆盖父类的方法,最终调用的是子类的commonInit方法.此时,子类就不会注册kvo,而当对象销毁时,会执行到父类的dealloc方法移除KVO,于是程序会因为KVO注册—移除不匹配而导致崩溃.

这或许就是有些框架中父类的初始化方法都有”_”的缘故吧.

iOS app文件系统

app沙盒:

AppName.app

app的bundle。该目录包含APP程序及开发过程中用到的本地资源。该目录是只读的,不可修改否则APP将无法启动。

Documents目录

说明:

1
2
3
Use this directory to store user-generated content. The contents of this directory can be made available to the user through file sharing; therefore, this directory should only contain files that you may wish to expose to the user.

The contents of this directory are backed up by iTunes and iCloud.

Documents目录主要放置用户创建的相关内容,比如用户的动态,消息数据库等。Documents目录应该仅存放你想暴露给用户的文件。该目录下的文件都会被备份到iTunes和iCloud。

Library目录

阅读全文 »