2020 年下半年iOS工作技术点 tips

背景:疫情原因导致之前云喵工作不能够继续,2020年6月还是面试找了一份相对稳定的工作,去了南航的一家外包供应商。

E App 是一个企业内部OA即时通讯App. 驻场南航后主要是针对此项目进行功能迭代敏捷开发。主要说一下几个技术任务点:

  1. 首要任务就是将旧有MRC内存运行环境改成ARC运行环境。
    任务到不是复杂,主要修改点是多,体力劳动。将ARC相关的修饰关键词,控制器的dealloc 方法以及设置的代理delegate, 配置文件-fno-objc 相关配置干掉。
    遇到的问题?
    期间由于之前是MRC手动内存回收机制,由于开发人员代码某些变量没有回收或者泄露贮存在内存中,当改成ARC之后会自动释放导致再次使用该变量的时候出现nil值崩溃或者业务的中断开!
    C++代码块Client类某方法的变量被释放造成崩溃?
    当时问题点抛向如何停止某线程的解决方案上去了,而不是终止某条件然后让线程自然停止。当程序进入后台applicationWillTerminate的时候,手动退出IM.然后进入销毁连接阶段。为了使alive_thread , recv_thread 两线程退出,在CLIENT_Disconnect 销毁阶段,通过改变alivethread的条件让while条件中止,线程必然退出。
  2. h5网页打卡有时候定位不到或者崩溃。因App程序使用了百度地图sdk,所以直接升级百度地图sdk即可。sdk编译分xcode11.3 或 xcode 12 。具体看E项目如果采取xcode11.3 编译打包上线,那么需要找到适应的.a包或者framework 进行编译。
  3. 将ASI网络文件下载改成AFNetwroking网络文件下载,将长进度条改成扇形进度条。
    ASI那套网络请求比较老旧,当时还是用NSURLConnnection来进行网络请求,而且需要单独维护一个网络线程长期贮存到内存中。AFN 采取NSURLSession来进行网络请求,线程即用即回收。
    AFN请求时设置支持SSL安全策略。

    1
    2
    3
    4
    5
    AFSecurityPolicy *securityPolicy = [[AFSecurityPolicy alloc] init];
    securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
    securityPolicy.allowInvalidCertificates = YES;
    securityPolicy.validatesDomainName = NO;
    [_manager setSecurityPolicy:securityPolicy];

    然后通过NSURLSessionDownloadTask 进行下载request,返回对应的进度,通过KVO进行进度条的UI显示,也可通过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
     NSURLSessionDownloadTask *downloadTask = [_manager downloadTaskWithRequest:request progress:^(NSProgress * _Nonnull downloadProgress) {
    dispatch_async(dispatch_get_main_queue(), ^{
    if(progressBlock){
    progressBlock(downloadProgress);
    }
    downloadModel.progress = downloadProgress.fractionCompleted;
    });
    } destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
    return [NSURL fileURLWithPath:downloadModel.pathName]; //这里直接返回需要存储的本地沙盒路径
    } completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
    if (error) {
    if (failedBlock) {
    downloadModel.progress = 1;
    failedBlock(error);
    }
    }else{
    if(successBlock){
    NSHTTPURLResponse * successRes = (NSHTTPURLResponse *)response;
    successBlock(successRes,downloadModel);
    }
    }
    }];
    [downloadTask resume];
    downloadModel.task = downloadTask;

    扇形视图的定义编写参考TZImagePicker图片选择器开源UI框架中的TZProgressView,后期的聊天图片预览也很大参考了TZ的图片、gif,视频混合预览控件。后期还会讲到基于TZImagePicker的图片入口进行图片编辑等功能扩展。
    回到扇形进度视图,我们定义了一个PieProgressView, 代码如下:

    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
    #define Hollow_Circle_Radius 0 //中间空心圆半径,默认为0实心
    #define KOffsetRadius 10 //偏移距离
    #define KMargin 0 //边缘间距

    @interface PieProgressView (){
    CGFloat _radius;
    CGPoint _center;
    }

    @property (nonatomic, strong) CAShapeLayer *pieLayer;
    @end

    @implementation PieProgressView

    - (instancetype)initWithFrame:(CGRect)frame{

    self = [super initWithFrame:frame];
    if (self) {
    self.backgroundColor = UIColor.clearColor;

    //线的半径为扇形半径的一半,线宽是扇形半径->半径+线宽的一半=真实半径,这样就能画出圆形了
    _radius = (frame.size.width - KMargin*2)/4.f;
    _center = CGPointMake(_radius*2 + KMargin, _radius*2 + KMargin);

    _pieLayer = [CAShapeLayer layer];
    _pieLayer.strokeStart = 0;
    _pieLayer.lineWidth = _radius*2 - Hollow_Circle_Radius;
    _pieLayer.strokeColor = [[UIColor colorWithWhite:0 alpha:0.5] CGColor];
    _pieLayer.fillColor = [UIColor clearColor].CGColor;
    _pieLayer.strokeEnd = 0.98;
    }

    return self;
    }

    #pragma mark -- Publish Methods

    - (void)drawRect:(CGRect)rect {

    _radius = (rect.size.width - KMargin*2)/4.f;
    _center = CGPointMake(_radius*2 + KMargin, _radius*2 + KMargin);
    _pieLayer.frame = self.bounds;
    UIBezierPath *piePath = [UIBezierPath bezierPathWithArcCenter:_center radius:_radius + Hollow_Circle_Radius startAngle:M_PI_2*3 endAngle:-M_PI_2 clockwise:false];

    _pieLayer.strokeEnd = 1.0-_progress;
    _pieLayer.path = piePath.CGPath;
    [_pieLayer removeFromSuperlayer];
    [self.layer addSublayer:_pieLayer];
    }

    - (void)setProgress:(float)progress {

    _progress = progress;
    [self setNeedsDisplay];
    }
    @end

    设置视图进度属性的时候,通过调用setNeedsDisplay告诉系统需要进行视图渲染,系统调用drawRect方法,往self.layer添加一个pieLayer, pieLayer的path通过贝塞尔曲线进行设置,piePath相当于从画了一个圆弧度,通过strokeEnd控制结束点,需要和closewise配合控制逆时针。pieLayer需要每次移除再往里添加。
    在控制器列表cellFor的时候, 设置progress的起初值为0.0001很小,几乎接近0,这样在点击的时候如果去下载,则灰色透明蒙版值达到0.999接近1.0 ,给人很逼真的感觉。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    if(_convRecord.msg_type == type_file){
    PieProgressView *pieProgressView = (PieProgressView *) [cell.contentView viewWithTag:pic_progress_circle_tag];
    pieProgressView.superview.hidden = NO;
    if(pieProgressView) {
    if(_convRecord.download_flag == state_download_success) {
    pieProgressView.hidden = YES;
    }else {
    pieProgressView.hidden = YES;
    pieProgressView.alpha = 1.0;
    pieProgressView.progress = 0.0001; //初始化配置进度。
    }
    }
    }

    由于ui需求需显示圆扇形进度,需要将pieProgressView添加到一个背景view中,再设置其circleBackView的背景颜色为透明,layer的masksToBounds为YES,这样就可以显示我们需要的圆扇形了。

  4. Xcode12 Runtime的方法调用。由于主工程是多个project工程相互依赖串行而成的工程,底工程需要依赖主工程类时候,可通过runtime obj_msgSend消息发送方式进行调用。

    1
    2
    3
    id cls = ((id (*) (id, SEL)) objc_msgSend)(objc_getClass("HttpTool"),sel_registerName("tool"));
    SEL sel = sel_registerName("POST:paramDic:Success:Failure:");
    ((void (*)(id,SEL,NSString *,NSDictionary *, id, id))objc_msgSend)(cls, sel, urlStr, dic, finishSuccessBlock,FailureBlock);
  5. 崩溃问题:

    • 数组越界。尤其在使用tableview的beginUpdate 的时候,局部刷新操作的数据源和读取的数据源个数不一致导致。还有一种情况是在子线程中操作了该数据,导致刷新的时候和该数据源不一致导致崩溃。或者是数据源遭到污染,变相被改变。
    • 字符串截取崩溃。 在使用字符串的方法截取range 的时候没有判断长度越界情况。
    • 在集合copy的时候,要注意是否变化为不可变集合,否则调用类似addObject等方法时候容易崩溃。
    • 对象释放。在使用SDWebImage下载图片的时候,如果用一个临时的imageView去下载一个图片,而这个imageView又没有被界面引用就出现对象被释放了。从而导致imageView sd_download的时候complateBlock一直没有回调。
    • 对象关键字的使用。比如block没有使用copy, delegate没有使用weak等。
    • 在子线程中发送了通知,然后在主线程更改了UI导致崩溃。
  6. 业务异常:

    • 切换登录用户,导致发送消息失败或者丢失。原因:切换用户时,数据库开关不正确导致sql事务没有提交。 解决方案:首先sqllite模式为串行模式,全局App只有一个共享的句柄handle。我们把这个变量定义在LCLSqlite中设置为只读。 建立一个单例dbManager类,只负责创建数据库和关闭数据库以及数据库版本的管理。 定义BaseSqlite类.提供公共的操纵数据库sql的读写方法,子类业务DAO都继承于BaseSqlite类,子类Dao可以使用handle(只读),但不可更改handle. 在数据库sqlite关闭的时候,为防止遗留与语句池statement还在使用, 需循环拿出未处理的语句池进行关闭。这样再打开数据库就能成功。
    • 正在某会话聊天的时候,输入文字发送后。突然杀掉进程,再次打开会话发现消息消失。解决方案:App线程干掉会走applicationWillTerminate方法,在此需要将数据库关闭。
    • 用户登录后收到很多离线消息,如果处理消息入库的线程池开辟数量大于2,则会出现多个业务先后顺序的问题(问题场景:消息在会话页面撤销了,但未在消息列表页面撤销掉)。必须控制线程数为1,保证Operation任务依赖有序插入,如果要处理入库的效率。可将消息数据记录进行批量事务入库。
  7. 线程问题:

    • 消息数据记录入库后,都会写一段代码来发送通知到控制器进行页面的刷新操作。当大量消息入库后发送通知时候,会造成主界面代码块如果开辟了GCD线程,会造成大量浪费和内存开辟的浪费。需要严格控制好线程的数量,可通过线程池等方面去控制。
    • 不要一味地想当然用异步去处理耗时长的任务然后直接下一步业务操作。比如发送一段语音,需要存储到沙盒,需要上传,需要把数据记录存到数据库。存到数据库时候,我们需要存储后端服务器返回给我们的fileToken,线程的执行有先后,文件的fileToken取到的是否正确的?值得考量。或者采取Block方式当真正拿到fileToken时候才进行下一步的业务操作。
    • 如何保证在多线程情况下的数据源安全?原子性?加同步锁?信号量?栅栏函数? 值得你去探索? 归根结底是引用类型造成的问题,Swift Struct 值类型值得你拥有。
  8. 业务懒加载:

    • 场景1:某消息类型为群待办或者群公告。他们重用的消息Cell可能一直,只是局部UI的不同。 cell的显示需要实时显示正确的消息类型对应的UI,这个时候可能通过查数据库拿到值后再更新UI. 但在计算cell高度的时候,无论是群待办还是群公告,它们的高度是固定的。所以无需多余的查询,只需load数据源的时候提前一次查询即可。
    • 当涉及到对象的copy的时候,尤其是当对象里某属性重写了get又去查询数据库的时候,性能损耗较大,在copyWithZone的方法里赋值的时候可根据情况将self.propA 改成_propA 这样就不会触发耗时的查询。
    • 在消息首页不断地查询计算总得未读数的时候,我们可以控制当消息数99+的时候,终止没必要的查询机制。当在收到通知需要查询未读数业务时候可控制专门的线程去查询该未读数。
    • 为了减少通知之后的刷新率,我们可以在消息入库之后发出通知之前,用一个中转器相当于一个定时时间内去检查有记录则即通知刷新一次(带参将消息数据发出去)。
  9. 性能问题:

    • 大部分性能损耗其实还是发生在主线程进行IO读取上,应该尽量去控制IO的读取频次,以及根据业务情况按实际需要它放在子线程中处理,再通过Block形式去返回。而block形式会给编写的代码造成block嵌套,尤其是层次多的时候,给阅读上造成一定的干扰。所以应该控制嵌套层次数,两者结合。业界也有coobjc协程解决方案。或者参考PromiseKit 类似前端web的promise方案。
    • Sqlite串行模式下数据库读写在大量离线消息收到时候比较容易出现卡顿的问题,因为所触发的读和写都是穿插排队。并不能很好地实现读写并发。在此基础上,我们应该控制大量数据写的时候尽量剥离出冗合的业务,将数据批量事务入库,这样在性能上也不至于那么卡。当然此方案也需要后台配合,看后台服务器是一条条下发给客户端还是批量多条下发给客户端。
    • 当在群会话发送一条消息的时候,本地有一张数据已读回执表记录着消息id, 用户id,阅读状态,创建时间,阅读时间等。首先需要将群里所有人批量入库初始为未阅读状态。当有收到回执消息时,即更新对应的阅读状态。这样给此表造成数据量的庞大和臃肿。解决方案:可根据时间戳删除一个月之前的废记录。在创建表的时候,可根据一个群一个回执记录表。这样有利于消息的多表连接与快速查询。
    • 当有大量数据在内存中排序的时候,如果能从数据库中排序尽量通过sql排序,效率远远大于内存排序。
    • 如果服务器是一条条离线消息下发,可根据消息的类型属于聊天消息专门通过一个队列来维护,后台线程getMessage循环去取的时候,一次就可以拿多个进行批量入库。
  10. 关于IM App 消息存储问题:
    目前是所有消息都存储在本地客户端。虽然节省了服务器资源,如果不批量下发,客户端性能上处理可能有点问题。可能会有轻微卡顿,如果量大,那就很明显了。虽然进入会话都是从本地拉数据,查询数据也都是本地进行搜索,比较全。但是如果后期要做多端数据同步[漫游]。那其实服务器还是要存储一定时间段的用户聊天消息的。网上查了很多资料,还有一种做法是这样:用户每次登录后,IM服务端会下发每个会话的近50条消息,10个会话也才500条,其实数据量很小了。当用户点击某会话时候,先显示最近的消息,当往上翻页的时候,本地数据库如果有则本地加载出来,如果没有则请求网络进行拉数据。这样推拉结合能减轻客户端很多问题。客户端根据需要进行消息存储。

系列文章

接下来将会写一些功能点上的技术心得:
图片编辑:涂鸦,裁剪,文字帖,马赛克
聊天图片浏览:图片,gif,视频
消息收藏
[YYTextView富文本部分文字光标选择]
正则匹配解决群内邀请新成员点击问题
@某人在文字前中后的处理规则
群插件如何开发?
[图片的转发]
[合并消息的思路实现]

评论