Giter Site home page Giter Site logo

iosblog's Introduction

ChenYilong 👋

😄 I'm @ChenYilong, living in Auckland, New Zealand, and a programmer who knows a bit about Objective-C, Swift, and Java and has written apps and frameworks.
👯 I'm currently writing some Swift, flutter, and Java code in my spare time at iTeaTime(源码派). Welcome to join the group.
📫 If you want to chat, you can send me an email, direct Twitter DM, and other ways to contact me.
📺 Welcome to fellow my YouTube channel: @iTeaTime Tech | 技术清谈

iosblog's People

Contributors

chenyilong avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

iosblog's Issues

使用 Heap-Stack Dance 替代 Weak-Strong Dance,优雅避开循环引用



【使用 Heap-Stack Dance 替代 Weak-Strong Dance,优雅避开循环引用】Weak-Strong Dance这一最佳实践的原理已经被讲烂了,开发者对该写法已经烂熟于心。有相当一部分开发者是不理解 Weak-Strong Dance 的原理,但却写得很溜,即使没必要加 strongSelf 的场景下也会添加上 strongSelf。没错,这样做,总是没错。

有没有想过从API层面简化一下?

介绍下我的做法:

为 block 多加一个参数,也就是 self 所属类型的参数,那么在 block 内部,该参数就会和 strongSelf 的效果一致。同时你也可以不写 weakSelf,直接使用使用该参数(作用等同于直接使用 strongSelf )。这样就达到了:“多加一个参数,省掉两行代码”的效果。原理就是利用了“参数”的特性:参数是存放在栈中的(或寄存器中),系统负责回收,开发者无需关心。因为解决问题的思路是:将 block 会捕获变量到堆上的问题,化解为了:变量会被分配到栈(或寄存器中)上,所以我把种做法起名叫 Heap-Stack Dance 。

具体用法示例如下:
(详见仓库中的Demo---文件夹叫做:weak-strong-drance-demo )

#import "Foo.h"

typedef void (^Completion)(Foo *foo);

@interface Foo ()

@property (nonatomic, copy) Completion completion1;
@property (nonatomic, copy) Completion completion2;

@end

@implementation Foo

- (instancetype)init {
   if (!(self = [super init])) {
       return nil;
   }
   __weak typeof(self) weakSelf = self;
   self.completion1 = ^(Foo *foo) {
       NSLog(@"completion1");
   };
   self.completion2 = ^(Foo *foo) {
       __strong typeof(self) strongSelf = weakSelf;
       NSLog(@"completion2");
       NSUInteger delaySeconds = 2;
       dispatch_time_t when = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delaySeconds * NSEC_PER_SEC));
       dispatch_after(when, dispatch_get_main_queue(), ^{
           NSLog(@"两秒钟后");
           foo.completion1(foo);//foo等价于strongSelf
       });
   };
   self.completion2(self);
   return self;
}

- (void)dealloc {
   NSLog(@"dealloc");
}

@end


@implementation ViewController

- (void)viewDidLoad {
   [super viewDidLoad];
   __autoreleasing Foo *foo = [Foo new];
}

@end

打印如下:

completion2
两秒钟后
completion1
dealloc

举一个实际开发中的例子:

如果我们为UIViewController添加了一个属性,叫做viewDidLoadBlock,让用户来进行一些UI设置。

具体的做法如下:

@property (nonatomic, copy) CYLViewDidLoadBlock viewDidLoadBlock;

- (void)viewDidLoad {
   [super viewDidLoad];
   //...
   !self.viewDidLoadBlock ?: self.viewDidLoadBlock(self);
}

那么可以想象block中必然是要使用到viewController本身,为了避免循环引用,之前我们不得不这样做:

简化前:

__weak typeof(controller) weakController = conversationController;
[conversationController setViewDidLoadBlock:^{
   [weakController.navigationItem setTitle:@"XXX"];
}];

如果借助这种做法,简化后:

   [conversationViewController setViewDidLoadBlock:^(LCCKBaseViewController *viewController) {
       viewController.navigationItem.title = @"XXX";
   }];

这种可能优势不太明显,毕竟编译器都能看出来,会报警告。但如果遇到了那种很难看出会造成循环引用的情景下,优势就显现出来了。
尤其是在公开的 API 中,无法获知 block 是否被 self 持有的,如果在 block 中加增一个 self 类型的参数,因为 block 内部已经提供了 weakSelf 或者是 strongSelf 的替代者,那么调用者就可以在不使用 Weak-Strong Dance 的情况下避免循环引用。

下面这个语句,编译器不会报警告,你能看出来有循环应用吗?

比如我们为 UIViewController 添加了一个方法,这个方法主要作用就是配置下 navigationBar 右上角的 item 样式以及点击事件:

   [aConversationController configureBarButtonItemStyle:LCCKBarButtonItemStyleGroupProfile
                                                 action:^(__kindof LCCKBaseViewController *viewController, UIBarButtonItem *sender, UIEvent *event) {                                                      [aConversationController.navigationController pushViewController:[UIViewController new] animated:YES];
                                                 }];

实际上你必须点击进去看一下该 API 的实现,你才能发现原来 aConversationController 持有了 action 这个 block,而在这种用法中 block 又持有了 aConversationController ,所以这种情况是有循环引用的。

可以看下上述方法的具体的实现:

- (void)configureBarButtonItemStyle:(LCCKBarButtonItemStyle)style action:(LCCKBarButtonItemActionBlock)action {
   NSString *icon;
   switch (style) {
       case LCCKBarButtonItemStyleSetting: {
           icon = @"barbuttonicon_set";
           break;
       }
       case LCCKBarButtonItemStyleMore: {
           icon = @"barbuttonicon_more";
           break;
       }
       case LCCKBarButtonItemStyleAdd: {
           icon = @"barbuttonicon_add";
           break;
       }
       case LCCKBarButtonItemStyleAddFriends:
           icon = @"barbuttonicon_addfriends";
           break;
       case LCCKBarButtonItemStyleSingleProfile:
           icon = @"barbuttonicon_InfoSingle";
           break;
       case LCCKBarButtonItemStyleGroupProfile:
           icon = @"barbuttonicon_InfoMulti";
           break;
       case LCCKBarButtonItemStyleShare:
           icon = @"barbuttonicon_Operate";
           break;
   }
   self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithImage:[UIImage lcck_imageNamed:icon bundleName:@"BarButtonIcon" bundleForClass:[self class]] style:UIBarButtonItemStylePlain target:self action:@selector(clickedBarButtonItemAction:event:)];
   self.barButtonItemAction = action;
}

- (void)clickedBarButtonItemAction:(UIBarButtonItem *)sender event:(UIEvent *)event {
   if (self.barButtonItemAction) {
       self.barButtonItemAction(self, sender, event);
   }
}

必须让调用者理解了内部实现,才能用得好的API,不是一个好的API设计。

能不能在API层面就避免?增加一个self类型的参数就好了:

           [aConversationController configureBarButtonItemStyle:LCCKBarButtonItemStyleGroupProfile
                                                         action:^(__kindof LCCKBaseViewController *viewController, UIBarButtonItem *sender, UIEvent *event) {
                                                             [viewController.navigationController pushViewController:[UIViewController new] animated:YES];
                                                         }];

各位如果觉得好用,可以到你的项目中使用 Heap-Stack Dance 替代 Weak-Strong Dance,重构一些代码。

这里还有另外一种方法来证明 self 做参数传进 block 不会被 Block 捕获:

用 clang 对 Foo.m 文件转成c/c++代码:

clang -rewrite-objc Foo.m -Wno-deprecated-declarations -fobjc-arc

比如如下代码:

   int tmpTarget;
   self.completion1 = ^(Foo *foo) {
       tmpTarget;
       NSLog(@"completion1");
   };
   self.completion1(self);

可以看到 Block 只会对传入的 tmpTarget 引用,self 不会捕获:

struct __Foo__init_block_impl_0 {
 struct __block_impl impl;
 struct __Foo__init_block_desc_0* Desc;
 int tmpTarget;
 __Foo__init_block_impl_0(void *fp, struct __Foo__init_block_desc_0 *desc, int _tmpTarget, int flags=0) : tmpTarget(_tmpTarget) {
   impl.isa = &_NSConcreteStackBlock;
   impl.Flags = flags;
   impl.FuncPtr = fp;
   Desc = desc;
 }
};

而如果是如下代码 self 就会被捕获:

int tmpTarget;
   self.completion1 = ^(Foo *foo) {
       tmpTarget;
       _b;
       NSLog(@"completion1");
   };
   self.completion1(self);
struct __Foo__init_block_impl_0 {
 struct __block_impl impl;
 struct __Foo__init_block_desc_0* Desc;
 int tmpTarget;
 Foo *__strong self;
 __Foo__init_block_impl_0(void *fp, struct __Foo__init_block_desc_0 *desc, int _tmpTarget, Foo *__strong _self, int flags=0) : tmpTarget(_tmpTarget), self(_self) {
   impl.isa = &_NSConcreteStackBlock;
   impl.Flags = flags;
   impl.FuncPtr = fp;
   Desc = desc;
 }
};


Posted by 微博@iOS程序犭袁
原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

iOS 防 DNS 污染方案调研(一)--- HTTPS(非SNI) 业务场景

iOS 防 DNS 污染方案调研(一)--- HTTPS(非SNI) 业务场景

1. 背景说明

本文主要介绍 HTTPS(含SNI) 业务场景下在 iOS 端实现 “IP直连” 的通用解决方案。

1.1 HTTPS

发送 HTTPS 请求首先要进行 SSL/TLS 握手,握手过程大致如下:

  1. 客户端发起握手请求,携带随机数、支持算法列表等参数。
  2. 服务端收到请求,选择合适的算法,下发公钥证书和随机数。
  3. 客户端对服务端证书进行校验,并发送随机数信息,该信息使用公钥加密。
  4. 服务端通过私钥获取随机数信息。
  5. 双方根据以上交互的信息生成session ticket,用作该连接后续数据传输的加密密钥。

上述过程中,和“IP直连”有关的是第3步,客户端需要验证服务端下发的证书,验证过程有以下两个要点:

  1. 客户端用本地保存的根证书解开证书链,确认服务端下发的证书是由可信任的机构颁发的。
  2. 客户端需要检查证书的 domain 域和扩展域,看是否包含本次请求的 host。

如果上述两点都校验通过,就证明当前的服务端是可信任的,否则就是不可信任,应当中断当前连接。

当客户端使用“IP直连”解析域名时,请求URL中的host会被替换成解析出来的IP,所以在证书验证的第2步,会出现domain不匹配的情况,导致SSL/TLS握手不成功。

1.2 SNI

SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的SSL/TLS扩展。它的工作原理如下:

  1. 在连接到服务器建立SSL链接之前先发送要访问站点的域名(Hostname)。
  2. 服务器根据这个域名返回一个合适的证书。

目前,大多数操作系统和浏览器都已经很好地支持SNI扩展,OpenSSL 0.9.8也已经内置这一功能。

上述过程中,当客户端使用“IP直连”时,请求URL中的host会被替换成解析出来的IP,导致服务器获取到的域名为解析后的IP,无法找到匹配的证书,只能返回默认的证书或者不返回,所以会出现SSL/TLS握手不成功的错误。

比如当你需要通过 HTTPS 访问 CDN 资源时,CDN 的站点往往服务了很多的域名,所以需要通过SNI指定具体的域名证书进行通信。

2. HTTPS场景(非SNI)解决方案

针对“domain不匹配”问题,可以采用如下方案解决:hook 证书校验过程中第2步,将IP直接替换成原来的域名,再执行证书验证。该方案与使用“自定义证书”进行 HTTPS 请求的校验方案一样。

【注意】基于该方案发起网络请求,若报出SSL校验错误,比如 iOS 系统报错kCFStreamErrorDomainSSL, -9813; The certificate for this server is invalid,Android系统报错System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,请检查应用场景是否为SNI(单IP多HTTPS域名)。

下面分别列出 iOS 平台的示例代码。

iOS示例

此示例针对NSURLSession/NSURLConnection接口。

- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust
                  forDomain:(NSString *)domain
{
    /*
     * 创建证书校验策略
     */
    NSMutableArray *policies = [NSMutableArray array];
    if (domain) {
        [policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)domain)];
    } else {
        [policies addObject:(__bridge_transfer id)SecPolicyCreateBasicX509()];
    }
    
    /*
     * 绑定校验策略到服务端的证书上
     */
    SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies);
    
    
    /*
     * 评估当前serverTrust是否可信任,
     * 官方建议在result = kSecTrustResultUnspecified 或 kSecTrustResultProceed
     * 的情况下serverTrust可以被验证通过,https://developer.apple.com/library/ios/technotes/tn2232/_index.html
     * 关于SecTrustResultType的详细信息请参考SecTrust.h
     */
    SecTrustResultType result;
    SecTrustEvaluate(serverTrust, &result);
    
    return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
}

/*
 * NSURLConnection
 */
- (void)connection:(NSURLConnection *)connection
willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge
{
    if (!challenge) {
        return;
    }
    
    /*
     * URL里面的host在使用“IP直连”的情况下被设置成了IP,此处从HTTP Header中获取真实域名
     */
    NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
    if (!host) {
        host = self.request.URL.host;
    }
    
    /*
     * 判断challenge的身份验证方法是否是NSURLAuthenticationMethodServerTrust(HTTPS模式下会进行该身份验证流程),
     * 在没有配置身份验证方法的情况下进行默认的网络请求流程。
     */
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust])
    {
        if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
            /*
             * 验证完以后,需要构造一个NSURLCredential发送给发起方
             */
            NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
            [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
        } else {
            /*
             * 验证失败,进入默认处理流程
             */
            [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];
        }
    } else {
        /*
         * 对于其他验证方法直接进行处理流程
         */
        [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];
    }
}

////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////

/*
 * NSURLSession
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
 completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler
{
    if (!challenge) {
        return;
    }
    
    NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    NSURLCredential *credential = nil;
    
    /*
     * 获取原始域名信息。
     */
    NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
    if (!host) {
        host = self.request.URL.host;
    }
    
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
        if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
            disposition = NSURLSessionAuthChallengeUseCredential;
            credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
        } else {
            disposition = NSURLSessionAuthChallengePerformDefaultHandling;
        }
    } else {
        disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    }
    // 对于其他的challenges直接使用默认的验证方案
    completionHandler(disposition,credential);
}

需要修改HOST的场景总结

那么什么时候需要修改Host?

答案是所有情况都需要设置 HOST:做网络请求时,采用 IP 直连的方案会遇到 HOST 字段被改为 IP 的问题,所以都需要手动地配置 HOST 字段。

场景 HTTP HTTPS(非SNI) HTTPS(SNI)
如何设置 改Host 改Host,在移动端我们自己校验,直接返回YES 改HOST,而且需要做SNI适配。

虽然 IP 直接连的方案,导致的结果是 HOST 字段被改为了IP,所以需要手动修改HOST。但是服务端唯一的根据是SNI字段。下面就介绍下针对 SNI 场景的方案:

3. HTTPS(SNI)场景方案

3.1 iOS SNI场景

SNI(单IP多HTTPS证书)场景下,iOS上层网络库NSURLConnection/NSURLSession没有提供接口进行SNI字段的配置,因此需要Socket层级的底层网络库例如CFNetwork,来实现IP直连网络请求适配方案。而基于CFNetwork的解决方案需要开发者考虑数据的收发、重定向、解码、缓存等问题(CFNetwork是非常底层的网络实现),希望开发者合理评估该场景的使用风险。
可参考:

具体的实现方案可以参考: 《防 DNS 污染方案调研---iOS HTTPS(含SNI) 业务场景(二)-- SNI 场景》

怎么查哪个 SDK 使用了 UIWebView 的 API ?

怎么查哪个 SDK 使用了 UIWebView 的 API ?


相信很多人都收到了 UIWebView 的 API 的警告邮件了,
Apple 正打算强制开发者弃用 UIWebView 的 API , 继续用可能会审核被拒.
自己是否用了该 API 只需要在 Xcode 中全局搜索即可, 那么如何查询自己的项目中哪个 sdk 使用了 UIWebView 的 API 呢?

以下步骤, 首先要进入到工程的根目录,然后使用终端输入如下命令:
这里介绍两种方式.

两种方式查 API 的方式

第一种

Grep

grep -r "UIWebView" .

打印效果:

输出结果为:

Binary file ./Alipay/AlipaySDK.framework/AlipaySDK matches
Binary file ./Alipay copy名称包含空格测试/AlipaySDK.framework/AlipaySDK matches
Binary file ./Alipaycopy/AlipaySDK.framework/AlipaySDK matches
Binary file ./Alipay名称包含汉语名称测试/AlipaySDK.framework/AlipaySDK matches
./shell.sh:  do if nm "$file"/`basename "$file"  | sed -e s/\\.framework$//g` 2>/dev/null  | grep UIWebView > /dev/null; then echo -e "[EN]"$file" contains API of UIWebView  \n[CN] "$file" 包含 UIWebView 相关 API "; fi;done
./WS_APP_PAY_SDK_BASE_15.7.5/iOS_Demo/AliSDKDemo/AliSDKDemo/APWebViewController.h:@interface APWebViewController : UIViewController<UIWebViewDelegate>
./WS_APP_PAY_SDK_BASE_15.7.5/iOS_Demo/AliSDKDemo/AliSDKDemo/APWebViewController.h:@property (strong, nonatomic) IBOutlet UIWebView *webView;
./WS_APP_PAY_SDK_BASE_15.7.5/iOS_Demo/AliSDKDemo/AliSDKDemo/APWebViewController.m:- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
./WS_APP_PAY_SDK_BASE_15.7.5/iOS_Demo/AliSDKDemo/AliSDKDemo/Base.lproj/Main.storyboard:                    <view key="view" contentMode="scaleToFill" id="NbF-BA-eFG" customClass="UIWebView">
./WS_APP_PAY_SDK_BASE_15.7.5/updatelog.txt:1. 去除UIWebView改为WKWebView

第二种

指定文件名

只查询 .framework .a 文件

echo "------------<🔎 search in *.framework & *.a & *.h & *.m & *.swift>-------------"
echo "------------------------<I>------------------------"
find . \( -name "*.framework" -o -name "*.a"  \) -exec sh -c ' 
usefullArray=();
uselessArray=();
for file do
if  nm "$file"/`basename "$file"  | sed -e s/\\.framework$//g` 2>/dev/null  | grep -rl UIWebView > /dev/null  ; then
usefullArray+=("$file")
elif grep -rl "UIWebView" "$file" > /dev/null;  then
usefullArray+=("$file")
elif   nm "$file" 2>/dev/null  | grep -rl UIWebView > /dev/null; then
usefullArray+=("$file")
#[EN🇺🇸] Uncomment this line for more log information[CN🇨🇳]打开本行注释可以看到扫描日志
#else uselessArray+=("$file")
fi
done
for i in "${uselessArray[@]}"
do
echo "✅  UIWebView does not appear  in "$file"";
done
for i in "${usefullArray[@]}"
do
echo "⚠️   UIWebView          appears in "$i"";
done
' sh {} + ;
sh -c 'echo "------------------------<I>------------------------";
echo "🎉 Done!";'

打印效果

查询 .framework .a .h .m .swift文件

echo "------------<🔎 search in *.framework & *.a & *.h & *.m & *.swift>-------------"
echo "------------------------<I>------------------------"
find . \( -name "*.framework" -o -name "*.a"  -o -name "*.h"  -o -name "*.m" -o -name "*.swift" \) -exec sh -c ' 
usefullArray=();
uselessArray=();
for file do
if  nm "$file"/`basename "$file"  | sed -e s/\\.framework$//g` 2>/dev/null  | grep -rl UIWebView > /dev/null  ; then
usefullArray+=("$file")
elif grep -rl "UIWebView" "$file" > /dev/null;  then
usefullArray+=("$file")
elif   nm "$file" 2>/dev/null  | grep -rl UIWebView > /dev/null; then
usefullArray+=("$file")
#[EN🇺🇸] Uncomment this line for more log information[CN🇨🇳]打开本行注释可以看到扫描日志
#else uselessArray+=("$file")
fi
done
for i in "${uselessArray[@]}"
do
echo "✅  UIWebView does not appear  in "$file"";
done
for i in "${usefullArray[@]}"
do
echo "⚠️   UIWebView          appears in "$i"";
done
' sh {} + ;
sh -c 'echo "------------------------<I>------------------------";
echo "🎉 Done!";'

打印效果:

注意上面修改文件类型,只需要修改第一行即可.

注意情况

有人检测出 Google SDK 包含了相关关键词,这是 Google 的回复:

Hi there
Thank you for reporting this
As mentioned by my colleague Deepika, grep -r “UIWebView" . does not necessarily mean that an SDK is calling UIWebview Some of the strings shown in the Google Mobile Ads SDK are part of log messages. Also, we’ve tested the latest version of the SDK and we didn’t get warnings about UIWebView usage
Could you double-check the other binaries in your app to verify whether they are using Webview? we also recommending our publishers to run multiple tests with a subset of their binaries (e. G. A test without any mediation adapter/SDKs binaries if you use mediation) to verify the offending binary.
Please let us know how it goes so I could raise this to the team once more.
Regards
Mobile Ads SDK Team

翻一下就是:

正如我的同事XXX提到的,grep -r "UIWebView " . 并不意味着SDK正在调用UIWebview。另外,我们测试了最新版本的SDK,没有收到关于UIWebView使用的警告。
我们也建议我们的发布者用他们的二进制文件的子集来运行多个测试(例如,如果你使用mediation的话,在没有任何mediation适配器/SDKS二进制文件的情况下进行测试),以验证违规的二进制文件。
请让我们知道情况如何,这样我可以再次向团队提出这个问题。
移动广告SDK团队

上面我提到的两种情况, 均与 Google 团队回复的情况一致, 上面的命令只是文本查找, 并不代表该文件一定使用了该API 谷歌的回复内容可以忽略,如果abi能找出来,就说明他编译到里面了,苹果的机审可不会管你有没有用。 请尽快与SDK提供商沟通,要求其提供升级版本, 避免被拒的风险.

相关代码可点击原文查看.

觉得有用的话,点击“在看”吧👇

计算机进制的基础知识

开篇先给大家讲个冷笑话, 暖场一下:

下面正式开始吧.

进制基础

在网上找到一个讲进制讲得比较清楚的视频, 分享给大家.
视频地址

分几个章节:

./
├── 01_进制基础知识
├── 02_R进制到十进制的转换
├── 03_十进制到R进制的转换
├── 04_进制间的快速转换
├── 05_Java内置的进制转换
├── 06_有符号数据表示法
├── 07_强制转换之数据溢出
├── 08_浮点数的进制转换
├── 09_浮点数存储
└── 10_浮点数的运算

里面的代码演示部分用的 Java. 也是较为简单的 Java 语法, 如果你对 Java 不太熟悉也能看懂.

PPT



06




































代码

请点击“查看原文”查看

Demo1

/*
Demo1
   练习:在控制台输出下面的数据,看结果是多少
   0b10000000000
   02000
   0x400
*/
public class Demo01 {
   public static void main(String[] args) {
       System.out.println(0b10000000000);
       System.out.println(02000);
       System.out.println(0x400);
   }
}

Demo2

/*
   java.lang.Integer 类中的静态方法:

   public static String toBinaryString(int i):在基数2中返回整数参数的字符串表示形式为无符号整数
   public static String toOctalString(int i):在基数8中返回整数参数的字符串表示形式为无符号整数
   public static String toHexString(int i):返回整数参数的字符串表示形式,作为16位中的无符号整数

   public static String toString(int i,int radix):返回由第二个参数指定的基数中的第一个参数的字符串表示形式
*/
public class Demo02 {
   public static void main(String[] args) {
//        public static String toBinaryString(int i):在基数2中返回整数参数的字符串表示形式为无符号整数
       System.out.println(Integer.toBinaryString(42));
//        public static String toOctalString(int i):在基数8中返回整数参数的字符串表示形式为无符号整数
       System.out.println(Integer.toOctalString(42));
//        public static String toHexString(int i):返回整数参数的字符串表示形式,作为16位中的无符号整数
       System.out.println(Integer.toHexString(42));
       System.out.println("-------------------------------");

//        public static String toString(int i,int radix):返回由第二个参数指定的基数中的第一个参数的字符串表示形式
       System.out.println(Integer.toString(0b101010,10));
       System.out.println(Integer.toString(052,10));
       System.out.println(Integer.toString(0x2a,10));
       System.out.println(Integer.toString(0x2A,10));
   }
}

Demo3

/*
   请问下列代码是否有问题?如果有问题,如何解决呢?解决完毕之后,结果是多少呢?
   byte b = 130;
*/
public class Demo03 {
   public static void main(String[] args) {
       byte b = (byte)130;
       System.out.println(b);

       /*
           130默认是int类型的
           十进制的数据转为二进制数据:
           原码:00000000 00000000 00000000 10000010
           反码:00000000 00000000 00000000 10000010
           补码:00000000 00000000 00000000 10000010

           截取操作:
           补码:1 0000010
           反码:1 0000001
           原码:1 1111110

           64 + 32 + 16 + 8 + 4 + 2 = 126
           -126
        */
   }
}

Demo 4

import java.math.BigDecimal;

/*
   浮点数运算
*/
public class Demo04 {
   public static void main(String[] args) {
       System.out.println(2.0f - 1.5f);
       /*
           十进制:2.0
           二进制:10.0
           规范化表示:1.00000000000000000000000 * 2 ^ 1
           存储:
               符号:0
               M:00000000000000000000000
               E:1 + 127 = 128 = 10000000

               0 10000000 00000000000000000000000

           十进制:1.5
               0.5 * 2 = 1.0   1
           二进制:1.1
           规范化表示:1.10000000000000000000000 * 2 ^ 0
           存储:
               符号:0
               M:10000000000000000000000
               E:0 + 127 = 127 = 01111111

               0 01111111 10000000000000000000000

           计算:
               1.000000000000000000000000
               0.110000000000000000000000
               0.010000000000000000000000  * 2 ^ 1

               规范化表示:1.00000000000000000000000 * 2 ^ -1

           0.5的存储?
           十进制:0.5
               0.5 * 2 = 1.0   1
           二进制:0.1
           规范化表示:1.00000000000000000000000 * 2 ^ -1

           规范化表示:1.00000000000000000000000 * 2 ^ -1
        */
       System.out.println(2.0f - 1.3f);
       /*
           十进制:2.0
           二进制:10.0
           规范化表示:1.00000000000000000000000 * 2 ^ 1
           存储:
               符号:0
               M:00000000000000000000000
               E:1 + 127 = 128 = 10000000

               0 10000000 00000000000000000000000

           十进制:1.3
               0.3 * 2 = 0.6   0
               0.6 * 2 = 1.2   1
               0.2 * 2 = 0.4   0
               0.4 * 2 = 0.8   0
               0.8 * 2 = 1.6   1
               0.6 * 2 = 1.2   1
               0.2 * 2 = 0.4   0
               0.4 * 2 = 0.8   0
               0.8 * 2 = 1.6   1
               ...
           二进制:1.0100110011001100110011001
           规范化表示:1.01001100110011001100110 * 2 ^ 0
           存储:
               符号:0
               M:01001100110011001100110
               E:0 + 127 = 127 = 01111111

               0 01111111 01001100110011001100110

           计算:
               1.000000000000000000000000
               0.101001100110011001100110
               0.010110011001100110011010  * 2 ^ 1

               规范化表示:1.01100110011001100110100 * 2 ^ -1

           0.7的存储?
               十进制:0.7
                   0.7 * 2 = 1.4   1
                   0.4 * 2 = 0.8   0
                   0.8 * 2 = 1.6   1
                   0.6 * 2 = 1.2   1
                   0.2 * 2 = 0.4   0
                   0.4 * 2 = 0.8   0
                   0.8 * 2 = 1.6   1
                   0.6 * 2 = 1.2   1
                   0.2 * 2 = 0.4   0
                   ...
               二进制:0.1011001100110011001100110
               规范化表示:1.01100110011001100110011 * 2 ^ -1

               规范化表示:1.01100110011001100110100 * 2 ^ -1
        */
       System.out.println(2.0f - 1.4f);
       /*
           十进制:2.0
           二进制:10.0
           规范化表示:1.00000000000000000000000 * 2 ^ 1
           存储:
               符号:0
               M:00000000000000000000000
               E:1 + 127 = 128 = 10000000

               0 10000000 00000000000000000000000

           十进制:1.4
               0.4 * 2 = 0.8   0
               0.8 * 2 = 1.6   1
               0.6 * 2 = 1.2   1
               0.2 * 2 = 0.4   0
               0.4 * 2 = 0.8   0
               0.8 * 2 = 1.6   1
               0.6 * 2 = 1.2   1
               0.2 * 2 = 0.4   0
               ...
           二进制:1.011001100110011001100110
           规范化表示:1.01100110011001100110011 * 2 ^ 0
           存储:
               符号:0
               M:01100110011001100110011
               E:0 + 127 = 127 = 01111111

               0 01111111 01100110011001100110011

           计算:
               1.000000000000000000000000
               0.101100110011001100110011
               0.010011001100110011001101 * 2 ^ 1

               规范化表示:1.00110011001100110011010 * 2 ^ -1

           0.6的存储?
           十进制:0.6
               0.6 * 2 = 1.2   1
               0.2 * 2 = 0.4   0
               0.4 * 2 = 0.8   0
               0.8 * 2 = 1.6   1
               0.6 * 2 = 1.2   1
               0.2 * 2 = 0.4   0
               0.4 * 2 = 0.8   0
               0.8 * 2 = 1.6   1
               ...
           二进制:0.1001100110011001100110011001
           规范化表示:1.00110011001100110011010 * 2 ^ -1       0舍1入

           规范化表示:1.00110011001100110011010 * 2 ^ -1
        */
       /*
       0.5
       0.70000005
       0.6
        */

       BigDecimal bd1 = new BigDecimal("2.0");
       BigDecimal db2 = new BigDecimal("1.3");
       BigDecimal bd = bd1.subtract(db2);
       System.out.println(bd);
   }


UIWebView API 被拒情况汇总(附API检测脚本)


根据 @Iteatime(技术清谈) 群里的反馈将最近 UIWebView API 被拒情况汇总一下,希望对大家上架有帮助

2020-05-09 13:15:54更新:

UIWebView API 被拒情况汇总:

  • 新APP上架 (v1.0) 有 UIWebView API 机审不通过 移除包含 UIWebView API 的SDK 重新上传上架成功
  • 老APP迭代 (比如从v1,0到v2.0) 上一版本(v1.0) 有 UIWebView API 提交时有 warning 12月为 Deadline 有案例 成功上架更新包含 UIWebView API 的更新包 (2019年5月6日)
  • 老APP迭代 上一版本(v1.0) 无 UIWebView API 新加入 UIWebView API 机审不通过 移除包含 UIWebView API 的SDK 重新上传 上架成功

感谢群里的 @独醉年华-云图iOS -北京 提供2019年5月6日的上架数据。
群里的@慧编 表示:新的app 不支持的,旧的也快了。
@em-送外卖的-重庆iOS 表示:响应苹果的号召吧 长痛不如早痛.

检测脚本

注意文章的评论区,我会在那里给出更新过的建议:

《怎么查哪个 SDK 使用了 UIWebView 的 API ?》

《怎么查哪个 SDK 使用了 UIWebView 的 API ?》

附上 Apple 的最后通牒:

Updating Apps that Use Web Views
December 23, 2019


If your app still embeds web content using the deprecated UIWebView API, we strongly encourage you to update to WKWebView as soon as possible for improved security and reliability. WKWebView ensures that compromised web content doesn’t affect the rest of an app by limiting web processing to the app’s web view. And it’s supported in iOS and macOS, and by Mac Catalyst.

The App Store will no longer accept new apps using UIWebView as of April 2020 and app updates using UIWebView as of December 2020.

翻译过来就是:

更新使用 WebView的应用程序
2019年12月23日


如果您的应用程序仍然使用被废弃的 UIWebView API 嵌入 Web 内容,我们强烈建议您尽快更新到 WKWebView,以提高安全性和可靠性。WKWebView 通过将 Web 处理限制在应用程序的 Web 视图中,确保相关的 Web 内容不会影响(侵入)到应用程序的其他部分。而且它在 iOS 和 macOS 以及 Mac Catalyst 中都支持。
从2020年4月起,App Store 将不再接受使用 UIWebView 的新应用,而从2020年12月起,App Store 将不再接受使用 UIWebView 的应用更新。

点了"在看"的同学

下半年审核通过率100%

"在看"👇👇

我信你个鬼, GitHub 出了 WebIDE 就让我卸载 VSCode ? 小编你电脑根本就没装过吧?!

基于 Web 的IDE(下文简称 WebIDE )的确方便不少,但仅仅限于前端应用里的简单场景,复杂场景并不适合。更不可能取代 VSCode 等桌面IDE,别听风就是雨。

如果有一天 WebIDE 完全取代 VSCode 等桌面 IDE 真的发生了,我们至少还需要几代人的时间来实现,很明显我们现在还是第一代(史称前浪)。

WebIDE 的优点

首先 WebIDE 很有用,毋庸置疑。好处比如:

  1. 没有跨机同步的麻烦。可以在云存储和本地存储之间无缝同步。
  2. 不需要安装工具链来工作或恢复工作--只需要一个浏览器和一个连接就可以了。浏览器无疑是现在普及率最高,并且支持最好的、功能最丰富的GUI工具箱之一。
  3. 易于团队合作,在线开发环境一致。
  4. 易于部署。
  5. 转向Chrome OS的超廉价笔记本电脑的好处将成为现实。

优点不少,但在我看来,这里没有什么杀手级的功能让我来删除 VSCode。

同时我想说,前端人多,小编你也不能不把我们这些非脚本语言、非标记语言的开发者当空气呀。

我作为一名非脚本语言开发者表示 WebIDE 的局限性似乎比优点要多:

WebIDE 系统交互能力被阉割

IDE 需要与支持的系统紧密结合。这对于编译语言来说尤其如此,但即使是使用 Python 这样的开发语言也很重要。如果使用第三方服务的IDE,他们需要给我几乎完全访问命令行和系统的权限。我需要能够安装依赖关系,管理版本,使用 Git,可能还有编译程序。

悲剧的是,浏览器不能直接与本地文件系统交互,不能直接调用其他本地程序。至今很多人无法理解。因为每个人已经熟悉了如何在手机上安装应用程序,并明确授予它们访问本地资源,如文件系统、摄像头等。让人迷惑得是,浏览器厂商没有直接添加 "完全访问本地文件系统"作为基于网页的应用程序可以请求的选项。我假设浏览器制造商只是害怕被恶意软件入侵,因为授予特定来源的页面访问本地机器的文件系统分支或启动本地机器进程的权限本来是一件很简单的事情。

虽然HTML5有 "本地存储",即你的磁盘空间是用来存储应用程序和/或数据的物理空间,然而数据是与应用程序相关联的。虽然在理论上你可以使用提取工具来获取,但最终数据不容易在App之外获得。它解决了 "需要一直保持在线 "的问题,但并不能真正解决与数据绑定到webapp相关的其他问题。事实上,在某些方面,它让事情变得更糟。

如果这个权限能放开的话,到那时,基于浏览器的IDE,才有可能成为本地IDE的替代方案。

同时浏览器缓存能力真的很弱,你看看现在的视频聊天应用,即使是基于 WebRTC 这种原生为 Chrome 打造的聊天协议,也很难在 Chrome 上商用并且不卡。

给予文件系统交互的问题,这很容易让开发者想到在自己的VPS上建立一个在线IDE。我认为这个想法比在第三方服务上托管要重要得多。

WebIDE 计算能力有限

托管式 IDE 的另一个未公开的缺点是计算能力有限。这是一个可以而且很可能会改变的事情。如果使用第三方服务的IDE,我可能会和其他非常多数量的开发者共享一台服务器,他们在任何时候都在利用甚至浪费CPU。但你如果自己准备在VPS上搭建一个,价格可能会太高,不值得。下图是国内某云服务提供商提供的低端套餐1G内存816一年

国内某云服务提供商提供的低端套餐价

反观,我自己开发者800块买一台运行1G内存的笔记本电脑上编程,我一个人用,还不用掏租金,不香吗?

不过 WebIDE 是在利用云计算的力量来进行颠覆,有些场景下也很有意义。我常在朋友圈看到有的开发者朋友为了训练 AI 模型又从某宝上高价组装了一台高配台式机,如果可以将资源密集型的任务(例如编译)发送到大型云服务器上,并且只需支付实际使用量的费用,我们就可以在小型VPS上进行开发。用生产型服务器做这个工作的基础设施是存在的,只是我对这种方式是否能支持开发到发布全流程闭环能力表示怀疑。

WebIDE 界面稳定性在真实场景下不堪一击

速度显然是现有的web IDE的最大缺点,而且在未来一段时间内仍将继续存在。这不是开发机器的速度,而是实际IDE界面的速度。Web应用只是还没有达到桌面应用的水平,有很多因素在起作用。服务器速度、互联网速度、当前的Web/本地ISP流量、连接性、工作站功率等。良好的离线支持可以帮助,但并不能解决这个问题。

使用桌面型IDE,我可以在繁忙的机场和恶劣的wifi条件下进行编程,或者每天坐一个小时的火车上下班。基于网络的IDE如何克服这一障碍?

人类是基于水活着,上帝允许我们24小时不喝水。
虽然我们是基于 Google 编程、基于 Stack Overflow 编程,但请允许我们24小时没网也能编程,要求过分吗?

这样看来 WebIDE 要求在线才能编程,是不是有点过分??

参考:

Do you think that Web-Based IDEs are the future of IDEs? What are the advantages and disadvantages of Web IDEs? What features make much more sense in a Web IDE? What are the best web based IDEs?

非脚本语言开发者都点了"在看"👇

IM 即时通讯技术在多应用场景下的技术实现,以及性能调优(iOS视角)

IM 即时通讯技术在多应用场景下的技术实现,以及性能调优(iOS视角)

演讲视频(上下两部,时长将近2个半小时)以及 PPT 下载:链接: http://pan.baidu.com/s/1i5oH6LZ 密码: 4ayq

2016年9月份参加了 MDCC2016(**移动开发者大会),

2016年9月份我参加了 MDCC2016(**移动开发者大会)

在 MDCC2016 上我做了关于 IM 相关分享,会上因为有50分钟的时间限制 ,所以有很多东西都没有展开,这篇是演讲稿的博文版本,比会上讲得更为详细。有些演讲时一笔带过的部分,在文中就可以展开讲讲。

图为我正在演讲

注:

  • 本文中所涉及到的所有 iOS 端相关代码,均已100%开源(不存在 framework ),便于学习参考。
  • 本文侧重移动端的设计与实现,会展开讲,服务端仅仅属于概述,不展开。
  • 为大家在设计或改造优化 IM 模块时,提供一些参考。

我现在任职于 LeanCloud(原名 AVOS 。LeanCloud 是国内较早提供 IM 服务的 Paas 厂商,提供 IM 相关的 SDK 供开发者使用,现在采纳我们 IM 方案的 APP 有:知乎Live、掌上链家、懂球帝等等,在 IM 方面也积累了一些经验,这次就在这篇博文分享下。

采纳了我们IM方案和推送方案的APP

IM系列文章

IM 系列文章分为下面这几篇:

本文是第一篇。

提纲

  1. 应用场景
    1. IM 发展史
    2. 大家都在使用什么技术
    3. 社交场景
    4. 直播场景
    5. 数据自动更新场景
    6. 电梯场景(假在线状态处理)

  2. 技术实现细节
    1. 基于 WebSocket 的 IM 系统
    2. 更多

  3. 性能调优 -- 针对移动网络特点的性能调优
    1. 极简协议,传输协议 Protobuf
    2. 在安全上需要做哪些事情?

    1. 防止 DNS 污染
    2. 账户安全
      3. 重连机制
      4. 使用 HTTP/2 减少不必要的网络连接
      5. 设置合理的超时时间
      6. 图片视频等文件上传
      7. 使用缓存:基于 Hash 的本地缓存校验

大规模即时通讯技术上的难点

思考几个问题:

  • 如何在移动网络环境下优化电量,流量,及长连接的健壮性?现在移动网络有2G、3G、4G各种制式,并且随时可能切换和中断,移动网络优化可以说是面向移动服务的共同问题。
  • 如何确保IM系统的整体安全?因为用户的消息是个人隐私,因此要从多个层面来保证IM系统的安全性。
  • 如何降低开发者集成门槛?
  • 如何应对新的iOS生态下的政策以及结合新技术:比如HTTP/2、IPv6、新的APNs协议等。

应用场景

一个 IM 服务最大的价值在于什么?

可复用的长连接。一切高实时性的场景,都适合使用IM来做。

比如:

  • 视频会议、聊天、私信
  • 弹幕、抽奖
  • 互动游戏
  • 协同编辑
  • 股票基金实时报价、体育实况更新、
  • 基于位置的应用:Uber、滴滴司机位置
  • 在线教育
  • 智能家居

下文会挑一些典型的场景进行介绍,并涉及到技术细节。

IM 发展史

基本的发展历程是:轮询、长轮询、长连接。

挑一些代表性的技术做下介绍:

一般的网络请求:一问一答

轮询:频繁的一问一答

长轮询:耐心地一问一答

一种轮询方式是否为长轮询,是根据服务端的处理方式来决定的,与客户端没有关系。

短轮询很容易理解,那么什么叫长轮询?与短轮询有什么区别。

举个例子:

比如中秋节我们要做一个秒杀月饼的页面,要求我们要实时地展示剩余的月饼数量,也就是库存量。这时候如果要求你只能用短轮询或长轮询去做,怎么做呢?

长轮询和短轮询最大的区别是,短轮询去服务端查询的时候,不管服务端有没有变化,服务器就立即返回结果了。而长轮询则不是,在长轮询中,服务器如果检测到库存量没有变化的话,将会把当前请求挂起一段时间(这个时间也叫作超时时间,一般是几十秒)。在这个时间里,服务器会去检测库存量有没有变化,检测到变化就立即返回,否则就一直等到超时为止,这就是区别。

(实际开发中不会使用长短轮询来做这种需求,这里仅仅是为了说明两者区别而做的一个例子。)

长轮询曾被 Facebook 早起版本采纳,示意图如下:

HTML5 WebSocket: 双向

参考: What are Long-Polling, Websockets, Server-Sent Events (SSE) and Comet?

我们可以看到,发展历史是这样:从长短轮询到长连接,使用 WebSocket 来替代 HTTP。

其中长短轮询与长短连接的区别主要有:

  1. 概念范畴不同:长短轮询是应用层概念、长短连接是传输层概念
  2. 协商方式不同:一个 TCP 连接是否为长连接,是通过设置 HTTP 的 Connection Header 来决定的,而且是需要两边都设置才有效。而一种轮询方式是否为长轮询,是根据服务端的处理方式来决定的,与客户端没有关系。
  3. 实现方式不同:连接的长短是通过协议来规定和实现的。而轮询的长短,是服务器通过编程的方式手动挂起请求来实现的。

在移动端上长连接是趋势。

其最大的特点是节省 Header。

轮询与 WebSocket 所花费的Header流量对比

让我们来作一个测试:

假设 Header 是871字节,

我们以相同的频率 10W/s 去做网络请求, 对比下轮询与 WebSocket 所花费的 Header 流量:

Header 包括请求和响应头信息。

出于兼容性考虑,一般建立 WebSocket 连接也采用 HTTP 请求的方式,那么从这个角度讲:无论请求如何频繁,都只需要一个 Header。

并且 Websocket 的数据传输是 frame 形式传输的,帧传输更加高效,对比轮询的2个 Header,这里只有一个 Header 和一个 frame。

而 Websocket 的frame 仅仅用2个字节就代替了轮询的871字节!

相同的每秒客户端轮询的次数,当次数高达 10W/s 的高频率次数的时候,Polling 轮询需要消耗665Mbps,而 WebSocket 仅仅只花费了1.526Mbps,将近435倍!!

数据参考:

  1. HTML5 WebSocket: A Quantum Leap in Scalability for the Web
  2. 《微信,QQ这类IM app怎么做——谈谈Websocket》

下面探讨下长连接实现方式里的协议选择:

大家都在使用什么技术

最近做了两个 IM 相关的问卷,累计产生了900多条的投票数据:

  1. 《你项目中使用什么协议实现了 IM 即时通讯》
  2. 《IM 即时通讯中你会选用什么数据传输格式?》

注:本次投票是发布在微博@iOS程序犭袁 ,鉴于微博关注机制,本数据只能反映出 IM 技术在 iOS 领域的使用情况,并不能反映出整个IT行业的情况。

下文会对这个投票结果进行下分析。

投票结果 《你项目中使用什么协议实现了 IM 即时通讯》

协议如何选择?

IM 协议选择原则一般是:易于拓展,方便覆盖各种业务逻辑,同时又比较节约流量。后一点的需求在移动端 IM 上尤其重要。常见的协议有:XMPP、SIP、MQTT、私有协议。

我们这里只关注前三名,

名称 优点 缺点
XMPP 优点:协议开源,可拓展性强,在各个端(包括服务器)有各种语言的实现,开发者接入方便; 缺点:缺点也是不少,XML表现力弱、有太多冗余信息、流量大,实际使用时有大量天坑。
MQTT 优点:协议简单,流量少;订阅+推送模式,非常适合Uber、滴滴的小车轨迹的移动。 缺点:它并不是一个专门为 IM 设计的协议,多使用于推送。IM 情景要复杂得多,pub、sub,比如:加入对话、创建对话等等事件。
私有协议 市面上几乎所有主流IM APP都是是使用私有协议,一个被良好设计的私有协议优点非常明显。优点:高效,节约流量(一般使用二进制协议),安全性高,难以破解; 缺点:在开发初期没有现有样列可以参考,对于设计者的要求比较高。

一个好的协议需要满足如下条件:高效,简洁,可读性好,节约流量,易于拓展,同时又能够匹配当前团队的技术堆栈。基于如上原则,我们可以得出: 如果团队小,团队技术在 IM 上积累不够可以考虑使用 XMPP 或者 MQTT+HTTP 短连接的实现。反之可以考虑自己设计和实现私有协议,这里建议团队有计划地迁移到私有协议上。

这里特别提一下排名第二的 WebSocket ,区别于上面的聊天协议,这是一个传输通讯协议,那为什么会有这么多人在即时通讯领域运用了这一协议?除了上文说的长连接特性外,这个协议 web 原生支持,有很多第三方语言实现,可以搭配 XMPP、MQTT 等多种聊天协议进行使用,被广泛地应用于即时通讯领。

社交场景

最大的特点在于:模式成熟,界面类似。

我们专门为社交场景开发的开源组件:ChatKit-OC,star数,1000+。

ChatKit-OC 在协议选择上使用的是 WebSocket 搭配私有聊天协议的方式,在数据传输上选择的是 Protobuf 搭配 JSON 的方式。

项目地址:ChatKit-OC

下文会专门介绍下技术实现细节。

直播场景

一个演示如何为直播集成 IM 的开源直播 Demo:

项目地址:LiveKit-iOS

(这个库,我最近也在优化,打算做成 Lib,支持下 CocoaPods 。希望能帮助大家快速集成直播模块。有兴趣的也欢迎参与进来提 PR)

LiveKit 相较社交场景的特点:

  • 无人数限制的聊天室
  • 自定义消息
  • 打赏机制的服务端配合

有人可能有这样的疑问:

(叫我Elon(读:一龙)就好了)

那么可以看下 Demo 的实现:我们可以看到里面的弹幕、礼物、点赞出心这些都是 IM 系统里的自定义消息。

数据自动更新场景

  • 打车应用场景(Uber、滴滴等 APP 首页的移动小车)
  • 朋友圈状态的实施更新,朋友圈自己发送的消息无需刷新,自动更新

这些场景比聊天要简单许多,仅仅涉及到监听对象的订阅、取消订阅。
正如上文所提到的,使用 MQTT 实现最为经济。用社交类、直播类的思路来做,也可以实现,但略显冗余。

电梯场景(假在线状态处理)

iOS端的假在线的状态,有两种方案:

  • 双向ping pong机制
  • iOS端只走APNs

双向 ping-pong 机制

Message 在发送后,在服务端维护一个表,一段时间内,比如15秒内没有收到 ack,就认为应用处于离线状态,先将用户踢下线,然后转而进行推送。这里如果出现,重复推送,客户端要负责去重。将 Message 消息相当于服务端发送的 Ping 消息,APP 的 ack 作为 pong。

使用 APNs 来作聊天

优缺点:

优点:

  • 解决了,iOS端假在线的问题。

缺点:(APNs的缺点)

  • 无法保证消息的及时性。
  • 让服务端负载过重

APNs不保证消息的到达率,消息会被折叠:

你可能见过这种推送消息:

enter image description here

这中间发生了什么?

当 APNs 向你发送了4条推送,但是你的设备网络状况不好,在 APNs 那里下线了,这时 APNs 到你的手机的链路上有4条任务堆积,APNs 的处理方式是,只保留最后一条消息推送给你,然后告知你推送数。那么其他三条消息呢?会被APNs丢弃。

有一些 App 的 IM 功能没有维持长连接,是完全通过推送来实现的,通常情况下,这些 App 也已经考虑到了这种丢推送的情况,这些 App 的做法都是,每次收到推送之后,然后向自己的服务器查询当前用户的未读消息。但是 APNs 也同样无法保证这四条推送能至少有一条到达你的 App。

为什么这么设计?APNs的存储-转发能力太弱,大量的消息存储和转发将消耗 Apple 服务器的资源,可能是出于存储成本考虑,也可能是因为 Apple 转发能力太弱。总之结果就是 APNs 从来不保证消息的达到率。并且设备上线之后也不会向服务器上传信息。

现在我们可以保证消息一定能推送到 APNs 那里,但是 APNs 不保证帮我们把消息投递给用户。

即使搭配了这样的策略:每次收到推送就拉历史记录的消息,一旦消息被 APNs 丢弃,这条消息可能会在几天之后受到了新推送后才被查询到。

让服务端负载过重:

APNs 的实现原理决定了:必须每次收到消息后,拉取历史消息。这意味着你无法控制 APP 请求服务端的频率,同一时间十万、百万的请求量都是可能的,这带来的负载以及风险,有时甚至会比轮询还要大。

参考:《基于HTTP2的全新APNs协议》

结论:如果面向的目标用户对消息的及时性并不敏感,可以采用这种方案。比如社交场景。(对消息较为敏感的APP则并不适合,比如:专门为情侣间使用的APP。。。)

技术实现细节

###基于 WebSocket 的 IM 系统

WebSocket简介

WebSocket 是 HTML5 开始提供的一种浏览器与服务器间进行全双工通讯的网络技术。 WebSocket 通信协定于2011年被 IETF 定为标准 RFC 6455,WebSocket API 被 W3C 定为标准。

在 WebSocket API 中,浏览器和服务器只需要要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

只从 RFC 发布的时间看来,WebSocket要晚很多,HTTP 1.1是1999年,WebSocket 则是12年之后了。WebSocket 协议的开篇就说,本协议的目的是为了解决基于浏览器的程序需要拉取资源时必须发起多个HTTP请求和长时间的轮训的问题而创建的。可以达到支持 iOS,Android,Web 三端同步的特性。

更多

技术实现细节的部分较长,单独成篇。《技术实现细节》

下面是文章的第二部分:

性能调优 -- 针对移动网络特点的性能调优

极简协议,传输协议 Protobuf

目录如下:

  1. 极简协议,传输协议 Protobuf
  2. 在安全上做了哪些事情?
    1. 防止 DNS 污染
    2. 账户安全
  3. 重连机制
  4. 使用 HTTP/2 减少不必要的网络连接
  5. 设置合理的超时时间
  6. 图片视频等文件上传
  7. 使用缓存:基于 Hash 的本地缓存校验

首先让我们来看下:

IM 即时通讯中你会选用什么数据传输格式?

之前做的调研数据如下:

《IM 即时通讯中你会选用什么数据传输格式?》

注:本次投票是发布在微博@iOS程序犭袁 ,鉴于微博关注机制,本数据只能反映出 IM 技术在 iOS 领域的使用情况,并不能反映出整个IT行业的情况。

排名前三的分别的JSON 、ProtocolBuffer、XML;

这里重点推荐下 ProtocolBuffer:

该协议已经在业内有很多应用,并且效果显著:

使用 ProtocolBuffer 减少 Payload

  • 滴滴打车40%;
  • 携程之前分享过,说是采用新的Protocol Buffer数据格式+Gzip压缩后的Payload大小降低了15%-45%。数据序列化耗时下降了80%-90%。

采用高效安全的私有协议,支持长连接的复用,稳定省电省流量

  1. 【高效】提高网络请求成功率,消息体越大,失败几率随之增加。
  2. 【省流量】流量消耗极少,省流量。一条消息数据用Protobuf序列化后的大小是 JSON 的1/10、XML格式的1/20、是二进制序列化的1/10。同 XML 相比, Protobuf 性能优势明显。它以高效的二进制方式存储,比 XML 小 3 到 10 倍,快 20 到 100 倍。
  3. 【省电】省电
  4. 【高效心跳包】同时心跳包协议对IM的电量和流量影响很大,对心跳包协议上进行了极简设计:仅 1 Byte 。
  5. 【易于使用】开发人员通过按照一定的语法定义结构化的消息格式,然后送给命令行工具,工具将自动生成相关的类,可以支持java、c++、python、Objective-C等语言环境。通过将这些类包含在项目中,可以很轻松的调用相关方法来完成业务消息的序列化与反序列化工作。语言支持:原生支持c++、java、python、Objective-C等多达10余种语言。 2015-08-27 Protocol Buffers v3.0.0-beta-1中发布了Objective-C(Alpha)版本, 2016-07-28 3.0 Protocol Buffers v3.0.0正式版发布,正式支持 Objective-C。
  6. 【可靠】微信和手机 QQ 这样的主流 IM 应用也早已在使用它(采用的是改造过的Protobuf协议)

如何测试验证 Protobuf 的高性能?

对数据分别操作100次,1000次,10000次和100000次进行了测试,

纵坐标是完成时间,单位是毫秒,

反序列化 序列化 字节长度

数据来源

数据来自:项目 thrift-protobuf-compare,测试项为 Total Time,也就是 指一个对象操作的整个时间,包括创建对象,将对象序列化为内存中的字节序列,然后再反序列化的整个过程。从测试结果可以看到 Protobuf 的成绩很好.

缺点:

可能会造成 APP 的包体积增大,通过 Google 提供的脚本生成的 Model,会非常“庞大”,Model 一多,包体积也就会跟着变大。

如果 Model 过多,可能导致 APP 打包后的体积骤增,但 IM 服务所使用的 Model 非常少,比如在 ChatKit-OC 中只用到了一个 Protobuf 的 Model:Message对象,对包体积的影响微乎其微。

在使用过程中要合理地权衡包体积以及传输效率的问题,据说去哪儿网,就曾经为了减少包体积,进而减少了 Protobuf 的使用。

在安全上需要做哪些事情?

防止 DNS 污染

文章较长,单独成篇。《防 DNS 污染方案.md》

账户安全

IM 服务账号密码一旦泄露,危害更加严峻。尤其是对于消息可以漫游的类型。比如:

介绍下我们是如何做到,即使是我们的服务器被攻破,你的用户系统依然不会受到影响:

  1. 帐号安全:

无侵入的权限控制:
与用户的用户帐号体系完全隔离,只需要提供一个ID就可以通信,接入方可以对该 ID 进行 MD5 加密后再进行传输和存储,保证开发者用户数据的私密性及安全。

  1. 签名机制

对关键操作,支持第三方服务器鉴权,保护你的信息安全。

参考: 《实时通信服务总览-权限和认证》

  1. 单点登录

让 APP 支持单点登录,能有限减少盗号造成的安全问题。在 ChatKit-OC 中,我们就默认开启了单点登录功能,以此来提升 APP 的安全性。

重连机制

  • 精简心跳包,保证一个心跳包大小在10字节之内;
  • 减少心跳次数:心跳包只在空闲时发送;从收到的最后一个指令包进行心跳包周期计时而不是固定时间。
  • 重连冷却
    2的指数级增长2、4、8,消息往来也算作心跳。类似于 iPhone 密码的 错误机制,冷却单位是5分钟,依次是5分钟后、10分钟后、15分钟后,10次输错,清除数据。

当然,这样灵活的策略也同样决定了,只能在 APP 层进行心跳ping。

enter image description here

这里有必要提一下重连机制的必要性,我们知道 TCP 也有保活机制,但这个与我们在这里讨论的“心跳保活”机制是有区别的。

TCP 保活(TCP KeepAlive 机制)和心跳保活区别:

TCP保活 心跳保活
在定时时间到后,一般是 7200 s,发送相应的 KeepAlive 探针。,失败后重试 10 次,每次超时时间 75 s。(详情请参见《TCP/IP详解》中第23章) 通常可以设置为3-5分钟发出 Ping
检测连接的死活(对应于下图中的1) 检测通讯双方的存活状态(对应于下图中的2)

保活,究竟保的是谁?

比如:考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态,一直向当前服务器发送些必然会失败的请求。

使用 HTTP/2 减少不必要的网络连接

大多数的移动网络(3G)并不允许一个给定 IP 地址超过两个的并发 HTTP 请求,既当你有两个针对同一个地址的连接时,再发起的第三个连接总是会超时。而2G网络下这个限定为1个。同一时间发起过多的网络请求不仅不会起到加速的效果,反而有副作用。

另一方面,由于网络连接很是费时,保持和共享某一条连接就是一个不错的选择:比如短时间内多次的HTTP请求。

使用 HTTP/2 就可以达到这样的目的。

HTTP/2 是 HTTP 协议发布后的首个更新,于2015年2月17日被批准。它采用了一系列优化技术来整体提升 HTTP 协议的传输性能,如异步连接复用、头压缩等等,可谓是当前互联网应用开发中,网络层次架构优化的首选方案之一。

HTTP/2 也以高复用著称,而且如果我们要使用 HTTP/2,那么在网络库的选择上必然要使用 NSURLSession。所以 AFN2.x 也需要升级到AFN3.x.

设置合理的超时时间

过短的超时容易导致连接超时的事情频频发生,甚至一直无法连接,而过长的超时则会带来等待时间过长,体验差的问题。就目前来看,对于普通的TCP连接30秒是个不错的超时值,而Http请求可以按照重要性和当前网络情况动态调整超时,尽量将超时控制在一个合理的数值内,以提高单位时间内网络的利用率。

图片视频等文件上传

图片格式优化在业界已有成熟的方案,例如 Facebook 使用的 WebP 图片格式,已经被国内众多 App 使用。

分片上传、断点续传、秒传技术、

  • 文件分块上传:因为移动网络丢包严重,将文件分块上传可以使得一个分组包含合理数量的TCP包,使得重试概率下降,重试代价变小,更容易上传到服务器;
  • 提供文件秒传的方式:服务器根据MD5、SHA进行文件去重;
  • 支持断点续传。
  • 上传失败,合理的重连,比如3次。

使用缓存:基于 Hash 的本地缓存校验

微信是不用考虑消息同步问题,因为微信是不存储历史记录的,卸载重装消息记录就会丢失。

所以我们可以采用一个类似 E-Tag、Last-Modified 的本地消息缓存校验机制,具体做法就是,当我们想加载最近10条的聊天记录时,先将本地缓存的最近10条做一个 hash 值,将 hash 值发送给服务端,服务端将服务端的最近十条做一个 hash ,如果一致就返回304。最理想的情况是服务端一直返回304,一直加载本地记录。这样做的好处:

  • 消息同步
  • 节省流量

IM系列文章

IM 系列文章分为下面这几篇:

本文是第一篇。


Posted by 微博@iOS程序犭袁
原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

all-reward

KVO crash 自修复技术实现与原理解析

前言

【前言】KVO API设计非常不合理,于是有很多的KVO三方库,比如 KVOController 用更优的API来规避这些crash,但是侵入性比较大,必须编码规范来约束所有人都要使用该方式。有没有什么更优雅,无感知的接入方式?

简介

KVO crash 也是非常常见的 Crash 类型,在探讨 KVO crash 原因前,我们先来看一下传统的KVO写发:

#warning move this to top of .m file
//#define MyKVOContext(A) static void * const A = (void*)&A;
static void * const MyContext = (void*)&MyContext;

#warning move this to viewdidload or init method 
   // KVO注册监听:
   // _A 监听 _B  的 @"keyPath"  属性
   //[self.B  addObserver: self.A forKeyPath:@"keyPath" options:NSKeyValueObservingOptionNew context:MyContext];

- (void)dealloc {
   // KVO反注册
   [_B removeObserver:_A forKeyPath:@"keyPath"];
}

// KVO监听执行 
#warning — please move this method to  the class of _A  
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
   if(context != MyContext) {
       [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
       return;
   }
   if(context == MyContext) {
   //if ([keyPath isEqualToString:@"keyPath"]) {
       id newKey = change[NSKeyValueChangeNewKey];
       BOOL boolValue = [newKey boolValue];
       
   }
}

看到如上的写发,大概我们就明白了 API 设计不合理的地方:

B 需要做的工作太多,B可能引起Crash的点也太多:

B 需要主动移除监听者的时机,否则就crash:

  • B 在释放变为nil后,hook dealloc时机
  • A 在释放变为nil后 否则报错 Objective-C Thread 1: EXC_BAD_ACCESS (code=EXC_I386_GPFLT)

KVO的被观察者dealloc时仍然注册着KVO导致的crash

B 不能移除监听者A的时机,否则就crash:

  • B没有被A监听
  • B已经移除A的监听。

添加KVO重复添加观察者或重复移除观察者(KVO 注册观察者与移除观察者不匹配)导致的crash。

采取的措施:

  • B添加A监听的时候,避免重复添加,移除的时候避免重复移除。
  • B dealloc时及时移除 A
  • A dealloc时,让 B 移除A。
  • 避免重复添加,避免重复移除。

报错信息一览:

2018-01-24 16:08:54.100667+0800 BootingProtection[63487:29487624] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: '<CYLObserverView: 0x7fb287002fb0; frame = (0 0; 207 368); layer = <CALayer: 0x604000039360>>: An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.

其他情况的crash

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'An instance 0x7f8827d21d20 of class XXXX was deallocated while key value observers were still registered with it. Current observation info: <NSKeyValueObservationInfo 0x61000003db00> (
<NSKeyValueObservance 0x61000025ae80: Observer: 0x7f882890b4c0, Key path: dataSource, Options: <New: YES, Old: NO, Prior: NO> Context: 0x10dfe7730, Property: 0x61000025b810>
)'
*** First throw call stack:
(
   0   CoreFoundation                      0x00000001102b0b0b __exceptionPreprocess + 171
   1   libobjc.A.dylib                     0x00000001167eb141 objc_exception_throw + 48
   2   CoreFoundation                      0x0000000110319625 +[NSException raise:format:] + 197
   3   Foundation                          0x0000000111322b53 NSKVODeallocate + 294
   4   UIKit                               0x00000001138ec544 __destroy_helper_block_.125 + 80
   5   libsystem_blocks.dylib              0x00000001185a999d _Block_release + 111
   6   UIKit                               0x00000001139bd187 -[UIViewAnimationBlockDelegate .cxx_destruct] + 43
   7   libobjc.A.dylib                     0x00000001167e99bc _ZL27object_cxxDestructFromClassP11objc_objectP10objc_class + 127
   8   libobjc.A.dylib                     0x00000001167f5d34 objc_destructInstance + 129
   9   libobjc.A.dylib                     0x00000001167f5d66 object_dispose + 22
   10  libobjc.A.dylib                     0x00000001167ffb8e _ZN11objc_object17sidetable_releaseEb + 202
   11  CoreFoundation                      0x000000011021952d -[__NSDictionaryI dealloc] + 125
   12  libobjc.A.dylib                     0x00000001167ffb8e _ZN11objc_object17sidetable_releaseEb + 202
   13  libobjc.A.dylib                     0x00000001168002fa _ZN12_GLOBAL__N_119AutoreleasePoolPage3popEPv + 866
   14  CoreFoundation                      0x00000001101ffe96 _CFAutoreleasePoolPop + 22
   15  CoreFoundation                      0x000000011023baec __CFRunLoopRun + 2172
   16  CoreFoundation                      0x000000011023b016 CFRunLoopRunSpecific + 406
   17  GraphicsServices                    0x0000000118f1ea24 GSEventRunModal + 62
   18  UIKit                               0x0000000113904134 UIApplicationMain + 159
   19  HaiDiLao                            0x000000010d50b5ef main + 111
   20  libdyld.dylib                       0x000000011856265d start + 1
   21  ???                                 0x0000000000000001 0x0 + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException

防crash措施

于是有很多的KVO三方库,比如 KVOController 用更优的API来规避这些crash,但是侵入性比较大,必须编码规范来约束所有人都要使用该方式。有没有什么更优雅,无感知的接入方式?

那便是我们下面要讲的 KVO crash 防护机制。

我们可以对比下其他的一些KVO防护方案:

网络上有一些类似的方案,“大白健康系统”方案大致如下:

KVO的被观察者dealloc时仍然注册着KVO导致的crash 的情况,可以将NSObject的dealloc swizzle, 在object dealloc的时候自动将其对应的kvodelegate所有和kvo相关的数据清空,然后将kvodelegate也置空。避免出现KVO的被观察者dealloc时仍然注册着KVO而产生的crash

这样未免太过麻烦,我们可以借助第三方库 CYLDeallocBlockExecutor hook 任意一个对象的 dealloc 时机,然后在 dealloc 前进行我们需要进行的操作,因此也就不需要为 NSObject 加 flag 来进行全局的筛选。flag 效率非常底,影响 app 性能。

“大白健康系统”思路是建立一个delegate,观察者和被观察者通过delegate间接建立联系,由于没有demo源码,这种方案比较繁琐。可以考虑建立一个哈希表,用来保存观察者、keyPath的信息,如果哈希表里已经有了相关的观察者,keyPath信息,那么继续添加观察者的话,就不载进行添加,同样移除观察的时候,也现在哈希表中进行查找,如果存在观察者,keypath信息,那么移除,如果没有的话就不执行相关的移除操作。要实现这样的思路就需要用到methodSwizzle来进行方法交换。我这通过写了一个NSObject的cagegory来进行方法交换。示例代码如下:

下面是核心的swizzle方法:

原函数 swizzle后的函数
addObserver:forKeyPath:options:context: cyl_crashProtectaddObserver:forKeyPath:options:context:
removeObserver:forKeyPath: cyl_crashProtectremoveObserver:forKeyPath:
removeObserver:forKeyPath:context: cyl_crashProtectremoveObserver:forKeyPath:context:
- (void)cyl_crashProtectaddObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context{

   if (!observer || !keyPath || keyPath.length == 0) {
       return;
   }
   
   @synchronized (self) {
       NSInteger kvoHash = [self _cyl_crashProtectHash:observer :keyPath];
       if (!self.KVOHashTable) {
           self.KVOHashTable = [NSHashTable hashTableWithOptions:NSPointerFunctionsStrongMemory];
       }
       
       if (![self.KVOHashTable containsObject:@(kvoHash)]) {
           [self.KVOHashTable addObject:@(kvoHash)];
           [self cyl_crashProtectaddObserver:observer forKeyPath:keyPath options:options context:context];
           [self cyl_willDeallocWithSelfCallback:^(__unsafe_unretained id observedOwner, NSUInteger identifier) {
               [observedOwner cyl_crashProtectremoveObserver:observer forKeyPath:keyPath context:context];
           }];
           __unsafe_unretained typeof(self) unsafeUnretainedSelf = self;
           [observer cyl_willDeallocWithSelfCallback:^(__unsafe_unretained id observerOwner, NSUInteger identifier) {
               [unsafeUnretainedSelf cyl_crashProtectremoveObserver:observerOwner forKeyPath:keyPath context:context];
           }];
       }
   }

}

- (void)cyl_crashProtectremoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context {
   //TODO:  加上 context 限制,防止父类、子类使用同一个keyPath。
   [self cyl_crashProtectremoveObserver:observer forKeyPath:keyPath];

}

- (void)cyl_crashProtectremoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath{
   //TODO:  white list
   if (!observer || !keyPath || keyPath.length == 0) {
       return;
   }
   @synchronized (self) {
       if (!observer) {
           return;
       }
       NSInteger kvoHash = [self _cyl_crashProtectHash:observer :keyPath];
       NSHashTable *hashTable = [self KVOHashTable];
       if (!hashTable) {
           return;
       }
       if ([hashTable containsObject:@(kvoHash)]) {
           [self cyl_crashProtectremoveObserver:observer forKeyPath:keyPath];
           [hashTable removeObject:@(kvoHash)];
       }
   }

}

之后我们就可以模拟dealloc中不写removeObserver,同时也可以写,
同时也可以多次 addObserverremoveObserver 这样就完全不干扰我们平时的代码书写逻辑了。

连续启动 crash 自修复技术实现与原理解析

博客文章

前言

如果 app 连续 crash 两次无法启动,用户往往会选择卸载。

连续启动 crash 应该是 crash 类型中最严重的一类,该问题常常与数据库操作有关,比如:数据库损坏、服务端返回数据错误,存入数据库,app 读取时产生数组越界、找不到方法。

那么除了热修复,能否“自修复”该问题呢?

在微信读书团队发布的《iOS 启动连续闪退保护方案》 一文中,给出了连续启动crash的自修复技术的思路讲解,并在GitHub上给出了技术实现,并开源了 GYBootingProtection。方案思路很好,很轻量级。

实现原理

在微信读书团队给出的文章中已经有比较详细的阐述,在此不做赘述,实现的流程图如下所示:

但有个实现上可以优化下,可以降低50%以上误报机率,监听用户手动划掉 APP 这个事件,其中一些特定场景,是可以获取的。另外在这里也给出对其 API 设计的建议。最后给出优化后的实现。

优化:降低50%以上误报机率

用户主动 kill 掉 APP 分为两种情况:

  • App在前台时用户手动划掉APP的时候
  • APP在后台时划掉APP

第一种场景更为常见,可以通过监听 UIApplicationWillTerminateNotification 来捕获该动作,捕获后恢复计数。第二种情况,无法监听到。但也足以降低 50% 以上的误报机率。

对原有API设计的几点优化意见

1. 机制状态应当用枚举来做为API透出

该机制当前所处的状态,比如:NeedFix 、isFixing,建议用枚举来做为API透出。比如:

  • APP 启动正常
  • 正在检测是否会在特定时间内是否会 Crash,注意:检测状态下“连续启动崩溃计数”个数小于或等于上限值
  • APP 出现连续启动 Crash,需要采取修复措施
  • APP 出现连续启动 Crash,正在修复中

2. 关键数值应当做为初始化参数供用户设置

  • 当前启动Crash的状态

  • 达到需要执行上报操作的“连续启动崩溃计数”个数。

  • 达到需要执行修复操作的“连续启动崩溃计数”个数。

  • APP 启动后经过多少秒,可以将“连续启动崩溃计数”清零

3. 修复、上报逻辑应当支持用户异步操作

reportBlock 上报逻辑,
repairtBlock 修复逻辑

比如:

typedef void (^BoolCompletionHandler)(BOOL succeeded, NSError *error);
typedef void (^RepairBlock)(ABSBoolCompletionHandler completionHandler);

用户执行 BoolCompletionHandler 后即可知道是否执行完毕,并且支持异步操作。

异步操作带来的问题,可以通过前面提到的枚举API来实时监测状态,来决定各种其他操作。

什么时候会出现该异常?

连续启动 crash 自修复技术实现与原理解析

下面给出优化后的代码实现:

//
//  CYLBootingProtection.h
//  
//
//  Created by ChenYilong on 18/01/10.
//  Copyright © 2018年 ChenYilong. All rights reserved.
//

#import <Foundation/Foundation.h>

typedef void (^ABSBoolCompletionHandler)(BOOL succeeded, NSError *error);
typedef void (^ABSRepairBlock)(ABSBoolCompletionHandler completionHandler);
typedef void (^ABSReportBlock)(NSUInteger crashCounts);

typedef NS_ENUM(NSInteger, BootingProtectionStatus) {
   BootingProtectionStatusNormal,  /**<  APP 启动正常 */
   BootingProtectionStatusNormalChecking,  /**< 正在检测是否会在特定时间内是否会 Crash,注意:检测状态下“连续启动崩溃计数”个数小于或等于上限值 */
   BootingProtectionStatusNeedFix, /**< APP 出现连续启动 Crash,需要采取修复措施 */
   BootingProtectionStatusFixing,   /**< APP 出现连续启动 Crash,正在修复中... */
};

/**
* 启动连续 crash 保护。
* 启动后 `_crashOnLaunchTimeIntervalThreshold` 秒内 crash,反复超过 `_continuousCrashOnLaunchNeedToReport` 次则上报日志,超过 `_continuousCrashOnLaunchNeedToFix` 则启动修复操作。
*/
@interface CYLBootingProtection : NSObject

/**
* 启动连续 crash 保护方法。
* 前置条件:在 App 启动时注册 crash 处理函数,在 crash 时调用[CYLBootingProtection addCrashCountIfNeeded]。
* 启动后一定时间内(`crashOnLaunchTimeIntervalThreshold`秒内)crash,反复超过一定次数(`continuousCrashOnLaunchNeedToReport`次)则上报日志,超过一定次数(`continuousCrashOnLaunchNeedToFix`次)则启动修复程序;在一定时间内(`crashOnLaunchTimeIntervalThreshold`秒) 秒后若没有 crash 将“连续启动崩溃计数”计数置零。
 `reportBlock` 上报逻辑,
 `repairtBlock` 修复逻辑,完成后执行 `[self setCrashCount:0]`

*/
- (void)launchContinuousCrashProtect;

/*!
* 当前启动Crash的状态
*/
@property (nonatomic, assign, readonly) BootingProtectionStatus bootingProtectionStatus;

/*!
* 达到需要执行上报操作的“连续启动崩溃计数”个数。
*/
@property (nonatomic, assign, readonly) NSUInteger continuousCrashOnLaunchNeedToReport;

/*!
* 达到需要执行修复操作的“连续启动崩溃计数”个数。
*/
@property (nonatomic, assign, readonly) NSUInteger continuousCrashOnLaunchNeedToFix;

/*!
* APP 启动后经过多少秒,可以将“连续启动崩溃计数”清零
*/
@property (nonatomic, assign, readonly) NSTimeInterval crashOnLaunchTimeIntervalThreshold;

/*!
* 借助 context 可以让多个模块注册事件,并且事件 block 能独立执行,互不干扰。
*/
@property (nonatomic, copy, readonly) NSString *context;

/*!
* @details 启动后kCrashOnLaunchTimeIntervalThreshold秒内crash,反复超过continuousCrashOnLaunchNeedToReport次则上报日志,超过continuousCrashOnLaunchNeedToFix则启动修复程序;当所有操作完成后,执行 completion。在 crashOnLaunchTimeIntervalThreshold 秒后若没有 crash 将 kContinuousCrashOnLaunchCounterKey 计数置零。
* @param context 借助 context 可以让多个模块注册事件,并且事件 block 能独立执行,互不干扰。
*/
- (instancetype)initWithContinuousCrashOnLaunchNeedToReport:(NSUInteger)continuousCrashOnLaunchNeedToReport
                          continuousCrashOnLaunchNeedToFix:(NSUInteger)continuousCrashOnLaunchNeedToFix
                        crashOnLaunchTimeIntervalThreshold:(NSTimeInterval)crashOnLaunchTimeIntervalThreshold
                                                   context:(NSString *)context;
/*!
* 当前“连续启动崩溃“的状态
*/
+ (BootingProtectionStatus)bootingProtectionStatusWithContext:(NSString *)context continuousCrashOnLaunchNeedToFix:(NSUInteger)continuousCrashOnLaunchNeedToFix;

/*!
* 设置上报逻辑,参数 crashCounts 为启动连续 crash 次数
*/
- (void)setReportBlock:(ABSReportBlock)reportBlock;

/*!
* 设置修复逻辑
*/
- (void)setRepairBlock:(ABSRepairBlock)repairtBlock;

+ (void)setLogger:(void (^)(NSString *))logger;

@end
//
//  CYLBootingProtection.m
//
//
//  Created by ChenYilong on 18/01/10.
//  Copyright © 2018年 ChenYilong. All rights reserved.
//

#import "CYLBootingProtection.h"
#import <UIKit/UIKit.h>

static dispatch_queue_t _exceptionOperationQueue = 0;
void (^Logger)(NSString *log);

@interface CYLBootingProtection ()

@property (nonatomic, assign) NSUInteger continuousCrashOnLaunchNeedToReport;
@property (nonatomic, assign) NSUInteger continuousCrashOnLaunchNeedToFix;
@property (nonatomic, assign) NSTimeInterval crashOnLaunchTimeIntervalThreshold;
@property (nonatomic, copy) NSString *context;
@property (nonatomic, copy) ABSReportBlock reportBlock;
@property (nonatomic, copy) ABSRepairBlock repairBlock;

/*!
* 设置“连续启动崩溃计数”个数
*/
- (void)setCrashCount:(NSInteger)count;

/*!
* 设置“连续启动崩溃计数”个数
*/
+ (void)setCrashCount:(NSUInteger)count context:(NSString *)context;

/*!
* “连续启动崩溃计数”个数
*/
- (NSUInteger)crashCount;

/*!
* “连续启动崩溃计数”个数
*/
+ (NSUInteger)crashCountWithContext:(NSString *)context;

@end

@implementation CYLBootingProtection
+ (void)initialize {
   static dispatch_once_t onceToken;
   dispatch_once(&onceToken, ^{
       _exceptionOperationQueue = dispatch_queue_create("com.ChenYilong.CYLBootingProtection.fileCacheQueue", DISPATCH_QUEUE_SERIAL);
   });
}
- (instancetype)initWithContinuousCrashOnLaunchNeedToReport:(NSUInteger)continuousCrashOnLaunchNeedToReport
                          continuousCrashOnLaunchNeedToFix:(NSUInteger)continuousCrashOnLaunchNeedToFix
                        crashOnLaunchTimeIntervalThreshold:(NSTimeInterval)crashOnLaunchTimeIntervalThreshold
                                                   context:(NSString *)context {
   if (!(self = [super init])) {
       return nil;
   }
   _continuousCrashOnLaunchNeedToReport = continuousCrashOnLaunchNeedToReport;
   _continuousCrashOnLaunchNeedToFix = continuousCrashOnLaunchNeedToFix;
   _crashOnLaunchTimeIntervalThreshold = crashOnLaunchTimeIntervalThreshold;
   _context = [context copy];
   [[NSNotificationCenter defaultCenter] addObserver:self
                                            selector:@selector(applicationWillTerminate:)
                                                name:UIApplicationWillTerminateNotification
                                              object:[UIApplication sharedApplication]];
   return self;
}

/*!
* App在前台时用户手动划掉APP的时候,不计入检测。
* 但是APP在后台时划掉APP,无法检测出来。
* 见:https://stackoverflow.com/a/35041565/3395008
*/
- (void)applicationWillTerminate:(NSNotification *)note {
   BOOL isNormalChecking = [self isNormalChecking];
   if (isNormalChecking) {
       [self decreaseCrashCount];
   }
}

- (void)dealloc {
   [[NSNotificationCenter defaultCenter] removeObserver:self];
}

/*
支持同步修复、异步修复,两种修复方式
- 异步修复,不卡顿主UI,但有修复未完成就被再次触发crash、或者用户kill掉的可能。需要用户手动根据修复状态,来选择性地进行操作,应该有回掉。
- 同步修复,最简单直观,在主线程删除或者下载修复包。
*/
- (void)launchContinuousCrashProtect {
   NSAssert(_repairBlock, @"_repairBlock is nil!");
   [[self class] Logger:@"CYLBootingProtection: Launch continuous crash report"];
   [self resetBootingProtectionStatus];
   
   NSUInteger launchCrashes = [self crashCount];
   // 上报
   if (launchCrashes >= self.continuousCrashOnLaunchNeedToReport) {
       NSString *logString = [NSString stringWithFormat:@"CYLBootingProtection: App has continuously crashed for %@ times. Now synchronize uploading crash report and begin fixing procedure.", @(launchCrashes)];
       [[self class] Logger:logString];
       if (_reportBlock) {
           dispatch_async(dispatch_get_main_queue(),^{
               _reportBlock(launchCrashes);
           });
       }
   }
   
   // 修复
   if ([self isUpToBootingProtectionCount]) {
       [[self class] Logger:@"need to repair"];
       [self setIsFixing:YES];
       if (_repairBlock) {
           ABSBoolCompletionHandler completionHandler = ^(BOOL succeeded, NSError *__nullable error){
               if (succeeded) {
                   [self resetCrashCount];
               } else {
                   [[self class] Logger:error.description];
               }
           };
           dispatch_async(dispatch_get_main_queue(),^{
               _repairBlock(completionHandler);
           });
       }
   } else {
       [self increaseCrashCount:launchCrashes];
       // 正常流程,无需修复
       [[self class] Logger:@"need no repair"];
       
       // 记录启动时刻,用于计算启动连续 crash
       // 重置启动 crash 计数
       dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.crashOnLaunchTimeIntervalThreshold * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void){
           // APP活过了阈值时间,重置崩溃计数
           NSString *logString = [NSString stringWithFormat:@"CYLBootingProtection: long live the app ( more than %@ seconds ), now reset crash counts", @(self.crashOnLaunchTimeIntervalThreshold)];
           [[self class] Logger:logString];
           [self resetCrashCount];
       });
   }
}

//减少计数的时机:用户手动划掉APP
- (void)decreaseCrashCount {
   NSUInteger oldCrashCount = [self crashCount];
   [self decreaseCrashCountWithOldCrashCount:oldCrashCount];
}

- (void)decreaseCrashCountWithOldCrashCount:(NSUInteger)oldCrashCount {
   dispatch_sync(_exceptionOperationQueue, ^{
       if (oldCrashCount > 0) {
           [self setCrashCount:oldCrashCount-1];
       }
       [self resetBootingProtectionStatus];
   });
}

//重制计数的时机:修复完成、或者用户手动划掉APP
- (void)resetCrashCount {
   [self setCrashCount:0];
   [self resetBootingProtectionStatus];
}

//只在未达到计数上限时才会增加计数
- (void)increaseCrashCount:(NSUInteger)oldCrashCount {
   dispatch_sync(_exceptionOperationQueue, ^{
       [self setIsNormalChecking:YES];
       [self setCrashCount:oldCrashCount+1];
   });
}

- (void)resetBootingProtectionStatus {
   [self setIsNormalChecking:NO];
   [self setIsFixing:NO];
}

- (BootingProtectionStatus)bootingProtectionStatus {
   return [[self class] bootingProtectionStatusWithContext:_context continuousCrashOnLaunchNeedToFix:_continuousCrashOnLaunchNeedToFix];
}

/*!
*
@attention 注意之所以要检查 `BootingProtectionStatusNormalChecking` 原因如下:

`-launchContinuousCrashProtect` 方法与 `-bootingProtectionStatus` 方法,如果 `-launchContinuousCrashProtect` 先执行,那么会造成如下问题:
假设n为上限,但crash(n-1)次,但是用 `-bootingProtectionStatus` 判断出来,当前已经处于n次了。原因如下:

crash(n-1)次,正常流程,计数+1,变成n次,
随后在检查 `-bootingProtectionStatus` 时,发现已经处于异常状态了,实际是正常状态。所以需要使用`BootingProtectionStatusNormalChecking` 来进行区分。
*/
+ (BootingProtectionStatus)bootingProtectionStatusWithContext:(NSString *)context continuousCrashOnLaunchNeedToFix:(NSUInteger)continuousCrashOnLaunchNeedToFix {
   
   BOOL isNormalChecking = [self isNormalCheckingWithContext:context];
   if (isNormalChecking) {
       return BootingProtectionStatusNormalChecking;
   }
   
   BOOL isUpToBootingProtectionCount = [self isUpToBootingProtectionCountWithContext:context
                                                    continuousCrashOnLaunchNeedToFix:continuousCrashOnLaunchNeedToFix];
   if (!isUpToBootingProtectionCount) {
       return BootingProtectionStatusNormal;
   }
   
   BootingProtectionStatus type;
   BOOL isFixingCrash = [self isFixingCrashWithContext:context];
   if (isFixingCrash) {
       type = BootingProtectionStatusFixing;
   } else {
       type = BootingProtectionStatusNeedFix;
   }
   return type;
}

- (NSUInteger)crashCount {
   return [[self class] crashCountWithContext:_context];
}

- (void)setCrashCount:(NSInteger)count {
   if (count >=0) {
       [[self class] setCrashCount:count context:_context];
   }
}

- (void)setIsFixing:(BOOL)isFixingCrash {
   [[self class] setIsFixing:isFixingCrash context:_context];
}

/*!
* 是否正在修复
*/
- (BOOL)isFixingCrash {
   return [[self class] isFixingCrashWithContext:_context];
}

- (void)setIsNormalChecking:(BOOL)isNormalChecking {
   [[self class] setIsNormalChecking:isNormalChecking context:_context];
}

/*!
* 是否正在检查
*/
- (BOOL)isNormalChecking {
   return [[self class] isNormalCheckingWithContext:_context];
}

+ (NSUInteger)crashCountWithContext:(NSString *)context {
   NSString *continuousCrashOnLaunchCounterKey = [self continuousCrashOnLaunchCounterKeyWithContext:context];
   NSUInteger crashCount = [[NSUserDefaults standardUserDefaults] integerForKey:continuousCrashOnLaunchCounterKey];
   NSString *logString = [NSString stringWithFormat:@"crashCount:%@", @(crashCount)];
   [[self class] Logger:logString];
   return crashCount;
}

+ (void)setCrashCount:(NSUInteger)count context:(NSString *)context {
   NSString *continuousCrashOnLaunchCounterKey = [self continuousCrashOnLaunchCounterKeyWithContext:context];
   NSString *logString = [NSString stringWithFormat:@"setCrashCount:%@", @(count)];
   [[self class] Logger:logString];
   NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
   [defaults setInteger:count forKey:continuousCrashOnLaunchCounterKey];
   [defaults synchronize];
}

+ (void)setIsFixing:(BOOL)isFixingCrash context:(NSString *)context {
   NSString *continuousCrashFixingKey = [[self class] continuousCrashFixingKeyWithContext:context];
   NSString *logString = [NSString stringWithFormat:@"setisFixingCrash:{%@}", @(isFixingCrash)];
   [[self class] Logger:logString];
   NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
   [defaults setBool:isFixingCrash forKey:continuousCrashFixingKey];
   [defaults synchronize];
}

+ (BOOL)isFixingCrashWithContext:(NSString *)context {
   NSString *continuousCrashFixingKey = [[self class] continuousCrashFixingKeyWithContext:context];
   BOOL isFixingCrash = [[NSUserDefaults standardUserDefaults] boolForKey:continuousCrashFixingKey];
   NSString *logString = [NSString stringWithFormat:@"isFixingCrash:%@", @(isFixingCrash)];
   [[self class] Logger:logString];
   return isFixingCrash;
}

+ (void)setIsNormalChecking:(BOOL)isNormalChecking context:(NSString *)context {
   NSString *continuousCrashNormalCheckingKey = [[self class] continuousCrashNormalCheckingKeyWithContext:context];
   NSString *logString = [NSString stringWithFormat:@"setIsNormalChecking:{%@}", @(isNormalChecking)];
   [[self class] Logger:logString];
   NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
   [defaults setBool:isNormalChecking forKey:continuousCrashNormalCheckingKey];
   [defaults synchronize];
}

+ (BOOL)isNormalCheckingWithContext:(NSString *)context {
   NSString *continuousCrashFixingKey = [[self class] continuousCrashNormalCheckingKeyWithContext:context];
   BOOL isFixingCrash = [[NSUserDefaults standardUserDefaults] boolForKey:continuousCrashFixingKey];
   NSString *logString = [NSString stringWithFormat:@"isIsNormalChecking:%@", @(isFixingCrash)];
   [[self class] Logger:logString];
   return isFixingCrash;
}

- (BOOL)isUpToBootingProtectionCount {
   return [[self class] isUpToBootingProtectionCountWithContext:_context continuousCrashOnLaunchNeedToFix:_continuousCrashOnLaunchNeedToFix];
}

+ (BOOL)isUpToBootingProtectionCountWithContext:(NSString *)context continuousCrashOnLaunchNeedToFix:(NSUInteger)continuousCrashOnLaunchNeedToFix {
   BOOL isUpToCount = [self crashCountWithContext:context] >= continuousCrashOnLaunchNeedToFix;
   if (isUpToCount) {
       return YES;
   }
   return NO;
}

- (void)setReportBlock:(ABSReportBlock)block {
   _reportBlock = block;
}

- (void)setRepairBlock:(ABSRepairBlock)block {
   _repairBlock = block;
}

/*!
*  “连续启动崩溃计数”个数,对应的Key
*  默认为 "_CONTINUOUS_CRASH_COUNTER_KEY"
*/
+ (NSString *)continuousCrashOnLaunchCounterKeyWithContext:(NSString *)context {
   BOOL isValid = [[self class] isValidString:context];
   NSString *validContext = isValid ? context : @"";
   NSString *continuousCrashOnLaunchCounterKey = [NSString stringWithFormat:@"%@_CONTINUOUS_CRASH_COUNTER_KEY", validContext];
   return continuousCrashOnLaunchCounterKey;
}

/*!
*  是否正在修复记录,对应的Key
*  默认为 "_CONTINUOUS_CRASH_FIXING_KEY"
*/
+ (NSString *)continuousCrashFixingKeyWithContext:(NSString *)context {
   BOOL isValid = [[self class] isValidString:context];
   NSString *validContext = isValid ? context : @"";
   NSString *continuousCrashFixingKey = [NSString stringWithFormat:@"%@_CONTINUOUS_CRASH_FIXING_KEY", validContext];
   return continuousCrashFixingKey;
}

/*!
*  是否正在检查是否在特定时间内会Crash,对应的Key
*  默认为 "_CONTINUOUS_CRASH_CHECKING_KEY"
*/
+ (NSString *)continuousCrashNormalCheckingKeyWithContext:(NSString *)context {
   BOOL isValid = [[self class] isValidString:context];
   NSString *validContext = isValid ? context : @"";
   NSString *continuousCrashFixingKey = [NSString stringWithFormat:@"%@_CONTINUOUS_CRASH_CHECKING_KEY", validContext];
   return continuousCrashFixingKey;
}

#pragma mark -
#pragma mark - log and util Methods

+ (void)setLogger:(void (^)(NSString *))logger {
   Logger = [logger copy];
}

+ (void)Logger:(NSString *)log {
   if (Logger) Logger(log);
}

+ (BOOL)isValidString:(id)notValidString {
   if (!notValidString) {
       return NO;
   }
   if (![notValidString isKindOfClass:[NSString class]]) {
       return NO;
   }
   NSInteger stringLength = 0;
   @try {
       stringLength = [notValidString length];
   } @catch (NSException *exception) {}
   if (stringLength == 0) {
       return NO;
   }
   return YES;
}

@end

下面是相应的验证步骤:

等待15秒会有对应计数清零的操作日志输出:

2018-01-18 16:25:37.162980+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:CYLBootingProtection: Launch continuous crash report
2018-01-18 16:25:37.163140+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setIsNormalChecking:{0}
2018-01-18 16:25:37.165738+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setisFixingCrash:{0}
2018-01-18 16:25:37.166883+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:crashCount:0
2018-01-18 16:25:37.167102+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:crashCount:0
2018-01-18 16:25:37.167253+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setIsNormalChecking:{1}
2018-01-18 16:25:37.167938+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setCrashCount:1
2018-01-18 16:25:37.168806+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:need no repair



2018-01-18 16:25:52.225197+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:CYLBootingProtection: long live the app ( more than 15 seconds ), now reset crash counts
2018-01-18 16:25:52.225378+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setCrashCount:0
2018-01-18 16:25:52.226234+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setIsNormalChecking:{0}
2018-01-18 16:25:52.226595+0800 BootingProtection[89773:15553277] 🔴类名与方法名:-[AppDelegate onBeforeBootingProtection]_block_invoke(在第45行),描述:setisFixingCrash:{0}

iOS 常见耗电量检测方案调研

iOS 常见耗电量检测方案调研

本文对应 Demo 以及 Markdown 文件在 GitHub 仓库中,文中的错误可以提 PR 到这个文件,我会及时更改。

前言

如果我们想看下我们的 APP 或 SDK 是否耗电,需要给一些数据来展示,所以就对常见的电量测试方案做了一下调研。

影响 iOS 电量的因素,几个典型的耗电场景如下:

  1. 定位,尤其是调用GPS定位
  2. 网络传输,尤其是非Wifi环境
  3. cpu频率
  4. 内存调度频度
  5. 后台运行

系统接口

iOS 10 系统内置的 Setting 里可以查看各个 App 的电池消耗。

enter image description here

系统接口,能获取到整体的电池利用率,以及充电状态。代码演示如下:

   //#import <UIKit/UIDevice.h>
   UIDevice *device = [UIDevice currentDevice];
   device.batteryMonitoringEnabled = YES;
   //UIDevice返回的batteryLevel的范围在0到1之间。
   NSUInteger batteryLevel = device.batteryLevel * 100;
   //获取充电状态
   UIDeviceBatteryState state = device.batteryState;
   if (state == UIDeviceBatteryStateCharging || state == UIDeviceBatteryStateFull) {
       //正在充电和电池已满
   }

这些均不符合我们的检测需求,不能检测固定某一时间段内的电池精准消耗。

测试平台

阿里云移动测试MQC

MQC 调研,结论:没有iOS性能测试,无法提供耗电量指标。

解释 截图
安卓有性能测试项目 enter image description here
安卓的性能测试项目 enter image description here
iOS没有性能测试,无法提供耗电量指标 enter image description here

百度移动云测试中心 MTC 同样没有 iOS 的性能测试。

其他测试平台类似。

常用的电量测试方法:

  1. 硬件测试
  2. 软件工具检测

软件工具检测

下面介绍通过软件 Instrument 来进行耗电检测。

iOS电量测试方法

1.iOS 设置选项 ->开发者选项->logging ->start recording

enter image description here

2.进行需要测试电量的场景操作后进入开发者选项点击stop recording

3.将iOS设备和Mac连接

4.打开Instrument,选择Energy Diagnostics

5.选择 File > Import Logged Data from Device

enter image description here

6.保存的数据以时间轴输出到Instrument面板

enter image description here

其他

  • 测试过程中要断开 iOS设备和电脑、电源的连接
  • 电量使用level为0-20,1/20:表示运行该app,电池生命会有20个小时;20/20:表示运行该app,电池电量仅有1小时的生命
  • 数据不能导出计算,只能手动计算平均值

硬件检测

通过硬件 PowerMonitor 可以精准地获得应用的电量消耗。

步骤如下:

  1. 拆开iOS设备的外壳,找到电池后面的电源针脚。
  2. 连接电源监控器的设备针脚
  3. 运行应用
  4. 测量电量消耗

下图展示了与iPhone的电池针脚连接的电源监控器工具。

enter image description here

可以参考:Using Monsoon Power Monitor with iPhone 5s

  • 可以精准地获得应用的电量消耗。
  • 设备价格 $771.00 USD
  • 需要拆解手机

这样看来,只有 Instrument 的方案更适合,大家有什么方案的话,也可以贴在下面。

all-reward

记一次 CocoaPods 配置错误:新创建Lib,用户手动集成可以,但 CocoaPods 不行

我创建了一个Lib,同时支持手动集成和CocoaPods,但是集成者发现手动集成可以,但是 CocoaPods 不行。最后找到了原因。

对比
目录结构 enter image description here enter image description here
spec写法 将 bundle 文件写在了 s.vendored_frameworks 将 bundle 文件写在 s.resources 下
分析 旧的目录结构是这种,会报错误:Apple Mach-O Linker Error Group: Linker command failed with exit code 1 (use -v to see invocation)enter image description here 新的结构不会报错
错误原因 错误地将bundle文件作为了framework文件,你可以打开[cp]Copy Pods Resources 里面的对应的sh文件,发现是空的enter image description here 而这样的结构,对应sh文件非空,负责把bundle文件作为资源倒入到项目中。

all-reward

苹果在iOS 14中新增加了一个名为 "Clips "的API

  • 更新:[2020-06-23 03:26:21]
  • 注意:查看正文前,请先查看文末的评论,以确认是否有勘误。

本文提到的开发者测试版本(尝鲜版),今天就可以下载,普通用户等秋季吧。

IMAGE

iOS能否抢走微信小程序三分之一的用户?

IMAGE

WWDC20苹果推出了一个新交互,在系统级别中提供三方应用的特定一部分,而无需完整安装应用。该功能将可以让用户扫描二维码就体验某款应用的部分功能。而这个功能和微信小程序极为相似,按照安卓与iOS 2:1 的比例,iOS 可能会抢走微信小程序三分之一的用户。

理由是

互联网现在的争夺战聚焦在了入口之争,Apple clips的优势就是相机入口优势。微信扫码比Apple clips 链路要长一点。

那么iOS能否办到?

如果做起来,仅仅是美团、饿了么等第三产业大厂支持的情况下,就可以办到。

iOS 已经布局了 Apple pay, 以及 Apple 账号登陆,基于此,微信有的优势,iOS系统全部都拥有。可别忘了,iOS是强制要求 app 支持了 Apple 登陆。

想象一个场景,饭店的桌角贴着 APPLE 家的二维码,打开iPhone扫一扫,不需要跳转到美团、微信就可以点餐,不需要手机号登陆,直接使用 Apple ID 就可以登陆美团,Apple Pay就可以付款。

也许可以说:

苹果要求6/30之前所有App都要支持苹果登录可能就是在给它铺路!

Apple ID 登陆账户已经超过2个亿:

https://github.com/ChenYilong

纵观iOS的生活支付场景,有的小伙伴坦言:

  • 在生活中,自从有了NFC系统级本地刷公交车 我从未用过小程序, App clips能够提供Native的体验的话 真的懒得去开小程序了,期待ing。
  • 比如国务院那个通行码, - 我都宁可下个App我也懒得用小程序每次收验证码。

下面详细介绍下Clips

Clips 介绍

在 iOS14 以前,如果你从 iOS 设备上尝试打开一个 APP 的活动链接或扫描同样内容二维码,如果你的设备没安装过这个 APP,链接内容将只在 Safari 中展示。

https://github.com/ChenYilong/iOSBlog/issues/29

同时,应用程序也将提供通用链接,当安装该应用程序时,它可以代替系统浏览器打开该 APP。但该情况可能在 iOS14 中发生改变,在 iOS 14 中发现了一个全新的接口,内部命名为 "Clips"。
https://github.com/ChenYilong/iOSBlog/issues/29

该交互可让开发者在没有完整安装应用的情形下也能提供交互和动态展示内容。Clips 与 iOS14 构建版本中的二维码阅读器(QR Code Reader)直接相关,使用者可通过扫描一个与 App 相关的🔗链接,然后直接从屏幕上出现的卡片上进行互动。

假设你从油管上获得了一个链接,比如说视频的二维码,但你的 iOS 设备上未安装 Youtube 的 APP。有了 iOS 14 的 Clips 接口,就完全可以扫描该链接🔗,视频就会在浮动卡上重现,并显示 native 原生用户界面而非网页。

iOS系统甚至还提供了类似于微信小程序的“最近打开的小程序”菜单,方便用户无需扫码即可查看:

https://github.com/ChenYilong/iOSBlog/issues/29

这里需要注意⚠️ 开发者需定义好应用的哪个模块部分应该被 iOS 下载为 OTA包(Over-The-Air)来进行读取。

并且对包大小做了限制:

https://github.com/ChenYilong/iOSBlog/issues/29

系统级别的浮动卡会提示从 Apple 应用商店下载完整版本的应用,或者如果判断出已经安装了应用,则会显示选项按钮来打开该内容。

Android 安卓手机有一个类似的功能,叫 "Slices",可以在 Google 搜索结果和 Google 助手等地方显示应用的交互部分,所以我们也可以想象这样的整合:Clips API 就是 iOS 版本的 Spotlight。

但 iOS 14 代码中只提到通过二维码来实现。所以也可能相比 Android 的Slice,Clips API更像是微信小程序。

用完即走的设计理念,和微信小程序极为相似:

https://github.com/ChenYilong/iOSBlog/issues/29

https://github.com/ChenYilong/iOSBlog/issues/29

macOS能跑iOS程序

iOS生态迁移到macOS,两年后 Mac上刷抖音、吃鸡(mac 刺激战场?外设是个问题?)不是梦。

https://github.com/ChenYilong

Rosetta 2

https://github.com/ChenYilong/iOSBlog/issues/29

macOS上直接跑iOS程序

https://github.com/ChenYilong/iOSBlog/issues/29

并非近期规划,而是两年的规划
为开发者提供了专属开发硬件用来适配

https://github.com/ChenYilong/iOSBlog/issues/29

专属os
https://github.com/ChenYilong/iOSBlog/issues/29

为了上述的生态迁移,Apple 打算抛弃 Intel 芯片,改用自家的芯片:

有小伙伴评价说:想想打包的时候,不用再烦x86,挺好。

https://github.com/ChenYilong

https://github.com/ChenYilong/iOSBlog/issues/29
https://github.com/ChenYilong/iOSBlog/issues/29

不过可能生态迁移需要等一等:

https://github.com/ChenYilong/iOSBlog/issues/29

更加严格的隐私策略

道高一尺,魔高一丈。以后开发者用定位、后台保活的,用户都会知道。目前市面上太多app了,运用不太规范的后台保活策略。PS:看来: Google 和 fb 又要想办法了。

https://github.com/ChenYilong/iOSBlog/issues/29
https://github.com/ChenYilong/iOSBlog/issues/29
https://github.com/ChenYilong/iOSBlog/issues/29
https://github.com/ChenYilong/iOSBlog/issues/29
https://github.com/ChenYilong/iOSBlog/issues/29
https://github.com/ChenYilong/iOSBlog/issues/29

Widgets iOS 和 macOS上成为重要交互组件。

https://github.com/ChenYilong

Message 加了群聊功能

Apple开始这次开始给 Message 加了群聊功能,想做成:微信、WhatsApp、tg。

https://github.com/ChenYilong

可以艾特群成员。

https://github.com/ChenYilong

谁最活跃,谁头像最大。这个设定很有趣。

https://github.com/ChenYilong

Apple map 加入了非机动车导航模式

国内开车导航的人少,Apple map 一直没有流行,这次加入了非机动车导航模式,还支持了**,贴心得加入限行的策略提示:

https://github.com/ChenYilong

https://github.com/ChenYilong

iPhone 可以当车钥匙

可惜就只有一款BMW的车支持。(我总不能因为手机去买辆新车吧。。。)

AirPods pro 提供沉浸式体验。

耳机同时连接多个设备,

https://github.com/ChenYilong

macOS越来越像iOS了

https://github.com/ChenYilong

https://github.com/ChenYilong

https://github.com/ChenYilong

尤其这个控制中心,太iOS了。

https://github.com/ChenYilong

macOS 的 Safari 性能超过 Chrome

开始推插件了,卖点是安全🔐。隐私保护。dockduckgo的思路。不过大部分开发者不用 safri ,是因为 dev tool 不好用。期待将来完善。

还加入了类似 chrome的页面翻译功能

https://github.com/ChenYilong

https://github.com/ChenYilong

https://github.com/ChenYilong

Apple 芯片的历史时刻

Apple 对自家芯片的历史时刻做了回顾,有兴趣可以翻看下:

https://github.com/ChenYilong

原文链接:#29

听说点击一次“在看”

iOS14 适配工作就少一项

“在看”👇👇

[EN]How to Fullscreen Xcode and Simulator?[CN]如何让 Xcode10 与模拟器同时进入分屏模式?

源地址:https://www.weibo.com/1692391497/Hkpc4iStj

《如何让 Xcode10 与模拟器同时进入分屏模式》因为模拟器默认不支持全屏,所以没法跟 Xcode 一起分屏,断点时候屏幕跳来跳去就很烦,不过下面的操作可以开启模拟器的全屏支持。
Xcode9 上分屏模式的命令,在 Xcode10 上已经不奏效了,原因跟显示器没关系。在 Xcode10 上必须要先禁用系统的 SIP 功能才可以实现。
所以这个 feature 应该是Apple的 A/B-Testing,估计会在将来放开。
视频里演示了步骤。Apple 在 Xcode10 去掉了 Xcode9 上隐藏菜单 Internal 和手动开启模拟器分屏选项的显示,但按照视频这个操作可以自动开启分屏模式。用到的命令按顺序分别是:
$ csrutil disable
$ sudo mkdir /AppleInternal
$ defaults write com.apple.iphonesimulator AllowFullscreenMode -bool YES
$ csrutil enable

这个和iPad的分屏有点像,除了Xcode的,其他的可以分屏的也行。只要是可以全屏模式的,理论上都可以。本操作主要是开启模拟器的全屏模式。

点此查看视频

iOS 防 DNS 污染方案调研(二)--- SNI 业务场景

iOS 防 DNS 污染方案调研--- SNI 业务场景

对应的GitHub仓库镜像地址在这里 ,欢迎提PR进行修改。

概述

SNI(单IP多HTTPS证书)场景下,iOS上层网络库 NSURLConnection/NSURLSession 没有提供接口进行 SNI 字段 配置,因此需要 Socket 层级的底层网络库例如 CFNetwork,来实现 IP 直连网络请求适配方案。而基于 CFNetwork 的解决方案需要开发者考虑数据的收发、重定向、解码、缓存等问题(CFNetwork是非常底层的网络实现)。

针对 SNI 场景的方案, Socket 层级的底层网络库,大致有两种:

  • 基于 CFNetWork ,hook 证书校验步骤。
  • 基于原生支持设置 SNI 字段的更底层的库,比如 libcurl。

下面将目前面临的一些挑战,以及应对策略介绍一下:

支持 Post 请求

使用 NSURLProtocol 拦截 NSURLSession 请求丢失 body,故有以下几种解决方法:

方案如下:

  1. 换用 NSURLConnection
  2. 将 body 放进 Header 中
  3. 使用 HTTPBodyStream 获取 body,并赋值到 body 中
  4. 换用 Get 请求,不使用 Post 请求。

对方案做以下分析

  • 换用 NSURLConnection ,不多说了,与 NSURLSession 相比终究会被淘汰,不作考虑。
  • body放header的方法,2M以下没问题,超过2M会导致请求延迟,超过 10M 就直接 Request timeout。而且无法解决 Body 为二进制数据的问题,因为Header里都是文本数据。
  • 换用 Get 请求,不使用 Post 请求。这个也是可行的,但是毕竟对请求方式有限制,终究还是要解决 Post 请求所存在的问题。如果是基于旧项目做修改,则侵入性太大。这种方案适合新的项目。
  • 另一种方法是我们下面主要要讲的,使用 HTTPBodyStream 获取 body,并赋值到 body 中,具体的代码如下,可以解决上面提到的问题:
//
//  NSURLRequest+CYLNSURLProtocolExtension.h
//
//
//  Created by ElonChan on 28/07/2017.
//  Copyright © 2017 ChenYilong. All rights reserved.
//

#import <Foundation/Foundation.h>

@interface NSURLRequest (CYLNSURLProtocolExtension)

- (NSURLRequest *)cyl_getPostRequestIncludeBody;

@end



//
//  NSURLRequest+CYLNSURLProtocolExtension.h
//
//
//  Created by ElonChan on 28/07/2017.
//  Copyright © 2017 ChenYilong. All rights reserved.
//

#import "NSURLRequest+CYLNSURLProtocolExtension.h"

@implementation NSURLRequest (CYLNSURLProtocolExtension)

- (NSURLRequest *)cyl_getPostRequestIncludeBody {
   return [[self cyl_getMutablePostRequestIncludeBody] copy];
}

- (NSMutableURLRequest *)cyl_getMutablePostRequestIncludeBody {
   NSMutableURLRequest * req = [self mutableCopy];
   if ([self.HTTPMethod isEqualToString:@"POST"]) {
       if (!self.HTTPBody) {
           NSInteger maxLength = 1024;
           uint8_t d[maxLength];
           NSInputStream *stream = self.HTTPBodyStream;
           NSMutableData *data = [[NSMutableData alloc] init];
           [stream open];
           BOOL endOfStreamReached = NO;
           //不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
           while (!endOfStreamReached) {
               NSInteger bytesRead = [stream read:d maxLength:maxLength];
               if (bytesRead == 0) { //文件读取到最后
                   endOfStreamReached = YES;
               } else if (bytesRead == -1) { //文件读取错误
                   endOfStreamReached = YES;
               } else if (stream.streamError == nil) {
                   [data appendBytes:(void *)d length:bytesRead];
               }
           }
           req.HTTPBody = [data copy];
           [stream close];
       }
       
   }
   return req;
}
@end

使用方法:

在用于拦截请求的 NSURLProtocol 的子类中实现方法 +canonicalRequestForRequest: 并处理 request 对象:

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
   return [request cyl_getPostRequestIncludeBody];
}

下面介绍下相关方法的作用:

//NSURLProtocol.h

/*! 
   @method canInitWithRequest:
   @abstract This method determines whether this protocol can handle
   the given request.
   @discussion A concrete subclass should inspect the given request and
   determine whether or not the implementation can perform a load with
   that request. This is an abstract method. Sublasses must provide an
   implementation.
   @param request A request to inspect.
   @result YES if the protocol can handle the given request, NO if not.
*/
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;

/*! 
   @method canonicalRequestForRequest:
   @abstract This method returns a canonical version of the given
   request.
   @discussion It is up to each concrete protocol implementation to
   define what "canonical" means. However, a protocol should
   guarantee that the same input request always yields the same
   canonical form. Special consideration should be given when
   implementing this method since the canonical form of a request is
   used to look up objects in the URL cache, a process which performs
   equality checks between NSURLRequest objects.
   <p>
   This is an abstract method; sublasses must provide an
   implementation.
   @param request A request to make canonical.
   @result The canonical form of the given request. 
*/
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;

翻译下:

//NSURLProtocol.h
/*!
*  @method:创建NSURLProtocol实例,NSURLProtocol注册之后,所有的NSURLConnection都会通过这个方法检查是否持有该Http请求。
@parma :
@return: YES:持有该Http请求NO:不持有该Http请求
*/
+ (BOOL)canInitWithRequest:(NSURLRequest *)request

/*!
*  @method: NSURLProtocol抽象类必须要实现。通常情况下这里有一个最低的标准:即输入输出请求满足最基本的协议规范一致。因此这里简单的做法可以直接返回。一般情况下我们是不会去更改这个请求的。如果你想更改,比如给这个request添加一个title,组合成一个新的http请求。
@parma: 本地HttpRequest请求:request
@return:直接转发
*/

+ (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest *)request

简单说:

  • +[NSURLProtocol canInitWithRequest:] 负责筛选哪些网络请求需要被拦截
  • +[NSURLProtocol canonicalRequestForRequest:] 负责对需要拦截的网络请求NSURLRequest 进行重新构造。

这里有一个注意点:+[NSURLProtocol canonicalRequestForRequest:] 的执行条件是 +[NSURLProtocol canInitWithRequest:] 返回值为 YES

注意在拦截 NSURLSession 请求时,需要将用于拦截请求的 NSURLProtocol 的子类添加到 NSURLSessionConfiguration 中,用法如下:

    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSArray *protocolArray = @[ [CYLURLProtocol class] ];
    configuration.protocolClasses = protocolArray;
    NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];

换用其他提供了SNI字段配置接口的更底层网络库

如果使用第三方网络库:curl, 中有一个 -resolve 方法可以实现使用指定 ip 访问 https 网站,iOS 中集成 curl 库,参考 curl文档

另外有一点也可以注意下,它也是支持 IPv6 环境的,只需要你在 build 时添加上 --enable-ipv6 即可。

curl 支持指定 SNI 字段,设置 SNI 时我们需要构造的参数形如: {HTTPS域名}:443:{IP地址}

假设你要访问. www.example.org ,若IP为 127.0.0.1 ,那么通过这个方式来调用来设置 SNI 即可:

curl *** --resolve 'www.example.org:443:127.0.0.1'

iOS CURL 库

使用libcurl 来解决,libcurl / cURL 至少 7.18.1 (2008年3月30日) 在 SNI 支持下编译一个 SSL/TLS 工具包,curl 中有一个 --resolve 方法可以实现使用指定ip访问https网站。

在iOS实现中,代码如下

   //{HTTPS域名}:443:{IP地址}
   NSString *curlHost = ...;
   _hosts_list = curl_slist_append(_hosts_list, curlHost.UTF8String);
   curl_easy_setopt(_curl, CURLOPT_RESOLVE, _hosts_list);

其中 curlHost 形如:

{HTTPS域名}:443:{IP地址}

_hosts_list 是结构体类型hosts_list,可以设置多个IP与Host之间的映射关系。curl_easy_setopt方法中传入CURLOPT_RESOLVE 将该映射设置到 HTTPS 请求中。

这样就可以达到设置SNI的目的。

我在这里写了一个 Demo:CYLCURLNetworking,里面包含了编译好的支持 IPv6 的 libcurl 包,演示了下如何通过curl来进行类似NSURLSession。

走过的弯路

误以为 iOS11 新 API 可以直接拦截 DNS 解析过程

参考:NEDNSProxyProvider:DNS based on HTTP supported in iOS11

参考链接:

补充说明

文中提到的几个概念:

概念 解释 举例
host 可以是 IP 也可以是 FQDN。 www.xxx.com 或 1.1.1.1
FQDN fully qualified domain name,由主机名和域名两部分组成 www.xxx.com
域名 域名分为全称和简称,全称就是FQDN、简称就是 FQDN 不包括主机名的部分 比如:xxx.com ,也就是www.xxx.com 这个 FQDN 中,www 是主机名,xxx.com 是域名。

文中部分提到的域名,如果没有特殊说明均指的是 FQDN。

避免使用 GCD Global 队列创建 Runloop 常驻线程

避免使用 GCD Global队列创建Runloop常驻线程



本文对应 Demo 以及 Markdown 文件在仓库中,文中的错误可以提 PR 到这个文件,我会及时更改。

目录

GCD Global队列创建线程进行耗时操作的风险

先思考下如下几个问题:

  • 新建线程的方式有哪些?各自的优缺点是什么?
  • dispatch_async 函数分发到全局队列一定会新建线程执行任务么?
  • 如果全局队列对应的线程池如果满了,后续的派发的任务会怎么处置?有什么风险?

答案大致是这样的:dispatch_async 函数分发到全局队列不一定会新建线程执行任务,全局队列底层有一个的线程池,如果线程池满了,那么后续的任务会被 block 住,等待前面的任务执行完成,才会继续执行。如果线程池中的线程长时间不结束,后续堆积的任务会越来越多,此时就会存在 APP crash的风险。

比如:

- (void)dispatchTest1 {
    for (NSInteger i = 0; i< 10000 ; i++) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            [self dispatchTask:i];
        });
    }
}

- (void)dispatchTask:(NSInteger)index {
        //模拟耗时操作,比如DB,网络,文件读写等等
        sleep(30);
        NSLog(@"----:%ld",index);
}

以上逻辑用真机测试会有卡死的几率,并非每次都会发生,但多尝试几次就会复现,伴随前后台切换,crash几率增大。

下面做一下分析:

参看 GCD 源码我们可以看到全局队列的相关源码如下:

DISPATCH_NOINLINE
static void
_dispatch_queue_wakeup_global_slow(dispatch_queue_t dq, unsigned int n)
{
   dispatch_root_queue_context_t qc = dq->do_ctxt;
   uint32_t i = n;
   int r;

   _dispatch_debug_root_queue(dq, __func__);
   dispatch_once_f(&_dispatch_root_queues_pred, NULL,
   		_dispatch_root_queues_init);

#if HAVE_PTHREAD_WORKQUEUES
#if DISPATCH_USE_PTHREAD_POOL
   if (qc->dgq_kworkqueue != (void*)(~0ul))
#endif
   {
   	_dispatch_root_queue_debug("requesting new worker thread for global "
   			"queue: %p", dq);
#if DISPATCH_USE_LEGACY_WORKQUEUE_FALLBACK
   	if (qc->dgq_kworkqueue) {
   		pthread_workitem_handle_t wh;
   		unsigned int gen_cnt;
   		do {
   			r = pthread_workqueue_additem_np(qc->dgq_kworkqueue,
   					_dispatch_worker_thread4, dq, &wh, &gen_cnt);
   			(void)dispatch_assume_zero(r);
   		} while (--i);
   		return;
   	}
#endif // DISPATCH_USE_LEGACY_WORKQUEUE_FALLBACK
#if HAVE_PTHREAD_WORKQUEUE_SETDISPATCH_NP
   	if (!dq->dq_priority) {
   		r = pthread_workqueue_addthreads_np(qc->dgq_wq_priority,
   				qc->dgq_wq_options, (int)i);
   		(void)dispatch_assume_zero(r);
   		return;
   	}
#endif
#if HAVE_PTHREAD_WORKQUEUE_QOS
   	r = _pthread_workqueue_addthreads((int)i, dq->dq_priority);
   	(void)dispatch_assume_zero(r);
#endif
   	return;
   }
#endif // HAVE_PTHREAD_WORKQUEUES
#if DISPATCH_USE_PTHREAD_POOL
   dispatch_pthread_root_queue_context_t pqc = qc->dgq_ctxt;
   if (fastpath(pqc->dpq_thread_mediator.do_vtable)) {
   	while (dispatch_semaphore_signal(&pqc->dpq_thread_mediator)) {
   		if (!--i) {
   			return;
   		}
   	}
   }
   uint32_t j, t_count;
   // seq_cst with atomic store to tail <rdar://problem/16932833>
   t_count = dispatch_atomic_load2o(qc, dgq_thread_pool_size, seq_cst);
   do {
   	if (!t_count) {
   		_dispatch_root_queue_debug("pthread pool is full for root queue: "
   				"%p", dq);
   		return;
   	}
   	j = i > t_count ? t_count : i;
   } while (!dispatch_atomic_cmpxchgvw2o(qc, dgq_thread_pool_size, t_count,
   		t_count - j, &t_count, acquire));

   pthread_attr_t *attr = &pqc->dpq_thread_attr;
   pthread_t tid, *pthr = &tid;
#if DISPATCH_ENABLE_PTHREAD_ROOT_QUEUES
   if (slowpath(dq == &_dispatch_mgr_root_queue)) {
   	pthr = _dispatch_mgr_root_queue_init();
   }
#endif
   do {
   	_dispatch_retain(dq);
   	while ((r = pthread_create(pthr, attr, _dispatch_worker_thread, dq))) {
   		if (r != EAGAIN) {
   			(void)dispatch_assume_zero(r);
   		}
   		_dispatch_temporary_resource_shortage();
   	}
   } while (--j);
#endif // DISPATCH_USE_PTHREAD_POOL
}

对于执行的任务来说,所执行的线程具体是哪个线程,则是通过 GCD 的线程池(Thread Pool)来进行调度,正如Concurrent Programming: APIs and Challenges文章里给的示意图所示:

上面贴的源码,我们关注如下的部分:

其中有一个用来记录线程池大小的字段 dgq_thread_pool_size。这个字段标记着GCD线程池的大小。摘录上面源码的一部分:

uint32_t j, t_count;
  // seq_cst with atomic store to tail <rdar://problem/16932833>
  t_count = dispatch_atomic_load2o(qc, dgq_thread_pool_size, seq_cst);
  do {
  	if (!t_count) {
  		_dispatch_root_queue_debug("pthread pool is full for root queue: "
  				"%p", dq);
  		return;
  	}
  	j = i > t_count ? t_count : i;
  } while (!dispatch_atomic_cmpxchgvw2o(qc, dgq_thread_pool_size, t_count,
  		t_count - j, &t_count, acquire));

从源码中我们可以对应到官方文档 :Getting the Global Concurrent Dispatch Queues里的说法:

A concurrent dispatch queue is useful when you have multiple tasks that can run in parallel. A concurrent queue is still a queue in that it dequeues tasks in a first-in, first-out order; however, a concurrent queue may dequeue additional tasks before any previous tasks finish. The actual number of tasks executed by a concurrent queue at any given moment is variable and can change dynamically as conditions in your application change. Many factors affect the number of tasks executed by the concurrent queues, including the number of available cores, the amount of work being done by other processes, and the number and priority of tasks in other serial dispatch queues.

也就是说:

全局队列的底层是一个线程池,向全局队列中提交的 block,都会被放到这个线程池中执行,如果线程池已满,后续再提交 block 就不会再重新创建线程。这就是为什么 Demo 会造成卡顿甚至冻屏的原因。

避免使用 GCD Global 队列创建 Runloop 常驻线程

在做网路请求时我们常常创建一个 Runloop 常驻线程用来接收、响应后续的服务端回执,比如NSURLConnection、AFNetworking等等,我们可以称这种线程为 Runloop 常驻线程。

正如上文所述,用 GCD Global 队列创建线程进行耗时操作是存在风险的。那么我们可以试想下,如果这个耗时操作变成了 runloop 常驻线程,会是什么结果?下面做一下分析:

先介绍下 Runloop 常驻线程的原理,在开发中一般有两种用法:

  • 单一 Runloop 常驻线程:在 APP 的生命周期中开启了唯一的常驻线程来进行网络请求,常用于网络库,或者有维持长连接需求的库,比如: AFNetworking 、 SocketRocket
  • 多个 Runloop 常驻线程:每进行一次网络请求就开启一条 Runloop 常驻线程,这条线程的生命周期的起点是网络请求开始,终点是网络请求结束,或者网络请求超时。

单一 Runloop 常驻线程

先说第一种用法:

以 AFNetworking 为例,AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
 
+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

多个 Runloop 常驻线程

第二种用法,我写了一个小 Demo 来模拟这种场景,

我们模拟了一个场景:假设所有的网络请求全部超时,或者服务端根本不响应,然后网络库超时检测机制的做法:

#import "Foo.h"

@interface Foo()  {
    NSRunLoop *_runloop;
    NSTimer *_timeoutTimer;
    NSTimeInterval _timeoutInterval;
    dispatch_semaphore_t _sem;
}
@end

@implementation Foo

- (instancetype)init {
    if (!(self = [super init])) {
        return nil;
    }
    _timeoutInterval = 1 ;
    _sem = dispatch_semaphore_create(0);
    // Do any additional setup after loading the view, typically from a nib.
    return self;
}

- (id)test {
    // 第一种方式:
    // NSThread *networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint0:) object:nil];
    // [networkRequestThread start];
    //第二种方式:
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
        [self networkRequestThreadEntryPoint0:nil];
    });
    dispatch_semaphore_wait(_sem, DISPATCH_TIME_FOREVER);
    return @(YES);
}

- (void)networkRequestThreadEntryPoint0:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"CYLTest"];
        _runloop = [NSRunLoop currentRunLoop];
        [_runloop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        _timeoutTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(stopLoop) userInfo:nil repeats:NO];
        [_runloop addTimer:_timeoutTimer forMode:NSRunLoopCommonModes];
        [_runloop run];//在实际开发中最好使用这种方式来确保能runloop退出,做双重的保障[runloop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:(timeoutInterval+5)]];
    }
}

- (void)stopLoop {
    CFRunLoopStop([_runloop getCFRunLoop]);
    dispatch_semaphore_signal(_sem);
}

@end

如果

   for (int i = 0; i < 300 ; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
            [[Foo new] test];
            NSLog(@"🔴类名与方法名:%@(在第%@行),描述:%@", @(__PRETTY_FUNCTION__), @(__LINE__), @"");
        });
    }

以上逻辑用真机测试会有卡死的几率,并非每次都会发生,但多尝试几次就会复现,伴随前后台切换,crash几率增大。

其中我们采用了 GCD 全局队列的方式来创建常驻线程,因为在创建时可能已经出现了全局队列的线程池满了的情况,毕竟 libdispatch 的最大线程数量非常高(具体数值请参考 StackOverflow - Number of threads created by GCD? ),所以 GCD 派发的任务,无法执行,而且我们把超时检测的逻辑放进了这个任务中,所以导致的情况就是,一旦有很多任务的超时检测功能失效了,此时就只能依赖于服务端响应来结束该任务(服务端响应能结束该任务的逻辑在 Demo 中未给出),但是如果再加之服务端不响应,那么任务就永远不会结束。后续的网络请求也会就此 block 住,造成 crash。

如果我们把 GCD 全局队列换成 NSThread 的方式,那么就可以保证每次都会创建新的线程。

注意:文章中只演示的是超时 cancel runloop 的操作,实际项目中一定有其他主动 cancel runloop 的操作,就比如网络请求成功或失败后需要进行cancel操作。代码中没有展示网络请求成功或失败后的 cancel 操作。

Demo 的这种模拟可能比较极端,但是如果你维护的是一个像 AFNetworking 这样的一个网络库,你会放心把创建常驻线程这样的操作交给 GCD 全局队列吗?因为整个 APP 是在共享一个全局队列的线程池,那么如果 APP 把线程池沾满了,甚至线程池长时间占满且不结束,那么 AFNetworking 就自然不能再执行任务了,所以我们看到,即使是只会创建一条常驻线程, AFNetworking 依然采用了 NSThread 的方式而非 GCD 全局队列这种方式。

注释:以下方法存在于老版本AFN 2.x 中。

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
 
+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

正如你所看到的,没有任何一个库会用 GCD 全局队列来创建常驻线程,而你也应该

避免使用 GCD Global 队列来创建 Runloop 常驻线程。

有个朋友担心“NSThread创建太多,应该也会引起线程调度频繁,资源竞争大吧,global 队列创建太多常驻线程,也可能使得系统在global 队列运行的任务迟迟得不到执行吧”,这个担心是有道理的,这个需要在最外层限制并发数量。无限制也会导致线程泄露。

所以也可以说:

避面长期占用GCD全局队列进行耗时操作


Posted by Posted by 微博@iOS程序犭袁 & 公众号@iTeaTime技术清谈
原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

SB 是如何适配黑暗模式的?

  • 更新:2020-05-11-23:30:19。
  • 注意:查看正文前,请先查看文末的评论,以确认是否有勘误。

最近@Iteatime(技术清谈) 群里聊到了 SB、XIB 的暗黑模式适配问题,聊天内容如下:

聊天信息贴出前已征得群成员同意

今天详细介绍下方法:
内容来自 Table of Contents for iOS Programming: The Big Nerd Ranch Guide, 7th Edition
@Iteatime(技术清谈) 翻译整理。

适应黑暗模式

iOS 支持全系统的暗黑外观,称为暗黑模式,Apple 设计指南上建议所有 iOS 应用应该遵循用户的显示模式的设置偏好。今天我们将学习如何适配暗黑模式:

适配前:

黑暗模式下的显示

我们的任务:
适配后:

完成的APP颜色

让我们开始吧!

正文

首先我们来看看 Demo 目前在暗模式下的样子。

在应用程序仍在运行的情况下,点击Xcode调试工具栏中的 按钮,打开环境覆盖菜单,将环境更改为暗模式。 打开界面风格开关并选择暗模式。

选择黑暗模式环境覆盖。

选择黑暗模式环境覆盖

浏览应用,注意到 Demo 自动对黑暗模式的适应很好。

黑暗模式下的Demo。

黑暗模式下的显示

默认情况下,系统提供的视图--如UILabel、UITextField和UIView--会在绘制时使用动态颜色。动态颜色提供了不同的值,这取决于它们是以浅色还是深色的形式出现。(它们也会根据一些可访问性选项(如增加对比度)稍作改变。
Interface Builder的颜色选择器中的大部分颜色都是动态颜色。在颜色选择器中,请注意标签颜色和系统背景颜色。Label Color是标签的默认文本颜色;浅色外观为黑色,深色外观为白色。系统背景色与此相反;浅色外观时为白色,深色外观时为黑色。由于我们没有更改 Demo 中视图的默认颜色,因此应用程序会适当地响应于暗模式。

界面生成器中的动态颜色

界面生成器中的动态颜色

你也可以在代码中使用这些颜色。标签颜色映射到 UIColor.label,系统背景颜色映射到 UIColor.systemBackground。查看 UIColor 文档中的完整值列表。

动态颜色取决于当前的特征集合,以提供适应性。特征集合是 UITraitCollection 的一个实例,它可以帮助决定视图的外观,其属性如下:

userInterfaceIdiom 应用程序所运行的设备类型,如iPhone、iPad、Apple TV或CarPlay设备
userInterfaceStyle 明(Light Mode)或暗(Dark Mode)模式
userInterfaceLevel base level(用于全屏视图)或 elevated level (用于非全屏视图,如模态、弹出式窗口和分屏视图中的应用程序等)。

UIView 和 UIViewController 的实例都有一个 traitCollection 属性,可以用来访问这些属性。另一个访问 trait collection 的方便方法是 UITraitCollection 的 current 属性,它是由 UIKit 框架自动设置的。

动态颜色使用了当前的 trait collection ,根据界面风格和级别来决定返回哪种颜色。UIKit 包含了几个系统级的动态颜色。背景色有三种变体:主色、次色和三级色(primary, secondary, and tertiary)。

这些颜色可以让你结构化你的应用程序的视图层次结构。例如,当使用深色外观的 systemBackgroundColor 时,系统将为全屏(base level)视图使用纯黑色,为非全屏(elevated level )视图使用深灰色。你可以使用 trait collection 属性为你的界面做同样的事情。

背景色变体

背景色变体

有四个级别的文字颜色,可以让你强调各元素相对于其他元素的重要性。一级色用于标题,二级色用于副标题,三级色和四级色用于其他文字。您可以将这些颜色用于其他目的,但在使用这些颜色时,了解颜色的层次结构是很重要的。

虽然 Demo 对暗色模式反应良好,但让我们来更新一下它的界面,使用一些不同的颜色来给App增添一些色彩。要做到这一点,你将创建一些自定义颜色,然后在不同的界面元素上使用这些颜色。

为 Asset Catalog 添加颜色

你在 Quiz 和 WorldTrotter 应用程序中向Asset Catalog添加了图像。现在,你将使用 Asset Catalog 来添加颜色。使用 Asset Catalog 可以让你给这些颜色命名,这些颜色可以在界面生成器中以及代码中引用,更重要的是,你将能够自定义颜色,以便于明暗两色的外观。

打开Assets.xcasse 在左下角,单击 ,选择新建颜色集。

添加新的颜色

添加新的颜色

双击侧边栏中的颜色,并命名为 "主品牌填充色"。这个颜色将被用作整个应用程序的背景。在颜色仍被选中的情况下,打开它的属性检查器。从 "外观 "下拉菜单中,选择 "任意,深色"。注意,在目录中会出现一个额外的颜色选项。

添加另一个颜色外观

添加另一种颜色外观

现在你可以更新颜色值了。如果界面是暗色外观(Dark Appearance),将使用暗色外观的颜色。否则,将使用任意外观(Any Appearance)颜色。

选择 "任意外观"
框,打开其属性检查器。在颜色部分,将输入法设置为8位(0-255)。然后,将红色、绿色和蓝色值分别改为248、248和253。

现在选择暗色外观框,并将红色、绿色和蓝色值分别设置为25、25和42。

创建了主填充颜色后,重复相同的步骤,再创建两个你将在应用程序中使用的颜色。使用的值见下表。

颜色表

Name Any Appearance Dark Appearance
Secondary Brand Fill Color Red 236
Green 235
Blue 255
Red 45
Green 42
Blue 75
Brand Accent Color Red 240
Green 79
Blue 0
Red 255
Green 84
Blue 0

你可能会注意到, Brand Accent Color 和其他颜色非常相似,深色的外观颜色只比浅色的外观颜色浅一点。非填充色通常都是这样的情况。虽然任何橙色(在本例中)都会与白色、灰色或黑色的背景形成对比,但将深色外观色设置得更浅一点,会让它在较深的背景填充色上有额外的 "流行 "效果(反之亦然)。

使用自定义动态颜色

创建了三种动态颜色后,是时候让它们派上用场了。先从更新 ItemsViewController 开始。

打开 Main.storyboard,找到 Demo 的 scene。选择表视图,打开其属性检查器。向下滚动到视图部分,打开背景下拉菜单。你应该会在这个下拉菜单中看到一个新的部分,标签为 "命名的颜色"。从这个列表中选择主品牌填充色。现在对表格视图单元格进行同样的操作。选择它,打开属性检查器,将其背景颜色改为主品牌填充色。

颜色选择器中的命名颜色

颜色选择器中的命名颜色

目前为止,我们来看看界面的样子。你可以使用视图为菜单改变画布显示的是浅色风格还是深色风格的外观。展开 "视图为菜单",选择深色界面风格。

现在,更新导航栏。找到画布上的导航控制器,选择其界面顶部的导航栏。打开它的属性检查器,在导航栏部分找到导航栏色调选项。打开颜色菜单,选择 "次要品牌填充颜色"。

设置条形色条的色调

设置条形色
为了让 ItemsViewController 好看,最后要更新的颜色是全局色调颜色。每个应用程序都有一个色调颜色,它是用来给交互式界面元素着色的,如条形按钮项、警报和动作表动作标题以及标签栏项。

要更改全局色调颜色,请点击检查器中的 选项卡或使用键盘快捷键Option-Command-1打开文件检查器。向下滚动到界面生成器文档部分,找到全局色调选项。打开其颜色菜单,选择 "Brand Accent Color"。

设置全局色调颜色

设置全局色调
请注意,很多之前默认的蓝色调的界面元素现在都变成了 brand accent color 。

画布上的全局色调

全局色调在画布上的颜色

在处理好了ItemsViewController的颜色后,让我们把注意力转向DetailViewController。

选择DetailViewController的背景视图,打开它的属性检查器,将其背景颜色改为 Primary Brand Fill Color。然后选择工具栏,打开它的属性检查器,并将其条形色改为 Secondary Brand Fill Color 。这样你的界面会是这样的:

更新了 "DetailViewController "颜色

更新的DetailViewController颜色

文字字段的背景颜色可以改进一下,使其在背景视图中显得更加突出。让我们把它们更新一下,让它们变得更加赏心悦目一些。

选择名称文本字段,打开其属性检查器。向下滚动到视图部分,将背景改成 Tertiary System Fill Color。文档中说, Tertiary System Fill Color 适合填充大型形状,如输入字段、搜索栏、按钮等。文本字段就是输入字段,所以这个颜色选择很好用。对其他两个文本字段重复同样的步骤。完成后,界面看起来就会像:

*完成后的APP颜色

完成的APP颜色

构建并运行该项目。浏览应用程序,使用环境覆盖项在浅色和深色外观之间切换。Demo 对这两种外观的新颜色都有很好的响应。

我们已经完成了暗黑模式下的主题色适配!


原文地址:https://github.com//issues/30

据说点了"在看"的童鞋

明早起床,皮肤变白、不再暗黑!

"在看"👇👇

Callback vs CompletionHandler

cool... 《有一种 Block 叫 Callback,有一种 Callback 做 CompletionHandler》被文章名字深深吸引了,博主思考深刻,提出的观点有利于更简单的理解事物逻辑。

“不是所有 Block 都可以叫做 CompletionHandler” 我觉得用 Block 实现的“ CompletionHandler/callback”是同一个消息传递的正法两面,在 “ CompletionHandler + Delegate”组合中,Delegate方法实现的地方 Block 是一个“ CompletionHandler”, 但在Delegate方法使用的地方Block 叫做“ Callback”。这么讲的话,这两处只有一个本质区别: 一处是Block的执行另外一处是Block填充。

对于 Fetch 的这种应用场景,我认为用方法的返回值可以部分替代(与 CompletionHandler 相比无法自由切换线程是个弊端)

NSURLSession 的一个简单函数将 “callback” 命名为了 “completionHandler”:

- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;

另外文中"URLSession:didReceiveChallenge:completionHandler" 的例子,让我想到另外一个也非常普遍的例子(Delegate 方式使用URLSession 时候必不可少的 4个代理函数之一 )

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                didReceiveResponse:(NSURLResponse *)response
                                 completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;

//在代理方法实现代码里面,若是不执行 completionHandler(NSURLSessionResponseAllow) 话,http请求就终止了。

拙见...

IM 即时通讯技术篇-技术实现细节

#技术实现细节

IM系列文章

IM系列文章分为下面这几篇:

本文是第二篇。

本文将以开源项目 ChatKit-OC 为例进行介绍:

正文

ChatKit-OC 我们专门为社交场景开发的开源组件:ChatKit-OC,star数,1000+。

项目地址:ChatKit-OC

下文会专门介绍下技术实现细节。

先看下现在 IM 领域的轮子有什么问题:

  • Demo 太多,是时候该来一款 Lib 了;
  • 闭源的太多,是时候来一款开源的了;
  • 部分开源的太多,是时候来一款 100% 开源的了(iOS 端)
  • 手撕 Frame 的太多,是时候来一 AutoLayout 款了;
  • 自定义能力太弱的太多,是时候来一款可高度自定义的了;

ChatKit-OC 正是为解决这些问题做的,它的特点如下:

  • 集成方法简单,但可扩展性好。
  • iOS 端代码完全开源,你能看到完整的建立 Socket 连接,以及维持心跳的所有步骤。
  • 原生语言开发,利于调试,并非采用C++库。
  • Masonry 布局
  • 友好的 API 设计
  • 接地气
  • 支持 CocoaPods
  • 不需要改源码,不需要设 Delegate
  • 不需要在代码里调整聊天气泡位置

效果展示:

enter image description here

enter image description here

enter image description here

enter image description here

最近联系人 | 语音消息,根据语音长度调整宽度 | 图片消息,尺寸自适应
-------------|-------------|-------------|-------------
enter image description here|enter image description here | enter image description here

地理位置消息 失败消息本地缓存,可重发 上传图片,进度条提示
enter image description here enter image description here enter image description here
图片消息支持多图联播,支持多种分享 文本消息支持图文混排 文本消息支持双击全屏展示
enter image description here enter image description here enter image description here

紧凑的API设计:门面模式

使用门面模式,使接入方仅仅需要与一个类打交道,也就使得 API 更加紧凑。

极简参数:

像 UIAlertView 一样,你只需要使用 title 去 init 后,然后直接调用 show 就能达到预期的效果。采用了类似的 API 设计,

隐藏细节:

设计 API时尽量隐藏细节,尽量提供默认实现。

就像 SDWebImage 被使用最多的功能就是 TableView 图片的加载,

只需要传入占位图和图片 URL 两个参数,就能完成复杂的异步加载展示。

无侵入的用户系统接入

需求:与用户的用户帐号体系完全隔离,只需要提供一个 ID 就可以通信,接入方可以对该 ID 进行 MD5 加密后再进行传输和存储,保证开发者用户数据的私密性及安全。

基本的流程示意:

正如图中所示,ChatKit 在需要展现用户图像等信息到 UI 上时,会拿着 ID 去回调一个 Block,这个 Block 需要将对应的用户头像等信息 callback 给 ChatKit,进而展现在 UI 上。

其中最关键的部分是,ChatKit 提供的这个 Block,这个 Block 需要用户自己实现。对这个 Block 的具体实现感兴趣的话,可以看下这篇:

《有一种 Block 叫 Callback,有一种 Callback 做 CompletionHandler》

面向 ID 编程

  1. 底层协议:Client -- ClientID
  2. 上层UI:Conversation -- ConversationID

ClientId:

WebSocket 通信是 Session 对 Session 的通信,我们将概念简化为 Id 对 Id 的通信,这样一来,两个 APP 只需要 ID 就可以聊起来,这个 ID 我们取名叫 ClientId。

在 ChatKit-OC 的 UI 实现中,体现在对话页面的初始化,你只需要传递一个id,对话id,即可。你同样可以使用ConversationID也就是对话ID来初始化。这样简洁的 API 设计,做法类似 UIAlertView 的初始化只需要传一个 title 就可以。

Conversation(对话)这个概念,我们在使用其他的聊天协议时,比如XMPP,会有单聊和群聊之分,我们在 ChatKit-OC 中,将这个概念简化,不区分单聊群聊。这样一来,你在初始化时就会节省一个概念,而且可以达到只需要一个 ID 就能初始化一个对话页面的目的。在 ChatKit-OC 中我们通过对话中的人数来群分单聊群聊,当然你也可以为对话添加额外的字段,来准确地标记。每个 Conversation 对象都有一个 Attribute 属性,可以自定义字段。

可维护性

传统方式设置 View 坐标,或 颜色的问题:

  1. 直接写坐标可维护性太差
  2. 定义成宏,无法满足组件化后的自定义需求
  3. 失去动态更新能力

为了解决以上问题,ChatKit 使用了 UI 配置文件的方式来提高可维护性:

将配置文件以 JSON 文件,或 plist 文件的方式,我采用的是 plist 文件形式,放置在 bundle 中,让 bundle 文件可以自定义,图片等多媒体资源也就同样可以自定义了。如果在App运行中覆盖对应路径,也就能达到动态更新应用主题的目的。

  • 易于维护
  • 利于动态更新
  • 安卓和iOS公用一套 UI 配置

下图是 ChatKit 中部分可自定义项:


做法类似:

  • 安卓开发中的 style xml
  • 网页开发中的 css
  • 微信团队做法类似。淘宝的一些app也采用了。
    微信团队就是如此,软件开发团队从来不负责改坐标、颜色等 UI 参数,这些都是 UI 设计 团队去做。也是采用配置文件。淘宝的聚划算团队也使用了类似的策略,只不过是使用了 JSON 文件的形式。

可拓展性

使用 CocoaPods 集成,在 Demo 层面能实现红包这样大粒度的自定义业务模块,

效果图如下所示:

需要几个 API:

12个接口:

自定义项 公开API 备注
自定义消息 2 -registerSubclass+classMediaType
自定义Cell 4 -registerCustomMessageCell+classMediaType-setup-configureCellWithData:
自定义插件 6 -registerCustomInputViewPlugin+classPluginType-pluginIconImage-pluginTitle-pluginDidClickedsendCustomMessageHandler

效果图:

  • | - | -
    -------------|-------------|-------------
    | |
    | |

如何做到在Demo层面进行这样的拓展?
最关键的是:插件映射机制:

比如下图中输入框底部的这些可以点击的插件,ChatKit-OC 提供了一个可变字典,能让用户操作,添加元素。在需要展示 时会去该字典中拿View,因此只要提前进行了映射,就可以加载上。

这种映射机制应用在了:

  • 自定义cell类型
  • 自定义Message类型
  • 自定义输入框底部插件

封装程度高

现状,现在社区中很多轮子,大多数自定义消息的做法,是在“自定义字符串”,对话双方传输的是字符串,接收后需要自己去做序列化、反序列化。

ChatKit-OC 则封装程度更高,发送接收消息时,已经将所有的自定义消息,序列化和反序列化好。你只需要操作 Model 就可以。

IM系列文章

IM系列文章分为下面这几篇:

本文是第二篇。


Posted by 微博@iOS程序犭袁
原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

HTTP状态码汇总

#HTTP状态码汇总

编号 名称 解释
1️⃣❌❌ 🔴🔴🔴 1xx消息

这一类型的状态码,代表请求已被接受,需要继续处理。这类响应是临时响应,只包含状态行和某些可选的响应头信息,并以空行结束。由于HTTP/1.0协议中没有定义任何1xx状态码,所以除非在某些试验条件下,服务器禁止向此类客户端发送1xx响应。 这些状态码代表的响应都是信息性的,标示客户应该采取的其他行动。
100 Continue 客户端应当继续发送请求。这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。客户端应当继续发送请求的剩余部分,或者如果请求已经完成,忽略这个响应。服务器必须在请求完成后向客户端发送一个最终响应。
101 Switching Protocols 服务器已经理解了客户端的请求,并将通过Upgrade消息头通知客户端采用不同的协议来完成这个请求。在发送完这个响应最后的空行后,服务器将会切换到在Upgrade消息头中定义的那些协议。: 只有在切换新的协议更有好处的时候才应该采取类似措施。例如,切换到新的HTTP版本比旧版本更有优势,或者切换到一个实时且同步的协议以传送利用此类特性的资源。
102 Processing 由WebDAV(RFC 2518)扩展的状态码,代表处理将被继续执行。
2️⃣❌❌ 🔴🔴🔴 2xx成功 这一类型的状态码,代表请求已成功被服务器接收、理解、并接受。
200 OK 请求已成功,请求所希望的响应头或数据体将随此响应返回。
201 Created 请求已经被实现,而且有一个新的资源已经依据请求的需要而创建,且其URI已经随Location头信息返回。假如需要的资源无法及时创建的话,应当返回'202 Accepted'。
202 Accepted 服务器已接受请求,但尚未处理。正如它可能被拒绝一样,最终该请求可能会也可能不会被执行。在异步操作的场合下,没有比发送这个状态码更方便的做法了。:返回202状态码的响应的目的是允许服务器接受其他过程的请求(例如某个每天只执行一次的基于批处理的操作),而不必让客户端一直保持与服务器的连接直到批处理操作全部完成。在接受请求处理并返回202状态码的响应应当在返回的实体中包含一些指示处理当前状态的信息,以及指向处理状态监视器或状态预测的指针,以便用户能够估计操作是否已经完成。
203 Non-Authoritative Information 服务器已成功处理了请求,但返回的实体头部元信息不是在原始服务器上有效的确定集合,而是来自本地或者第三方的拷贝。当前的信息可能是原始版本的子集或者超集。例如,包含资源的元数据可能导致原始服务器知道元信息的超集。使用此状态码不是必须的,而且只有在响应不使用此状态码便会返回200 OK的情况下才是合适的。
204 No Content 服务器成功处理了请求,但不需要返回任何实体内容,并且希望返回更新了的元信息。响应可能通过实体头部的形式,返回新的或更新后的元信息。如果存在这些头部信息,则应当与所请求的变量相呼应。

如果客户端是浏览器的话,那么用户浏览器应保留发送了该请求的页面,而不产生任何文档视图上的变化,即使按照规范新的或更新后的元信息应当被应用到用户浏览器活动视图中的文档。

由于204响应被禁止包含任何消息体,因此它始终以消息头后的第一个空行结尾。
205 Reset Content 服务器成功处理了请求,且没有返回任何内容。但是与204响应不同,返回此状态码的响应要求请求者重置文档视图。该响应主要是被用于接受用户输入后,立即重置表单,以便用户能够轻松地开始另一次输入。

与204响应一样,该响应也被禁止包含任何消息体,且以消息头后的第一个空行结束。
206 Partial Content 服务器已经成功处理了部分GET请求。类似于FlashGet或者迅雷这类的HTTP 下载工具都是使用此类响应实现断点续传或者将一个大文档分解为多个下载段同时下载。

该请求必须包含Range头信息来指示客户端希望得到的内容范围,并且可能包含If-Range来作为请求条件。

响应必须包含如下的头部域:

Content-Range用以指示本次响应中返回的内容的范围;如果是Content-Type为multipart/byteranges的多段下载,则每一multipart段中都应包含Content-Range域用以指示本段的内容范围。假如响应中包含Content-Length,那么它的数值必须匹配它返回的内容范围的真实字节数。

Date ETag和/或Content-Location,假如同样的请求本应该返回200响应。

Expires, Cache-Control,和/或Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。

假如本响应请求使用了If-Range强缓存验证,那么本次响应不应该包含其他实体头;假如本响应的请求使用了If-Range弱缓存验证,那么本次响应禁止包含其他实体头;这避免了缓存的实体内容和更新了的实体头信息之间的不一致。否则,本响应就应当包含所有本应该返回200响应中应当返回的所有实体头部域。

假如ETag或Last-Modified头部不能精确匹配的话,则客户端缓存应禁止将206响应返回的内容与之前任何缓存过的内容组合在一起。

任何不支持Range以及Content-Range头的缓存都禁止缓存206响应返回的内容。
207 Multi-Status 由WebDAV(RFC 2518)扩展的状态码,代表之后的消息体将是一个XML消息,并且可能依照之前子请求数量的不同,包含一系列独立的响应代码。
3️⃣❌❌ 🔴🔴🔴 3xx重定向

这类状态码代表需要客户端采取进一步的操作才能完成请求。通常,这些状态码用来重定向,后续的请求地址(重定向目标)在本次响应的Location域中指明。

当且仅当后续的请求所使用的方法是GET或者HEAD时,用户浏览器才可以在没有用户介入的情况下自动提交所需要的后续请求。客户端应当自动监测无限循环重定向(例如:A→B→C→……→A或A→A),因为这会导致服务器和客户端大量不必要的资源消耗。按照HTTP/1.0版规范的建议,浏览器不应自动访问超过5次的重定向。
300 Multiple Choices 被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。

除非这是一个HEAD请求,否则该响应应当包括一个资源特性及地址的列表的实体,以便用户或浏览器从中选择最合适的重定向地址。这个实体的格式由Content-Type定义的格式所决定。浏览器可能根据响应的格式以及浏览器自身能力,自动作出最合适的选择。当然,RFC 2616规范并没有规定这样的自动选择该如何进行。

如果服务器本身已经有了首选的回馈选择,那么在Location中应当指明这个回馈的URI;浏览器可能会将这个Location值作为自动重定向的地址。此外,除非额外指定,否则这个响应也是可缓存的。
301 Moved Permanently 被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个URI之一。如果可能,拥有链接编辑功能的客户端应当自动把请求的地址修改为从服务器反馈回来的地址。除非额外指定,否则这个响应也是可缓存的。

新的永久性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。

如果这不是一个GET或者HEAD请求,因此浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。

注意:对于某些使用HTTP/1.0协议的浏览器,当它们发送的POST请求得到了一个301响应的话,接下来的重定向请求将会变成GET方式。
302 Found 请求的资源现在临时从不同的URI响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。

新的临时性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。

如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。

注意:虽然RFC 1945和RFC 2068规范不允许客户端在重定向时改变请求的方法,但是很多现存的浏览器将302响应视作为303响应,并且使用GET方式访问在Location中规定的URI,而无视原先请求的方法。状态码303和307被添加了进来,用以明确服务器期待客户端进行何种反应。
303 See Other 对应当前请求的响应可以在另一个URI上被找到,而且客户端应当采用GET的方式访问那个资源。这个方法的存在主要是为了允许由脚本激活的POST请求输出重定向到一个新的资源。这个新的URI不是原始资源的替代引用。同时,303响应禁止被缓存。当然,第二个请求(重定向)可能被缓存。

新的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。

注意:许多HTTP/1.1版以前的浏览器不能正确理解303状态。如果需要考虑与这些浏览器之间的互动,302状态码应该可以胜任,因为大多数的浏览器处理302响应时的方式恰恰就是上述规范要求客户端处理303响应时应当做的。
304 Not Modified 如果客户端发送了一个带条件的GET请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变,则服务器应当返回这个状态码。304响应禁止包含消息体,因此始终以消息头后的第一个空行结尾。

该响应必须包含以下的头信息:

Date,除非这个服务器没有时钟。假如没有时钟的服务器也遵守这些规则,那么代理服务器以及客户端可以自行将Date字段添加到接收到的响应头中去(正如RFC 2068中规定的一样),缓存机制将会正常工作。

ETag和/或Content-Location,假如同样的请求本应返回200响应。

Expires, Cache-Control,和/或Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。

假如本响应请求使用了强缓存验证,那么本次响应不应该包含其他实体头;否则(例如,某个带条件的GET请求使用了弱缓存验证),本次响应禁止包含其他实体头;这避免了缓存了的实体内容和更新了的实体头信息之间的不一致。

假如某个304响应指明了当前某个实体没有缓存,那么缓存系统必须忽视这个响应,并且重复发送不包含限制条件的请求。

假如接收到一个要求更新某个缓存条目的304响应,那么缓存系统必须更新整个条目以反映所有在响应中被更新的字段的值。
305 Use Proxy 被请求的资源必须通过指定的代理才能被访问。Location域中将给出指定的代理所在的URI信息,接收者需要重复发送一个单独的请求,通过这个代理才能访问相应资源。只有原始服务器才能创建305响应。

注意:RFC 2068中没有明确305响应是为了重定向一个单独的请求,而且只能被原始服务器创建。忽视这些限制可能导致严重的安全后果。
306 Switch Proxy 在最新版的规范中,306状态码已经不再被使用。
307 Temporary Redirect 请求的资源现在临时从不同的URI响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。

新的临时性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。因为部分浏览器不能识别307响应,因此需要添加上述必要信息以便用户能够理解并向新的URI发出访问请求。

如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。
4️⃣❌❌ 🔴🔴🔴 4xx客户端错误

这类的状态码代表了客户端看起来可能发生了错误,妨碍了服务器的处理。除非响应的是一个HEAD请求,否则服务器就应该返回一个解释当前错误状况的实体,以及这是临时的还是永久性的状况。这些状态码适用于任何请求方法。浏览器应当向用户显示任何包含在此类错误响应中的实体内容。

如果错误发生时客户端正在传送数据,那么使用TCP的服务器实现应当仔细确保在关闭客户端与服务器之间的连接之前,客户端已经收到了包含错误信息的数据包。如果客户端在收到错误信息后继续向服务器发送数据,服务器的TCP栈将向客户端发送一个重置数据包,以清除该客户端所有还未识别的输入缓冲,以免这些数据被服务器上的应用程序读取并干扰后者。
400 Bad Request 由于包含语法错误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求。
401 Unauthorized 当前请求需要用户验证。该响应必须包含一个适用于被请求资源的WWW-Authenticate信息头用以询问用户信息。客户端可以重复提交一个包含恰当的Authorization头信息的请求。如果当前请求已经包含了Authorization证书,那么401响应代表着服务器验证已经拒绝了那些证书。如果401响应包含了与前一个响应相同的身份验证询问,且浏览器已经至少尝试了一次验证,那么浏览器应当向用户展示响应中包含的实体信息,因为这个实体信息中可能包含了相关诊断信息。参见RFC 2617。
402 Payment Required 该状态码是为了将来可能的需求而预留的。
403 Forbidden 服务器已经理解请求,但是拒绝执行它。与401响应不同的是,身份验证并不能提供任何帮助,而且这个请求也不应该被重复提交。如果这不是一个HEAD请求,而且服务器希望能够讲清楚为何请求不能被执行,那么就应该在实体内描述拒绝的原因。当然服务器也可以返回一个404响应,假如它不希望让客户端获得任何信息。
404 Not Found 请求失败,请求所希望得到的资源未被在服务器上发现。没有信息能够告诉用户这个状况到底是暂时的还是永久的。假如服务器知道情况的话,应当使用410状态码来告知旧资源因为某些内部的配置机制问题,已经永久的不可用,而且没有任何可以跳转的地址。404这个状态码被广泛应用于当服务器不想揭示到底为何请求被拒绝或者没有其他适合的响应可用的情况下。
405 Method Not Allowed 请求行中指定的请求方法不能被用于请求相应的资源。该响应必须返回一个Allow头信息用以表示出当前资源能够接受的请求方法的列表。

鉴于PUT,DELETE方法会对服务器上的资源进行写操作,因而绝大部分的网页服务器都不支持或者在默认配置下不允许上述请求方法,对于此类请求均会返回405错误。
406 Not Acceptable 请求的资源的内容特性无法满足请求头中的条件,因而无法生成响应实体。

除非这是一个HEAD请求,否则该响应就应当返回一个包含可以让用户或者浏览器从中选择最合适的实体特性以及地址列表的实体。实体的格式由Content-Type头中定义的媒体类型决定。浏览器可以根据格式及自身能力自行作出最佳选择。但是,规范中并没有定义任何作出此类自动选择的标准。
407 Proxy Authentication Required 与401响应类似,只不过客户端必须在代理服务器上进行身份验证。代理服务器必须返回一个Proxy-Authenticate用以进行身份询问。客户端可以返回一个Proxy-Authorization信息头用以验证。参见RFC 2617。
408 Request Timeout 请求超时。客户端没有在服务器预备等待的时间内完成一个请求的发送。客户端可以随时再次提交这一请求而无需进行任何更改。
409 Conflict 由于和被请求的资源的当前状态之间存在冲突,请求无法完成。这个代码只允许用在这样的情况下才能被使用:用户被认为能够解决冲突,并且会重新提交新的请求。该响应应当包含足够的信息以便用户发现冲突的源头。

冲突通常发生于对PUT请求的处理中。例如,在采用版本检查的环境下,某次PUT提交的对特定资源的修改请求所附带的版本信息与之前的某个(第三方)请求向冲突,那么此时服务器就应该返回一个409错误,告知用户请求无法完成。此时,响应实体中很可能会包含两个冲突版本之间的差异比较,以便用户重新提交归并以后的新版本。
410 Gone 被请求的资源在服务器上已经不再可用,而且没有任何已知的转发地址。这样的状况应当被认为是永久性的。如果可能,拥有链接编辑功能的客户端应当在获得用户许可后删除所有指向这个地址的引用。如果服务器不知道或者无法确定这个状况是否是永久的,那么就应该使用404状态码。除非额外说明,否则这个响应是可缓存的。

410响应的目的主要是帮助网站管理员维护网站,通知用户该资源已经不再可用,并且服务器拥有者希望所有指向这个资源的远端连接也被删除。这类事件在限时、增值服务中很普遍。同样,410响应也被用于通知客户端在当前服务器站点上,原本属于某个个人的资源已经不再可用。当然,是否需要把所有永久不可用的资源标记为'410 Gone',以及是否需要保持此标记多长时间,完全取决于服务器拥有者。
411 Length Required 服务器拒绝在没有定义Content-Length头的情况下接受请求。在添加了表明请求消息体长度的有效Content-Length头之后,客户端可以再次提交该请求。
412 Precondition Failed 服务器在验证在请求的头字段中给出先决条件时,没能满足其中的一个或多个。这个状态码允许客户端在获取资源时在请求的元信息(请求头字段数据)中设置先决条件,以此避免该请求方法被应用到其希望的内容以外的资源上。
413 Request Entity Too Large 服务器拒绝处理当前请求,因为该请求提交的实体数据大小超过了服务器愿意或者能够处理的范围。此种情况下,服务器可以关闭连接以免客户端继续发送此请求。

如果这个状况是临时的,服务器应当返回一个Retry-After的响应头,以告知客户端可以在多少时间以后重新尝试。
414 Request-URI Too Long 请求的URI长度超过了服务器能够解释的长度,因此服务器拒绝对该请求提供服务。这比较少见,通常的情况包括:

本应使用POST方法的表单提交变成了GET方法,导致查询字符串(Query String)过长。

重定向URI“黑洞”,例如每次重定向把旧的URI作为新的URI的一部分,导致在若干次重定向后URI超长。

客户端正在尝试利用某些服务器中存在的安全漏洞攻击服务器。这类服务器使用固定长度的缓冲读取或操作请求的URI,当GET后的参数超过某个数值后,可能会产生缓冲区溢出,导致任意代码被执行[1]。没有此类漏洞的服务器,应当返回414状态码。
415 Unsupported Media Type 对于当前请求的方法和所请求的资源,请求中提交的实体并不是服务器中所支持的格式,因此请求被拒绝。
416 Requested Range Not Satisfiable 如果请求中包含了Range请求头,并且Range中指定的任何数据范围都与当前资源的可用范围不重合,同时请求中又没有定义If-Range请求头,那么服务器就应当返回416状态码。

假如Range使用的是字节范围,那么这种情况就是指请求指定的所有数据范围的首字节位置都超过了当前资源的长度。服务器也应当在返回416状态码的同时,包含一个Content-Range实体头,用以指明当前资源的长度。这个响应也被禁止使用multipart/byteranges作为其Content-Type。
417 Expectation Failed 在请求头Expect中指定的预期内容无法被服务器满足,或者这个服务器是一个代理服务器,它有明显的证据证明在当前路由的下一个节点上,Expect的内容无法被满足。
418 I'm a teapot 本操作码是在1998年作为IETF的传统愚人节笑话, 在RFC 2324 超文本咖啡壶控制协议中定义的,并不需要在真实的HTTP服务器中定义。
421 There are too many connections from your internet address 从当前客户端所在的IP地址到服务器的连接数超过了服务器许可的最大范围。通常,这里的IP地址指的是从服务器上看到的客户端地址(比如用户的网关或者代理服务器地址)。在这种情况下,连接数的计算可能涉及到不止一个终端用户。
422 Unprocessable Entity 请求格式正确,但是由于含有语义错误,无法响应。(RFC 4918 WebDAV)
423 Locked 当前资源被锁定。(RFC 4918 WebDAV)
424 Failed Dependency 由于之前的某个请求发生的错误,导致当前请求失败,例如PROPPATCH。(RFC 4918 WebDAV)
425 Unordered Collection 在WebDav Advanced Collections草案中定义,但是未出现在《WebDAV顺序集协议》(RFC 3658)中。
426 Upgrade Required 客户端应当切换到TLS/1.0。(RFC 2817)
449 Retry With 由微软扩展,代表请求应当在执行完适当的操作后进行重试。
5️⃣❌❌ 🔴🔴🔴 5xx服务器错误

这类状态码代表了服务器在处理请求的过程中有错误或者异常状态发生,也有可能是服务器意识到以当前的软硬件资源无法完成对请求的处理。除非这是一个HEAD请求,否则服务器应当包含一个解释当前错误状态以及这个状况是临时的还是永久的解释信息实体。浏览器应当向用户展示任何在当前响应中被包含的实体。

这些状态码适用于任何响应方法。
500 Internal Server Error 服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。一般来说,这个问题都会在服务器的程序码出错时出现。
501 Not Implemented 服务器不支持当前请求所需要的某个功能。当服务器无法识别请求的方法,并且无法支持其对任何资源的请求。
502 Bad Gateway 作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应。
503 Service Unavailable 由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。如果能够预计延迟时间,那么响应中可以包含一个Retry-After头用以标明这个延迟时间。如果没有给出这个Retry-After信息,那么客户端应当以处理500响应的方式处理它。
504 Gateway Timeout 作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器(URI标识出的服务器,例如HTTP、FTP、LDAP)或者辅助服务器(例如DNS)收到响应。 注意:某些代理服务器在DNS查询超时时会返回400或者500错误
505 HTTP Version Not Supported 服务器不支持,或者拒绝支持在请求中使用的HTTP版本。这暗示着服务器不能或不愿使用与客户端相同的版本。响应中应当包含一个描述了为何版本不被支持以及服务器支持哪些协议的实体。
506 Variant Also Negotiates 由《透明内容协商协议》(RFC 2295)扩展,代表服务器存在内部配置错误:被请求的协商变元资源被配置为在透明内容协商中使用自己,因此在一个协商处理中不是一个合适的重点。
507 Insufficient Storage 服务器无法存储完成请求所必须的内容。这个状况被认为是临时的。WebDAV(RFC 4918)
509 Bandwidth Limit Exceeded 服务器达到带宽限制。这不是一个官方的状态码,但是仍被广泛使用。
510 Not Extended 获取资源所需要的策略并没有没满足。

all-reward

iOS 防 DNS 污染方案调研(三)--- WebView 业务场景

iOS防DNS污染方案调研---WebView业务场景

对应的GitHub仓库镜像地址在这里 ,欢迎提PR进行修改。

概述

本文主要介绍,防 DNS 污染方案在 WebView 场景下所遇到的一些问题,及解决方案,也会涉及比如:“HTTPS+SNI” 等场景。

面临的问题

/one more thing/

WKWebView 无法使用 NSURLProtocol 拦截请求

方案如下:

  1. 换用 UIWebView
  2. 使用私有API进行注册拦截

换用 UIWebView 方案不做赘述,说明下使用私有API进行注册拦截的方法:

//注册自己的protocol
   [NSURLProtocol registerClass:[CustomProtocol class]];

   //创建WKWebview
   WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
   WKWebView * wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height) configuration:config];
   [wkWebView loadRequest:webViewReq];
   [self.view addSubview:wkWebView];

   //注册scheme
   Class cls = NSClassFromString(@"WKBrowsingContextController");
   SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
   if ([cls respondsToSelector:sel]) {
       // 通过http和https的请求,同理可通过其他的Scheme 但是要满足ULR Loading System
       [cls performSelector:sel withObject:@"http"];
       [cls performSelector:sel withObject:@"https"];
   }

使用私有 API 的另一风险是兼容性问题,比如上面的 browsingContextController 就只能在 iOS 8.4 以后才能用,反注册 scheme 的方法 unregisterSchemeForCustomProtocol: 也是在 iOS 8.4 以后才被添加进来的,要支持 iOS 8.0 ~ 8.3 机型的话,只能通过动态生成字符串的方式拿到 WKBrowsingContextController,而且还不能反注册,不过这些问题都不大。至于向后兼容,这个也不用太担心,因为 iOS 发布新版本之前都会有开发者预览版的,那个时候再测一下也不迟。对于本文的例子来说,如果将来哪个 iOS 版本移除了这个 API,那很可能是因为官方提供了完整的解决方案,到那时候自然也不需要本文介绍的方法了。

注意避免执行太晚,如果在 - (void)viewDidLoad 中注册,可能会因为注册太晚,引发问题。建议在 +load 方法中执行。

然后同样会遇到 《iOS SNI 场景下的防 DNS 污染方案调研》 里提到的各种 NSURLProtocol 相关的问题,可以参照里面的方法解决。

Cookie相关问题

单独成篇: 《防 DNS 污染方案调研---iOS HTTPS(含SNI) 业务场景(四)-- Cookie 场景》

302重定向问题

上面提到的 Cookie 方案无法解决302请求的 Cookie 问题,比如,第一个请求是 http://www.a.com ,我们通过在 request header 里带上 Cookie 解决该请求的 Cookie 问题,接着页面302跳转到 http://www.b.com ,这个时候 http://www.b.com 这个请求就可能因为没有携带 cookie 而无法访问。当然,由于每一次页面跳转前都会调用回调函数:

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

可以在该回调函数里拦截302请求,copy request,在 request header 中带上 cookie 并重新 loadRequest。不过这种方法依然解决不了页面 iframe 跨域请求的 Cookie 问题,毕竟-[WKWebView loadRequest:]只适合加载 mainFrame 请求。

参考链接

相关的库:

相关的文章:

可以参考的Demo:

走过的弯路

误以为 iOS11 新 API 可以原生拦截 WKWebView 的 HTTP/HTTPS 网络请求

参考:Deal With WKWebView DNS pollution problem in iOS11

有一种 Block 叫 Callback,有一种 Callback 叫 CompletionHandler

有一种 Block 叫 Callback,有一种 Callback 叫 CompletionHandler

【引言】iOS10推送部分的API,大量使用了 CompletionHandler 这种命名方式,那么本文我们将对比下这种 Block 的特殊性,以便更好的理解和在自己的项目中实践 CompletionHandler 样式的 Blcok。

原文链接: 《有一种 Block 叫 Callback,有一种 Callback 叫 CompletionHandler》

正文

我们作为开发者去集成一个 Lib (也可以叫轮子、SDK、下文统一叫 Lib)时,我们会发现我们遇到的 Block, 按照功能的角度划分,其实可以分为这几种:

  • Lib 通知开发者,Lib操作已经完成。一般命名为 Callback
  • 开发者通知 Lib,开发者的操作已经完成。一般可以命名为 CompletionHandler。

这两处的区别: 前者是 “Block 的执行”,后者是 “Block 的填充”。

Callback vs CompletionHandler 命名与功能的差别,Apple 也没有明确的编码规范指出过,只不过如果按照“执行与填充”的功能划分的话,callbackcompletionHandler 的命名可以区分开来对待。同时也方便调用者理解 block 的功能。但总体来说,Apple 官方的命名中,“Block 填充“这个功能一般都会命名为 “completionHandler”,“Block 执行”这个功能大多命名为了“callback” ,也有少部分命名为了 “completionHandler”。

比如:

NSURLSession 中,下面的函数将 “callback” 命名为了 “completionHandler”:

- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;

我们常常见到 CompletionHandler 被用到了第一种场景,而第一种场景“Block 执行”命名为 Callback 则更合适。

不是所有 Block 都适合叫做 CompletionHandler

一般情况下,CompletionHandler 的设计往往考虑到多线程操作,于是,你就完全可以异步操作,然后在线程结束时执行该 CompletionHandler,下文的例子中会讲述下 CompletionHandler 方式在多线程场景下的一些优势。

CompletionHandler + Delegate 组合

在 iOS10 中新增加的 UserNotificaitons 中大量使用了这种 Block,比如:

- (void)userNotificationCenter:(UNUserNotificationCenter *)center 
didReceiveNotificationResponse:(UNNotificationResponse *)response 
        withCompletionHandler:(void (^)(void))completionHandler;

文档 对 completionHandler 的注释是这样的:

The block to execute when you have finished processing the user’s response. You must execute this block from your method and should call it as quickly as possible. The block has no return value or parameters.

同样在这里也有应用:

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler;

还有另外一个也非常普遍的例子(Delegate 方式使用URLSession 时候必不可少的 4个代理函数之一 )

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                didReceiveResponse:(NSURLResponse *)response
                                 completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;

在代理方法实现代码里面,若是不执行 completionHandler(NSURLSessionResponseAllow) 话,http请求就终止了。

CompletionHandler + Block 组合

函数中将函数作为参数或者返回值,就叫做高阶函数。

按照这种定义,Block 中将 Block 作为参数,这也就是高阶函数。

结合实际的应用场景来看一个例子:

如果有这样一个需求:

拿我之前的一个 IM 项目 ChatKit-OC (开源的,下面简称 ChatKit)为例,当你的应用想要集成一个 IM 服务时,可能这时候,你的 APP 已经上架了,已经有自己的注册、登录等流程了。用 ChatKit 进行聊天很简单,只需要给 ChatKit 一个 id 就够了。聊天是正常了,但是双方只能看到一个id,这样体验很不好。但是如何展示头像、昵称呢?于是就设计了这样一个接口,-setFetchProfilesBlock:

这是上层(APP)提供用户信息的 Block,由于 ChatKit 并不关心业务逻辑信息,比如用户昵称,用户头像等。用户可以通过 ChatKit 单例向 ChatKit 注入一个用户信息内容提供 Block,通过这个用户信息提供 Block,ChatKit 才能够正确的进行业务逻辑数据的绘制。

示意图如下:

具体实现如下:

方法定义如下:

/*!
*  @brief The block to execute with the users' information for the userIds. Always execute this block at some point when fetching profiles completes on main thread. Specify users' information how you want ChatKit to show.
*  @attention If you fetch users fails, you should reture nil, meanwhile, give the error reason.
*/
typedef void(^LCCKFetchProfilesCompletionHandler)(NSArray<id<LCCKUserDelegate>> *users, NSError *error);

/*!
*  @brief When LeanCloudChatKit wants to fetch profiles, this block will be invoked.
*  @param userIds User ids
*  @param completionHandler The block to execute with the users' information for the userIds. Always execute this block at some point during your implementation of this method on main thread. Specify users' information how you want ChatKit to show.
*/
typedef void(^LCCKFetchProfilesBlock)(NSArray<NSString *> *userIds, LCCKFetchProfilesCompletionHandler completionHandler);

@property (nonatomic, copy) LCCKFetchProfilesBlock fetchProfilesBlock;

/*!
*  @brief Add the ablitity to fetch profiles.
*  @attention  You must get peer information by peer id with a synchronous implementation.
*              If implemeted, this block will be invoked automatically by LeanCloudChatKit for fetching peer profile.
*/
- (void)setFetchProfilesBlock:(LCCKFetchProfilesBlock)fetchProfilesBlock;

用法如下所示:

#warning 注意:setFetchProfilesBlock 方法必须实现,如果不实现,ChatKit将无法显示用户头像、用户昵称。以下方法循环模拟了通过 userIds 同步查询 users 信息的过程,这里需要替换为 App 的 API 同步查询
   [[LCChatKit sharedInstance] setFetchProfilesBlock:^(NSArray<NSString *> *userIds,
                            LCCKFetchProfilesCompletionHandler completionHandler) {
        if (userIds.count == 0) {
            NSInteger code = 0;
            NSString *errorReasonText = @"User ids is nil";
            NSDictionary *errorInfo = @{
                                        @"code":@(code),
                                        NSLocalizedDescriptionKey : errorReasonText,
                                        };
            NSError *error = [NSError errorWithDomain:NSStringFromClass([self class])
                                                 code:code
                                             userInfo:errorInfo];
            
            !completionHandler ?: completionHandler(nil, error);
            return;
        }
        
        NSMutableArray *users = [NSMutableArray arrayWithCapacity:userIds.count];
#warning 注意:以下方法循环模拟了通过 userIds 同步查询 users 信息的过程,这里需要替换为 App 的 API 同步查询
        
        [userIds enumerateObjectsUsingBlock:^(NSString *_Nonnull clientId, NSUInteger idx,
                                              BOOL *_Nonnull stop) {
            NSPredicate *predicate = [NSPredicate predicateWithFormat:@"peerId like %@", clientId];
            //这里的LCCKContactProfiles,LCCKProfileKeyPeerId都为事先的宏定义,
            NSArray *searchedUsers = [LCCKContactProfiles filteredArrayUsingPredicate:predicate];
            if (searchedUsers.count > 0) {
                NSDictionary *user = searchedUsers[0];
                NSURL *avatarURL = [NSURL URLWithString:user[LCCKProfileKeyAvatarURL]];
                LCCKUser *user_ = [LCCKUser userWithUserId:user[LCCKProfileKeyPeerId]
                                                      name:user[LCCKProfileKeyName]
                                                 avatarURL:avatarURL
                                                  clientId:clientId];
                [users addObject:user_];
            } else {
                //注意:如果网络请求失败,请至少提供 ClientId!
                LCCKUser *user_ = [LCCKUser userWithClientId:clientId];
                [users addObject:user_];
            }
        }];
        // 模拟网络延时,3秒
        //         sleep(3);
        
#warning 重要:completionHandler 这个 Bock 必须执行,需要在你**获取到用户信息结束**后,将信息传给该Block!
        !completionHandler ?: completionHandler([users copy], nil);
    }];

对于以上 Fetch 方法的这种应用场景,其实用方法的返回值也可以实现,但是与 CompletionHandler 相比,无法自由切换线程是个弊端。


原文链接: 《有一种 Block 叫 Callback,有一种 Callback 叫 CompletionHandler》

Posted by 微博@iOS程序犭袁

iOS 防 DNS 污染方案调研(四)--- Cookie 业务场景

iOS 防 DNS 污染方案调研 --- Cookie 业务场景

对应的GitHub仓库镜像地址在这里 ,欢迎提PR进行修改。

概述

本文将讨论下类似这样的问题:

  • WKWebView 对于 Cookie 的管理一直是它的短板,那么 iOS11 是否有改进,如果有,如何利用这样的改进?
  • 采用 IP 直连方案后,服务端返回的 Cookie 里的 Domain 字段也会使用 IP 。如果 IP 是动态的,就有可能导致一些问题:由于许多 H5 业务都依赖于 Cookie 作登录态校验,而 WKWebView 上请求不会自动携带 Cookie。

WKWebView 使用 NSURLProtocol 拦截请求无法获取 Cookie 信息

iOS11推出了新的 API WKHTTPCookieStore 可以用来拦截 WKWebView 的 Cookie 信息

用法示例如下:

  WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
  //get cookies
   [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
       NSLog(@"All cookies %@",cookies);
   }];

   //set cookie
   NSMutableDictionary *dict = [NSMutableDictionary dictionary];
   dict[NSHTTPCookieName] = @"userid";
   dict[NSHTTPCookieValue] = @"123";
   dict[NSHTTPCookieDomain] = @"xxxx.com";
   dict[NSHTTPCookiePath] = @"/";

   NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:dict];
   [cookieStroe setCookie:cookie completionHandler:^{
       NSLog(@"set cookie");
   }];

   //delete cookie
   [cookieStroe deleteCookie:cookie completionHandler:^{
       NSLog(@"delete cookie");
   }];

利用 iOS11 API WKHTTPCookieStore 解决 WKWebView 首次请求不携带 Cookie 的问题

问题说明:由于许多 H5 业务都依赖于 Cookie 作登录态校验,而 WKWebView 上请求不会自动携带 Cookie。比如,如果你在Native层面做了登陆操作,获取了Cookie信息,也使用 NSHTTPCookieStorage 存到了本地,但是使用 WKWebView 打开对应网页时,网页依然处于未登陆状态。如果是登陆也在 WebView 里做的,就不会有这个问题。

iOS11 的 API 可以解决该问题,只要是存在 WKHTTPCookieStore 里的 cookie,WKWebView 每次请求都会携带,存在 NSHTTPCookieStorage 的cookie,并不会每次都携带。于是会发生首次 WKWebView 请求不携带 Cookie 的问题。

解决方法:

在执行 -[WKWebView loadReques:] 前将 NSHTTPCookieStorage 中的内容复制到 WKHTTPCookieStore 中,以此来达到 WKWebView Cookie 注入的目的。示例代码如下:

       [self copyNSHTTPCookieStorageToWKHTTPCookieStoreWithCompletionHandler:^{
           NSURL *url = [NSURL URLWithString:@"https://www.v2ex.com"];
           NSURLRequest *request = [NSURLRequest requestWithURL:url];
           [_webView loadRequest:request];
       }];
- (void)copyNSHTTPCookieStorageToWKHTTPCookieStoreWithCompletionHandler:(nullable void (^)())theCompletionHandler; {
   NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
   WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
   if (cookies.count == 0) {
       !theCompletionHandler ?: theCompletionHandler();
       return;
   }
   for (NSHTTPCookie *cookie in cookies) {
       [cookieStroe setCookie:cookie completionHandler:^{
           if ([[cookies lastObject] isEqual:cookie]) {
               !theCompletionHandler ?: theCompletionHandler();
               return;
           }
       }];
   }
}

这个是 iOS11 的API,针对iOS11之前的系统,需要另外处理。

利用 iOS11 之前的 API 解决 WKWebView 首次请求不携带 Cookie 的问题

通过让所有 WKWebView 共享同一个 WKProcessPool 实例,可以实现多个 WKWebView 之间共享 Cookie(session Cookie and persistent Cookie)数据。不过 WKWebView WKProcessPool 实例在 app 杀进程重启后会被重置,导致 WKProcessPool 中的 Cookie、session Cookie 数据丢失,目前也无法实现 WKProcessPool 实例本地化保存。可以采取 cookie 放入 Header 的方法来做。

WKWebView * webView = [WKWebView new]; 
NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://xxx.com/login"]]; 
[request addValue:@"skey=skeyValue" forHTTPHeaderField:@"Cookie"]; 
[webView loadRequest:request];

其中对于 skey=skeyValue 这个cookie值的获取,也可以统一通过domain获取,获取的方法,可以参照下面的工具类:

HTTPDNSCookieManager.h

#ifndef HTTPDNSCookieManager_h
#define HTTPDNSCookieManager_h

// URL匹配Cookie规则
typedef BOOL (^HTTPDNSCookieFilter)(NSHTTPCookie *, NSURL *);

@interface HTTPDNSCookieManager : NSObject

+ (instancetype)sharedInstance;

/**
指定URL匹配Cookie策略

@param filter 匹配器
*/
- (void)setCookieFilter:(HTTPDNSCookieFilter)filter;

/**
处理HTTP Reponse携带的Cookie并存储

@param headerFields HTTP Header Fields
@param URL 根据匹配策略获取查找URL关联的Cookie
@return 返回添加到存储的Cookie
*/
- (NSArray<NSHTTPCookie *> *)handleHeaderFields:(NSDictionary *)headerFields forURL:(NSURL *)URL;

/**
匹配本地Cookie存储,获取对应URL的request cookie字符串

@param URL 根据匹配策略指定查找URL关联的Cookie
@return 返回对应URL的request Cookie字符串
*/
- (NSString *)getRequestCookieHeaderForURL:(NSURL *)URL;

/**
删除存储cookie

@param URL 根据匹配策略查找URL关联的cookie
@return 返回成功删除cookie数
*/
- (NSInteger)deleteCookieForURL:(NSURL *)URL;

@end

#endif /* HTTPDNSCookieManager_h */

HTTPDNSCookieManager.m
#import <Foundation/Foundation.h>
#import "HTTPDNSCookieManager.h"

@implementation HTTPDNSCookieManager
{
   HTTPDNSCookieFilter cookieFilter;
}

- (instancetype)init {
   if (self = [super init]) {
       /**
           此处设置的Cookie和URL匹配策略比较简单,检查URL.host是否包含Cookie的domain字段
           通过调用setCookieFilter接口设定Cookie匹配策略,
           比如可以设定Cookie的domain字段和URL.host的后缀匹配 | URL是否符合Cookie的path设定
           细节匹配规则可参考RFC 2965 3.3节
        */
       cookieFilter = ^BOOL(NSHTTPCookie *cookie, NSURL *URL) {
           if ([URL.host containsString:cookie.domain]) {
               return YES;
           }
           return NO;
       };
   }
   return self;
}

+ (instancetype)sharedInstance {
   static id singletonInstance = nil;
   static dispatch_once_t onceToken;
   dispatch_once(&onceToken, ^{
       if (!singletonInstance) {
           singletonInstance = [[super allocWithZone:NULL] init];
       }
   });
   return singletonInstance;
}

+ (id)allocWithZone:(struct _NSZone *)zone {
   return [self sharedInstance];
}

- (id)copyWithZone:(struct _NSZone *)zone {
   return self;
}

- (void)setCookieFilter:(HTTPDNSCookieFilter)filter {
   if (filter != nil) {
       cookieFilter = filter;
   }
}

- (NSArray<NSHTTPCookie *> *)handleHeaderFields:(NSDictionary *)headerFields forURL:(NSURL *)URL {
   NSArray *cookieArray = [NSHTTPCookie cookiesWithResponseHeaderFields:headerFields forURL:URL];
   if (cookieArray != nil) {
       NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
       for (NSHTTPCookie *cookie in cookieArray) {
           if (cookieFilter(cookie, URL)) {
               NSLog(@"Add a cookie: %@", cookie);
               [cookieStorage setCookie:cookie];
           }
       }
   }
   return cookieArray;
}

- (NSString *)getRequestCookieHeaderForURL:(NSURL *)URL {
   NSArray *cookieArray = [self searchAppropriateCookies:URL];
   if (cookieArray != nil && cookieArray.count > 0) {
       NSDictionary *cookieDic = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieArray];
       if ([cookieDic objectForKey:@"Cookie"]) {
           return cookieDic[@"Cookie"];
       }
   }
   return nil;
}

- (NSArray *)searchAppropriateCookies:(NSURL *)URL {
   NSMutableArray *cookieArray = [NSMutableArray array];
   NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
   for (NSHTTPCookie *cookie in [cookieStorage cookies]) {
       if (cookieFilter(cookie, URL)) {
           NSLog(@"Search an appropriate cookie: %@", cookie);
           [cookieArray addObject:cookie];
       }
   }
   return cookieArray;
}

- (NSInteger)deleteCookieForURL:(NSURL *)URL {
   int delCount = 0;
   NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
   for (NSHTTPCookie *cookie in [cookieStorage cookies]) {
       if (cookieFilter(cookie, URL)) {
           NSLog(@"Delete a cookie: %@", cookie);
           [cookieStorage deleteCookie:cookie];
           delCount++;
       }
   }
   return delCount;
}

@end

使用方法示例:

发送请求

WKWebView * webView = [WKWebView new]; 
NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://xxx.com/login"]]; 
NSString *value = [[HTTPDNSCookieManager sharedInstance] getRequestCookieHeaderForURL:url];
[request setValue:value forHTTPHeaderField:@"Cookie"];
[webView loadRequest:request];

接收处理请求:

   NSURLSessionTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
       if (!error) {
           NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
           // 解析 HTTP Response Header,存储cookie
           [[HTTPDNSCookieManager sharedInstance] handleHeaderFields:[httpResponse allHeaderFields] forURL:url];
       }
   }];
   [task resume];

通过 document.cookie 设置 Cookie 解决后续页面(同域)Ajax、iframe 请求的 Cookie 问题;

WKUserContentController* userContentController = [WKUserContentController new]; 
WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource: @"document.cookie = 'skey=skeyValue';" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO]; 
[userContentController addUserScript:cookieScript];

Cookie包含动态 IP 导致登陆失效问题

关于COOKIE失效的问题,假如客户端登录 session 存在 COOKIE,此时这个域名配置了多个IP,使用域名访问会读对应域名的COOKIE,使用IP访问则去读对应IP的COOKIE,假如前后两次使用同一个域名配置的不同IP访问,会导致COOKIE的登录session失效,

如果APP里面的webview页面需要用到系统COOKIE存的登录session,之前APP所有本地网络请求使用域名访问,是可以共用COOKIE的登录session的,但现在本地网络请求使用httpdns后改用IP访问,导致还使用域名访问的webview读不到系统COOKIE存的登录session了(系统COOKIE对应IP了)。IP直连后,服务端返回Cookie包含动态 IP 导致登陆失效。

使用IP访问后,服务端返回的cookie也是IP。导致可能使用对应的域名访问,无法使用本地cookie,或者使用隶属于同一个域名的不同IP去访问,cookie也对不上,导致登陆失效,是吧。

我这边的思路是这样的,

  • 应该得干预cookie的存储,基于域名。
  • 根源上,api域名返回单IP

第二种思路将失去DNS调度特性,故不考虑。第一种思路更为可行。

基于 iOS11 API WKHTTPCookieStore 来解决 WKWebView 的 Cookie 管理问题

当每次服务端返回cookie后,在存储前都进行下改造,使用域名替换下IP。
之后虽然每次网络请求都是使用IP访问,但是host我们都手动改为了域名,这样本地存储的 cookie 也就能对得上了。

代码演示:

在网络请求成功后,或者加载网页成功后,主动将本地的 domain 字段为 IP 的 Cookie 替换 IP 为 host 域名地址。

- (void)updateWKHTTPCookieStoreDomainFromIP:(NSString *)IP toHost:(NSString *)host {
   WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
   [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
       [[cookies copy] enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull cookie, NSUInteger idx, BOOL * _Nonnull stop) {
           if ([cookie.domain isEqualToString:IP]) {
               NSMutableDictionary<NSHTTPCookiePropertyKey, id> *dict = [NSMutableDictionary dictionaryWithDictionary:cookie.properties];
               dict[NSHTTPCookieDomain] = host;
               NSHTTPCookie *newCookie = [NSHTTPCookie cookieWithProperties:[dict copy]];
               [cookieStroe setCookie:newCookie completionHandler:^{
                   [self logCookies];
                   [cookieStroe deleteCookie:cookie
                           completionHandler:^{
                               [self logCookies];
                           }];
               }];
           }
       }];
   }];
}

iOS11中也提供了对应的 API 供我们来处理替换 Cookie 的时机,那就是下面的API:

@protocol WKHTTPCookieStoreObserver <NSObject>
@optional
- (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore;
@end
//WKHTTPCookieStore
/*! @abstract Adds a WKHTTPCookieStoreObserver object with the cookie store.
@param observer The observer object to add.
@discussion The observer is not retained by the receiver. It is your responsibility
to unregister the observer before it becomes invalid.
*/
- (void)addObserver:(id<WKHTTPCookieStoreObserver>)observer;

/*! @abstract Removes a WKHTTPCookieStoreObserver object from the cookie store.
@param observer The observer to remove.
*/
- (void)removeObserver:(id<WKHTTPCookieStoreObserver>)observer;

用法如下:

@interface WebViewController ()<WKHTTPCookieStoreObserver>
- (void)viewDidLoad {
   [super viewDidLoad];
   [NSURLProtocol registerClass:[WebViewURLProtocol class]];
   NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
   [cookieStorage setCookieAcceptPolicy:NSHTTPCookieAcceptPolicyAlways];
   WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
   [cookieStroe addObserver:self];

   [self.view addSubview:self.webView];
   //... ...
}

#pragma mark -
#pragma mark - WKHTTPCookieStoreObserver Delegate Method

- (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore {
   [self updateWKHTTPCookieStoreDomainFromIP:CYLIP toHost:CYLHOST];
}

-updateWKHTTPCookieStoreDomainFromIP 方法的实现,在上文已经给出。

这个方案需要客户端维护一个IP --> HOST的映射关系,需要能从 IP 反向查找到 HOST,这个维护成本还时挺高的。下面介绍下,更通用的方法,也是iOS11 之前的处理方法:

iOS11 之前的处理方法:NSURLProtocal拦截后,手动管理 Cookie 的存储:

步骤:
做 IP 替换时将原始 URL 保存到 Header 中


+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    NSMutableURLRequest *mutableReq = [request mutableCopy];
    NSString *originalUrl = mutableReq.URL.absoluteString;
    NSURL *url = [NSURL URLWithString:originalUrl];
    // 异步接口获取IP地址
    NSString *ip = [[HttpDnsService sharedInstance] getIpByHostAsync:url.host];
    if (ip) {
        NSRange hostFirstRange = [originalUrl rangeOfString:url.host];
        if (NSNotFound != hostFirstRange.location) {
            NSString *newUrl = [originalUrl stringByReplacingCharactersInRange:hostFirstRange withString:ip];
            mutableReq.URL = [NSURL URLWithString:newUrl];
            [mutableReq setValue:url.host forHTTPHeaderField:@"host"];
            // 添加originalUrl保存原始URL
            [mutableReq addValue:originalUrl forHTTPHeaderField:@"originalUrl"];
        }
    }
    NSURLRequest *postRequestIncludeBody = [mutableReq cyl_getPostRequestIncludeBody];
    return postRequestIncludeBody;
}

然后获取到数据后,手动管理 Cookie:

- (void)handleCookiesFromResponse:(NSURLResponse *)response {
    NSString *originalURLString = [self.request valueForHTTPHeaderField:@"originalUrl"];
    if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
        NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
        NSDictionary<NSString *, NSString *> *allHeaderFields = httpResponse.allHeaderFields;
        if (originalURLString && originalURLString.length > 0) {
            NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:allHeaderFields forURL: [[NSURL alloc] initWithString:originalURLString]];
            if (cookies && cookies.count > 0) {
                NSURL *originalURL = [NSURL URLWithString:originalURLString];
                [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookies:cookies forURL:originalURL mainDocumentURL:nil];
            }
        }
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)newRequest completionHandler:(void (^)(NSURLRequest *))completionHandler {
    NSString *location = response.allHeaderFields[@"Location"];
    NSURL *url = [[NSURL alloc] initWithString:location];
    NSMutableURLRequest *mRequest = [newRequest mutableCopy];
    mRequest.URL = url;
    if (location && location.length > 0) {
        if ([[newRequest.HTTPMethod lowercaseString] isEqualToString:@"post"]) {
            // POST重定向为GET
            mRequest.HTTPMethod = @"GET";
            mRequest.HTTPBody = nil;
        }
        [mRequest setValue:nil forHTTPHeaderField:@"host"];
        // 在这里为 request 添加 cookie 信息。
        [self handleCookiesFromResponse:response];
        [CYLURLProtocol removePropertyForKey:CYLURLProtocolHandledKey inRequest:mRequest];
        completionHandler(mRequest);
    } else{
       completionHandler(mRequest);
    }
}

发送请求前,向请求中添加Cookie信息:

+ (void)handleCookieWithRequest:(NSMutableURLRequest *)request {
   NSString* originalURLString = [request valueForHTTPHeaderField:@"originalUrl"];
   if (!originalURLString || originalURLString.length == 0) {
       return;
   }
   NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
   if (cookies && cookies.count >0) {
       NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
       NSString *cookieString = [cookieHeaders objectForKey:@"Cookie"];
       [request addValue:cookieString forHTTPHeaderField:@"Cookie"];
   }
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
   NSMutableURLRequest *mutableReq = [request mutableCopy];
//...
    [self handleCookieWithRequest:mutableReq];
   return [mutableReq copy];
}

相关的文章:

QQ、微信等其它能联系到我的途径



技术交流群

社交媒体 链接 社交媒体 链接
Telegram
群交流

https://t.me/iteatime

QQ
交流群

群号: 465239521

个人联系方式

📺[CN]我在YouTube上进行技术分享,欢迎关注: @iTeaTime Tech | 技术清谈

📺[EN] Welcome to fellow my YouTube channel: @iTeaTime Tech | 技术清谈

社交媒体 链接 社交媒体 链接
个人
微信
加我微信 chenyilong1010,
邀请进入微信群

个人
公众号
公众号:iTeaTime技术清谈

个人
微博
http://weibo.com/luohanchenyilong

个人
Twitter
https://twitter.com/iOSChenYilong

个人
GitHub
https://github.com/ChenYilong

个人
知乎

http://s.zhihu.com/BU5Mp
知识
星球
知识星球:技术清谈
向我
打赏
支付宝、微信打赏

//one more thing

移动端网络请求优化之 IP 测速排序方案

1. IP 测速排序方案的目的

无论是从 Local DNS 解析域名,获取到 IP 列表,还是从第三方的 DNS 解析服务中,获取到域名对应的 IP 列表。我们获得多个 IP 后,总是想选取一个最优的 IP 使用,本文主要探讨如何在客户端探测 IP 的连接性以及连接速度,保证返回可用性最好的IP,以达到“IP优选”的目的。

2. 新浪开源的 httpdns 的 sdk 里的测速逻辑

新浪开源一个 HTTPDNSLib ,里面包含了测速逻辑,GitHub地址如下:

我们以该 sdk 里的测速逻辑为例进行原理解析。

3. IP 测试实现原理

使用 linux socket connect 和 select 函数实现的。 基于以下原理:

  1. 即使套接口是非阻塞的。如果连接的服务器在同一台主机上,那么在调用connect 建立连接时,连接通常会立即建立成功,我们必须处理这种情况。
  2. 源自Berkeley的实现(和Posix.1g)有两条与select 和非阻塞IO相关的规则:
    A. 当连接建立成功时,套接口描述符变成可写;
    B. 当连接出错时,套接口描述符变成既可读又可写。

详细的测速实现如下,原理参考注释:

以 iOS 实现为例:

//#define CYL_SOCKET_CONNECT_TIMEOUT 10 //单位秒
//#define CYL_SOCKET_CONNECT_TIMEOUT_RTT 600000//10分钟 单位毫秒

- (int)testSpeedOf:(NSString *)ip port:(int16_t)port {
   NSString *oldIp = ip;
   //request time out
   float rtt = 0.0;
   //sock:将要被设置或者获取选项的套接字。
   int s = 0;
   struct sockaddr_in saddr;
   saddr.sin_family = AF_INET;
   // MARK: - 设置端口,这里需要根据需要自定义,默认是80端口。
   saddr.sin_port = htons(port);
   saddr.sin_addr.s_addr = inet_addr([ip UTF8String]);
   //saddr.sin_addr.s_addr = inet_addr("1.1.1.123");
   if( (s=socket(AF_INET, SOCK_STREAM, 0)) < 0) {
       NSLog(@"ERROR:%s:%d, create socket failed.",__FUNCTION__,__LINE__);
       return 0;
   }
   NSDate *startTime = [NSDate date];
   NSDate *endTime;
   //为了设置connect超时 把socket设置称为非阻塞
   int flags = fcntl(s, F_GETFL,0);
   fcntl(s,F_SETFL, flags | O_NONBLOCK);
   //对于阻塞式套接字,调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或者出错时才返回;
   //对于非阻塞式套接字,如果调用connect函数会之间返回-1(表示出错),且错误为EINPROGRESS,表示连接建立,建立启动但是尚未完成;
   //如果返回0,则表示连接已经建立,这通常是在服务器和客户在同一台主机上时发生。
   int i = connect(s,(struct sockaddr*)&saddr, sizeof(saddr));
   if(i == 0) {
       //建立连接成功,返回rtt时间。 因为connect是非阻塞,所以这个时间就是一个函数执行的时间,毫秒级,没必要再测速了。
       close(s);
       return 1;
   }
   struct timeval tv;
   int valopt;
   socklen_t lon;
   tv.tv_sec = CYL_SOCKET_CONNECT_TIMEOUT;
   tv.tv_usec = 0;
   
   fd_set myset;
   FD_ZERO(&myset);
   FD_SET(s, &myset);
   
   // MARK: - 使用select函数,对套接字的IO操作设置超时。
   /**
    select函数
    select是一种IO多路复用机制,它允许进程指示内核等待多个事件的任何一个发生,并且在有一个或者多个事件发生或者经历一段指定的时间后才唤醒它。
    connect本身并不具有设置超时功能,如果想对套接字的IO操作设置超时,可使用select函数。
    **/
   int maxfdp = s+1;
   int j = select(maxfdp, NULL, &myset, NULL, &tv);
   
   if (j == 0) {
       NSLog(@"INFO:%s:%d, test rtt of (%@) timeout.",__FUNCTION__,__LINE__, oldIp);
       rtt = CYL_SOCKET_CONNECT_TIMEOUT_RTT;
       close(s);
       return rtt;
   }
   
   if (j < 0) {
       NSLog(@"ERROR:%s:%d, select function error.",__FUNCTION__,__LINE__);
       rtt = 0;
       close(s);
       return rtt;
   }
   
   /**
    对于select和非阻塞connect,注意两点:
    [1] 当连接成功建立时,描述符变成可写; [2] 当连接建立遇到错误时,描述符变为即可读,也可写,遇到这种情况,可调用getsockopt函数。
    **/
   lon = sizeof(int);
   //valopt 表示错误信息。
   // MARK: - 测试核心逻辑,连接后,获取错误信息,如果没有错误信息就是访问成功
   /*!
    * //getsockopt函数可获取影响套接字的选项,比如SOCKET的出错信息
    * (get socket option)
    */
   getsockopt(s, SOL_SOCKET, SO_ERROR, (void*)(&valopt), &lon);
   //如果有错误信息:
   if (valopt) {
       NSLog(@"ERROR:%s:%d, select function error.",__FUNCTION__,__LINE__);
       rtt = 0;
   } else {
       endTime = [NSDate date];
       rtt = [endTime timeIntervalSinceDate:startTime] * 1000;
   }
   close(s);
   return rtt;
}

注意:当出现错误的时候测试结果是速度是0,所以排序时不能简单地按照值大小排序,可以先删除速度为0的结果,或者将速度为零重置为超时时间,比如上面的 CYL_SOCKET_CONNECT_TIMEOUT_RTT 。避免错误IP为0,结果排序后排在前面。

关于《iOS防DNS污染方案调研---SNI业务场景》的疑问

Hi, ChenYilong!
在标题包含的文中我读到“使用 NSURLProtocol 拦截 NSURLSession 请求丢失 body”,但我实践中使用NSURLConnection 通过post请求 NSURLProtocol 也会拦截,所以这段话是不是有歧义,或者劳烦解释下。

大话Socket

大话Socket

  • 更新:2020-05-09-23:41:35。
  • 注意:查看正文前,请先查看文末的评论,以确认是否有勘误。

要了解Socket首先要了解 TCP,他们两个的关系可以说是:

Socket 是抽象出来的使用 TCP/UDP 的概念模型,屏蔽掉了晦涩的底层协议的实现,是一个接口。

最近看到了一张如此详细的 TCP 三次握手和四次挥手,打印一张放工位!摘自《图解网络硬件》249页 图5-11 《TCP的三次握手》

《TCP三次握手图解:iOS的Socket开发基础 http://www.coderyi.com/archives/429》

所谓的X、X+1Y、Y+1
对应于你收到了没、我收到了你收到'我收到'没、我收到了不用回了,为什么用+1表示呢?那是因为前两个指的是一个人,后两个指的是一个人。
四组是三个连接,每个连接的序号依次是X、Y、Z。

TCP的连接过程就像两个人的对话:

想象一下,每次这俩儿人聊天,都要像下面这样一来一回三次,接下来他们才能【好好聊天了。。。】真是有点“作”。。。

我是客户端,树懒是服务端,演示三次握手、数据传输步骤

和下面这个图有异曲同工之妙:

其实有个问题,为什么连接的时候是三次握手,关闭的时候却是四次挥手?

因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,”你发的FIN报文我收到了”。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

而这一设计,主要是因为“服务器不是你想关就能关”。。。

比如说两个热恋中的人正在QQ上发送一个传mp4格式的文件,

A说,我要下QQ了,

B说:我知道了,你下吧。

A说,那我关了,(想关)

但是当A尝试关闭QQ的时候,QQ弹窗说“正在传输文件,传输完成后自动关闭QQ?”

这时候A对B说,呀,正在传东西,等传完了,我就关吧。(不能关)

B说:行。既然关不掉,不行再聊会儿呗?

A:聊吧。。。传完了啊,下了啊(传输结束了--能关)

B:下吧。我也下了。。。

就是多了一个Finish报文。

或者简单点表示是这样的:

图片演示了四次挥手,与三次握手相比,只多了一个被动方确认自身任务Finish的动作。

总结下相关的函数:

创建套接字

Socket(af,type,protocol)

建立地址和套接字的联系

bind(sockid, local addr, addrlen)

服务器端侦听客户端的请求

listen( Sockid ,quenlen)

建立服务器/客户端的连接 (面向连接TCP)

客户端请求连接

Connect(sockid, destaddr, addrlen)

服务器端等待从编号为Sockid的Socket上接收客户连接请求

newsockid=accept(SockidClientaddr, paddrlen)

发送/接收数据

面向连接:

send(sockid, buff, bufflen) 
recv( )

面向无连接:

sendto(sockid,buff,…,addrlen) 
recvfrom( )

释放套接字

close(sockid)

至于为什么是三次握手,而不是二次握手,还有四次握手,可以看看下面的类比:

三次握手 二次握手 四次握手

参考:http://zhihu.com/question/24853633/answer/114872771

三次握手的对话,也挺像这个的,哈哈:

  • A: How are you?
  • B: I'm fine, thanks,and you?
  • A: I'm fine too.

原文地址:#5

点了"在看"的同学

每晚都有人给你说晚安

"在看"👇👇

iOS unrecognized selector crash 自修复技术实现与原理解析



前言

在开发中 unrecognized selector sent to instance XXXXX 是非常常见的 crash 类型。

例如调用以下一段代码就会产生crash

[[NSNull null] performSelector:@selector(fooDoesNotRecognizeSelector1)];

具体 crash 时的表现见下:

2018-01-11 16:28:04.433573+0800 CYLSwizzleMainDemo[13252:156773356] -[NSNull fooDoesNotRecognizeSelector1]: unrecognized selector sent to instance 0x102870ef0
2018-01-11 16:28:04.440436+0800 CYLSwizzleMainDemo[13252:156773356] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSNull fooDoesNotRecognizeSelector1]: unrecognized selector sent to instance 0x102870ef0'
*** First throw call stack:
(
   0   CoreFoundation                      0x00000001025a712b __exceptionPreprocess + 171
   1   libobjc.A.dylib                     0x0000000101c3bf41 objc_exception_throw + 48
   2   CoreFoundation                      0x0000000102628024 -[NSObject(NSObject) doesNotRecognizeSelector:] + 132
   3   CoreFoundation                      0x0000000102529f78 ___forwarding___ + 1432
   4   CoreFoundation                      0x0000000102529958 _CF_forwarding_prep_0 + 120
   5   CYLSwizzleMainDemo                  0x0000000101321cef -[AppDelegate application:didFinishLaunchingWithOptions:] + 527
   6   UIKit                               0x0000000103315ac6 -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:] + 299
   7   UIKit                               0x0000000103317544 -[UIApplication _callInitializationDelegatesForMainScene:transitionContext:] + 4113
   8   UIKit                               0x000000010331c9e7 -[UIApplication _runWithMainScene:transitionContext:completion:] + 1720
   9   UIKit                               0x00000001036e5fb0 __111-[__UICanvasLifecycleMonitor_Compatability _scheduleFirstCommitForScene:transition:firstActivation:completion:]_block_invoke + 924
   10  UIKit                               0x0000000103abb998 +[_UICanvas _enqueuePostSettingUpdateTransactionBlock:] + 153
   11  UIKit                               0x00000001036e5ba9 -[__UICanvasLifecycleMonitor_Compatability _scheduleFirstCommitForScene:transition:firstActivation:completion:] + 249
   12  UIKit                               0x00000001036e6423 -[__UICanvasLifecycleMonitor_Compatability activateEventsOnly:withContext:completion:] + 696
   13  UIKit                               0x0000000104063fe9 __82-[_UIApplicationCanvas _transitionLifecycleStateWithTransitionContext:completion:]_block_invoke + 262
   14  UIKit                               0x0000000104063ea2 -[_UIApplicationCanvas _transitionLifecycleStateWithTransitionContext:completion:] + 444
   15  UIKit                               0x0000000103d410a0 __125-[_UICanvasLifecycleSettingsDiffAction performActionsForCanvas:withUpdatedScene:settingsDiff:fromSettings:transitionContext:]_block_invoke + 221
   16  UIKit                               0x0000000103f40126 _performActionsWithDelayForTransitionContext + 100
   17  UIKit                               0x0000000103d40f63 -[_UICanvasLifecycleSettingsDiffAction performActionsForCanvas:withUpdatedScene:settingsDiff:fromSettings:transitionContext:] + 231
   18  UIKit                               0x0000000103abaff5 -[_UICanvas scene:didUpdateWithDiff:transitionContext:completion:] + 392
   19  UIKit                               0x000000010331b266 -[UIApplication workspace:didCreateScene:withTransitionContext:completion:] + 523
   20  UIKit                               0x00000001038f5b97 -[UIApplicationSceneClientAgent scene:didInitializeWithEvent:completion:] + 369
   21  FrontBoardServices                  0x0000000106d74cc0 -[FBSSceneImpl _didCreateWithTransitionContext:completion:] + 338
   22  FrontBoardServices                  0x0000000106d7d7b5 __56-[FBSWorkspace client:handleCreateScene:withCompletion:]_block_invoke_2 + 235
   23  libdispatch.dylib                   0x0000000105fd933d _dispatch_client_callout + 8
   24  libdispatch.dylib                   0x0000000105fde9f3 _dispatch_block_invoke_direct + 592
   25  FrontBoardServices                  0x0000000106da9498 __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ + 24
   26  FrontBoardServices                  0x0000000106da914e -[FBSSerialQueue _performNext] + 464
   27  FrontBoardServices                  0x0000000106da96bd -[FBSSerialQueue _performNextFromRunLoopSource] + 45
   28  CoreFoundation                      0x000000010254a101 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
   29  CoreFoundation                      0x00000001025e9f71 __CFRunLoopDoSource0 + 81
   30  CoreFoundation                      0x000000010252ea19 __CFRunLoopDoSources0 + 185
   31  CoreFoundation                      0x000000010252dfff __CFRunLoopRun + 1279
   32  CoreFoundation                      0x000000010252d889 CFRunLoopRunSpecific + 409
   33  GraphicsServices                    0x000000010763b9c6 GSEventRunModal + 62
   34  UIKit                               0x000000010331e4d2 UIApplicationMain + 159
   35  CYLSwizzleMainDemo                  0x00000001013230bf main + 111
   36  libdyld.dylib                       0x0000000106055d81 start + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException
(lldb) 

这类 crash 尤其在混合开发,或者 JS 与 native 交互中经常遇到,非常影响用户体验,也降低了 app 的质量与稳定性。

常见的 crash 场景可以总结为:

  • JSON 解析后,空值解析为 NSNULL 对象,造成 crash
  • JS 调用 native 方法,结果由于native底版本,或者 JS 代码编写的问题,找不到方法,导致 app crash。

在研究如何实现 app 自修复该 bug 前,我们可以研究下什么时候会报 unrecognized selector 的异常?

什么时候会报unrecognized selector的异常?

objc是动态语言,每个方法在运行时会被动态转为消息发送,即:objc_msgSend(receiver, selector)。objc在向一个对象发送消息时,runtime库会根据对象的isa指针找到该对象实际所属的类,然后在该类中的方法列表以及其父类方法列表中寻找方法运行,如果,在最顶层的父类中依然找不到相应的方法时,程序在运行时会挂掉并抛出异常unrecognized selector sent to XXX 。但是在这之前,objc的运行时会给出三次拯救程序崩溃的机会:

  1. Method resolution

objc运行时会调用+resolveInstanceMethod:或者 +resolveClassMethod:,让你有机会提供一个函数实现。如果你添加了函数,那运行时系统就会重新启动一次消息发送的过程,否则 ,运行时就会移到下一步,消息转发(Message Forwarding)。

  1. Fast forwarding

如果目标对象实现了-forwardingTargetForSelector:,Runtime 这时就会调用这个方法,给你把这个消息转发给其他对象的机会。
只要这个方法返回的不是nil和self,整个消息发送的过程就会被重启,当然发送的对象会变成你返回的那个对象。否则,就会继续Normal Fowarding。
这里叫Fast,只是为了区别下一步的转发机制。因为这一步不会创建任何新的对象,但下一步转发会创建一个NSInvocation对象,所以相对更快点。
3. Normal forwarding

这一步是Runtime最后一次给你挽救的机会。首先它会发送-methodSignatureForSelector:消息获得函数的参数和返回值类型。如果-methodSignatureForSelector:返回nil,Runtime则会发出-doesNotRecognizeSelector:消息,程序这时也就挂掉了。如果返回了一个函数签名,Runtime就会创建一个NSInvocation对象并发送-forwardInvocation:消息给目标对象。

拦截调用的整个流程即 Objective-C 的消息转发机制。其具体流程如下图:

enter image description here

unrecognized selector sent to instance XXXXX crash 自修复技术实现

原理简单来说:

当调用该对象上某个方法,而该对象上没有实现这个方法的时候,
可以通过“消息转发”进行解决。

可以利用消息转发机制的三个步骤,选择哪一步去改造比较合适呢?

这里我们选择了第二步forwardingTargetForSelector。引用 《大白健康系统--iOS APP运行时Crash自动修复系统》 的分析:

resolveInstanceMethod 需要在类的本身上动态添加它本身不存在的方法,这些方法对于该类本身来说冗余的
forwardInvocation 可以通过 NSInvocation 的形式将消息转发给多个对象,但是其开销较大,需要创建新的 NSInvocation 对象,并且 forwardInvocation 的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写
forwardingTargetForSelector 可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写
选择了 forwardingTargetForSelector 之后,可以将 NSObject 的该方法重写,做以下几步的处理:

具体如下:

  • hook forwardingTargetForSelector 方法
  • 添加白名单,限制hook的范围,排除内部类,并自定义需要hook的类
  • 创建桩类 ForwardingTarget
  • 为桩类动态添加对应的selector的imp,指向一个函数,返回 NSNull 对象
  • 将消息转移到该桩类对象 ForwardingTarget 上
  • 将 hook 掉的 crash 信息进行上报,方便发现问题,后期修复掉。

添加白名单,避免出现hook内部方法以及不必要的对象。
内部对象的特征是都以 _ 开头。
其他需要限制的部分,经常会出现在组件化开发、SDK开发中,避免影响到其他模块的正常工作,可以用类的前缀做区分。

其中动态创建的方法,返回值为什么返回一个 NSNull,而不是其他的值。

这样做的好处在于,在设置白名单的时候,只需要将 NSNull 设置进白名单,就可以解决方法返回值调用方法造成的crash。返回其他类型,就需要在白名单中多设置一种类型。

可以解决如下问题:

 id foo = [[NSNull null] performSelector:@selector(fooDoesNotRecognizeSelector1)];
 [foo performSelector:@selector(fooDoesNotRecognizeSelector2)];

hook时注意如果对象的类本身如果重写了forwardInvocation方法的话,就不应该对forwardingTargetForSelector进行重写了,否则会影响到该类型的对象原本的消息转发流程。

通过重写NSObjectforwardingTargetForSelector方法,我们就可以将无法识别的方法进行拦截并且将消息转发到安全的桩类对象中,从而可以使app继续正常运行。

具体的实现代码如下:

//
//  ForwardingTarge.h
//  
//
//  Created by ChenYilong on 18/01/10.
//  Copyright © 2018年  All rights reserved.
//

#import <Foundation/Foundation.h>

@interface ForwardingTarget : NSObject

@end
//
//  ForwardingTarge.m
//  
//
//  Created by ChenYilong on 18/01/10.
//  Copyright © 2018年  All rights reserved.
//

#import "ForwardingTarget.h"
#import <objc/runtime.h>

@implementation ForwardingTarget

id ForwardingTarget_dynamicMethod(id self, SEL _cmd) {
   return [NSNull null];
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
   class_addMethod(self.class, sel, (IMP)ForwardingTarget_dynamicMethod, "@@:");
   [super resolveInstanceMethod:sel];
   return YES;
}

- (id)forwardingTargetForSelector:(SEL)aSelector {
   id result = [super forwardingTargetForSelector:aSelector];
   return result;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
   id result = [super methodSignatureForSelector:aSelector];
   return result;
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
   [super forwardInvocation:anInvocation];
}

- (void)doesNotRecognizeSelector:(SEL)aSelector {
   [super doesNotRecognizeSelector:aSelector]; // crash
}

@end
//
//  NSObject+DoesNotRecognizeSelectorExtension.h
//  
//
//  Created by ChenYilong on 18/01/10.
//  Copyright © 2018年  All rights reserved.
//

#import <Foundation/Foundation.h>

@interface NSObject (DoesNotRecognizeSelectorExtension)

@end
//
//  NSObject+DoesNotRecognizeSelectorExtension.m
//  
//
//  Created by ChenYilong on 18/01/10.
//  Copyright © 2018年 All rights reserved.
//

#import "NSObject+DoesNotRecognizeSelectorExtension.h"
#import <objc/runtime.h>
#import "ForwardingTarget.h"

static ForwardingTarget *_target = nil;

@implementation NSObject (DoesNotRecognizeSelectorExtension)

+ (void)load {
   static dispatch_once_t onceToken;
   dispatch_once(&onceToken, ^{
       _target = [ForwardingTarget new];;
       not_recognize_selector_classMethodSwizzle([self class], @selector(forwardingTargetForSelector:), @selector(doesnot_recognize_selector_swizzleForwardingTargetForSelector:));
   });
}

+ (BOOL)isWhiteListClass:(Class)class {
   NSString *classString = NSStringFromClass(class);
   BOOL isInternal = [classString hasPrefix:@"_"];
   if (isInternal) {
       return NO;
   }
   BOOL isNull =  [classString isEqualToString:NSStringFromClass([NSNull class])];
   
   BOOL isMyClass  = [classString ...];
   return isNull || isMyClass;
}

- (id)doesnot_recognize_selector_swizzleForwardingTargetForSelector:(SEL)aSelector {
   id result = [self doesnot_recognize_selector_swizzleForwardingTargetForSelector:aSelector];
   if (result) {
       return result;
   }
   BOOL isWhiteListClass = [[self class] isWhiteListClass:[self class]];
   if (!isWhiteListClass) {
       return nil;
   }
   
   if (!result) {
       result = _target;
   }
   return result;
}

#pragma mark - private method

BOOL not_recognize_selector_classMethodSwizzle(Class aClass, SEL originalSelector, SEL swizzleSelector) {
   Method originalMethod = class_getInstanceMethod(aClass, originalSelector);
   Method swizzleMethod = class_getInstanceMethod(aClass, swizzleSelector);
   BOOL didAddMethod =
   class_addMethod(aClass,
                   originalSelector,
                   method_getImplementation(swizzleMethod),
                   method_getTypeEncoding(swizzleMethod));
   if (didAddMethod) {
       class_replaceMethod(aClass,
                           swizzleSelector,
                           method_getImplementation(originalMethod),
                           method_getTypeEncoding(originalMethod));
   } else {
       method_exchangeImplementations(originalMethod, swizzleMethod);
   }
   return YES;
}

@end

参考文献:


Posted by Posted by 微博@iOS程序犭袁 & 公众号@iTeaTime技术清谈
原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

iOS 防 DNS 污染方案调研(五)--- 302等 URL 重定向业务场景

iOS 防 DNS 污染方案调研 --- 302等 URL 重定向业务场景

概述

302等 URL 重定向业务场景需要解决的问题:

302 等重定向状态码,如何正确执行跳转逻辑,要求跳转后,依然需要执行 IP 直连逻辑,多次302,也能覆盖到。

302等 URL 重定向业务场景问题主要集中在 POST 请求上,解决方案的方向大致有几种:

  • 将请求方式统一替换为 GET
  • 解决 POST 请求时的重定向问题

将 URL 统一替换为 GET,这种方案在客户端这边是成本最低的,如果团队中达成一致是最好的。不过限制也是显而易见的。那么我们就着重讨论下如何解决 POST 请求时的重定向问题。

POST 请求的重定向问题

对于 GET 请求,重定向问题较为简单,我们着重讨论下 POST 请求的重定向问题,看下不同状态码下的响应方式。

下面介绍下重定向的类型以及解释:

重定向的类型 对应协议 解释
300 Multiple Choices HTTP 1.0 可选重定向,表示客户请求的资源已经被转向到另外的地址了,但是没有说明是否是永久重定向还是临时重定向。
301 Moved Permancently HTTP 1.0 永久重定向,同上,但是这个状态会告知客户请求的资源已经永久性的存在在新的重定向的 URL 上。
302 Moved Temporarily HTTP 1.0 临时重定向,在 HTTP1.1 中状态描述是 Found,这个和 300 一样,但是说明请求的资源临时被转移到新的 URL 上,在以后可能会再次变动或者此 URL 会正常请求客户的连接。
303 See Other HTTP 1.1 类似于 301/302,不同之处在于,如果原来的请求是 POST,Location 头指定的重定向目标文档应该通过 GET 提取(HTTP 1.1新)。
304 Not Modified HTTP 1.0 并不真的是重定向 - 它用来响应条件 GET 请求,避免下载已经存在于浏览器缓存中的数据。
305 Use Proxy HTTP 1.0 客户请求的文档应该通过 Location 头所指明的代理服务器提取(HTTP 1.1新)。
306 HTTP 1.0 已废弃,不再使用
307 Temporary Redirect HTTP 1.1 和302(Found)相同。许多浏览器会错误地响应 302 应答进行重定向,即使原来的请求是 POST,即使它实际上只能在 POST 请求的应答是 303 时 才能重定向。由于这个原因,HTTP 1.1新增了 307,以便更加清楚地区分几个状态代码:当出现 303 应答时,浏览器可以跟随重定向的 GET 和 POST 请求;如果是 307 应答,则浏览器只能跟随对 GET 请求的重定向。(HTTP 1.1新)

因为常见的重定向为 301、302、303 307,所以下面重点说说这几种重定向的处理方法。

HTTP1.0 文档中的 302(或301) 状态码,原则上是要被废弃的,它在 HTTP1.1 被细分为了 303 和 307。不过 303 和 307 应用并不广泛,现在很多公司对 302(或301) 处理实际上是 303。

总结起来就是:

协议 状态码 协议规定 实际情况
HTTP1.0 302(或301) 不建议使用 仍在大面积使用
HTTP1.1 303 + 307 旧有302(或301)被细分,并建议使用的新的状态码 应用面积较小

这些新旧协议的主要差别集中在 POST 请求的重定向处理上:

对于301、302的location中包含的重定向url,如果请求method不是GET或者HEAD,那么浏览器是禁止自动重定向的,除非得到用户的确认,因为POST、PUT等请求是非冥等的(也就是再次请求时服务器的资源可能已经发生了变化)

另外注意307这种情况,表示的是 POST 不自动重定向为 GET ,需要询问访问当前 URL 的用户,是否需要重定向,进行手动重定向。

目前浏览器大都还把302当作303处理了(注意,303是HTTP1.1才加进来的,其实从HTTP1.0进化到HTTP1.1,浏览器什么都没动),它们获取到 HTTP 响应报文头部的 Location 字段信息,并发起一个 GET 请求。

我们可以根据业务需要,对不同的状态码做处理,比如可以对303状态码做如下处理,

  • location 中包含重定向 URL 就重定向
  • 如果是 POST 请求修改为 GET 请求,并清空 body。
  • 清空 host 信息
  • 重新发送网络请求

代码示例:

   NSString *location = self.response.headerFields[@"Location"];
   if (location && location.length > 0) {
       NSURL *url = [[NSURL alloc] initWithString:location];
       NSMutableURLRequest *mRequest = [self.swizzleRequest mutableCopy];
       mRequest.URL = url;
       if ([[self.swizzleRequest.HTTPMethod lowercaseString] isEqualToString:@"post"]) {
           // POST重定向为GET
           mRequest.HTTPMethod = @"GET";
           mRequest.HTTPBody = nil;
       }
       [mRequest setValue:nil forHTTPHeaderField:@"host"];
       

Cookie 场景重定向问题

之前提到的 Cookie 方案无法解决iOS11之前系统的 302 请求的 Cookie 问题,比如,第一个请求是 http://www.a.com ,我们通过在 request header 里带上 Cookie 解决该请求的 Cookie 问题,接着页面302跳转到 http://www.b.com ,这个时候 http://www.b.com 这个请求就可能因为没有携带 cookie 而无法访问。当然,由于每一次页面跳转前都会调用回调函数:

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

可以在该回调函数里拦截302请求,copy request,在 request header 中带上 cookie 并重新 loadRequest。不过这种方法依然解决不了页面 iframe 跨域请求的 Cookie 问题,毕竟-[WKWebView loadRequest:]只适合加载 mainFrame 请求。

如果通过之前篇幅里提到的 iOS11 的新 API 进行处理,也就不会有该问题。

相关的文章:

补充说明:

概念 解释 举例
host 可以是 IP 也可以是 FQDN。 www.xxx.com 或 1.1.1.1
FQDN fully qualified domain name,由主机名和域名两部分组成 www.xxx.com
域名 域名分为全称和简称,全称就是FQDN、简称就是 FQDN 不包括主机名的部分 比如:xxx.com ,也就是www.xxx.com 这个 FQDN 中,www 是主机名,xxx.com 是域名。

文中部分提到的域名,如果没有特殊说明均指的是 FQDN。

苹果抄微信?还是手机变服务器?——iOS 14 Clips 猜想

饭店的桌角全贴着 APPLE 家的二维码,iOS14会让梦想成真??

  • 更新:2020-05-11-00:30:26。
  • 注意:查看正文前,请先查看文末的评论,以确认是否有勘误。

相信大家都看到iOS14 Clips 的新闻了,没看过的话,可以这里补一下

《苹果在iOS 14中新增了一个名为 "Clips "的API》

@Iteatime 分析这个功能有几种可能:

  • 第一种猜想:这并非是一个小程序,而是一个 Demo (“小样”),类似于视频网站里试看5分钟,满意请充值VIP的逻辑,只是把充值 VIP 改成了下载 APP。
  • 第二种猜想:这并非是一个小程序,只是 Apple 想让你的手机变服务器,完成交互任务。
  • 第三种猜想:这就是一个小程序,并且是专门用来线下支付,比如:点餐场景。

第一种猜想的原因是Apple 引入了 OTA(Over-The-Air)包的概念:

开发者需要指定应用的哪个部分应该被 iOS 下载为 OTA(Over-The-Air)包来读取该内容。

其余的猜想下面会详细描述。

而我觉得第三种都有可能,而且有可能全部都在 iOS14 上实现!

iOS14之前的逻辑

一般来说,应用开发者会在网页中提供提供特定的按钮用来跳转到APP中,

并根据系统是否能响应 scheme 地址来判断 APP 是否已经安装,
并据此来路由到 APP 特定页面或者跳 APP STORE 进行安装。
例如,用户点击“应用中打开”按钮,用户没安装应用,直接跳转到应用商店。如果已经安装过,就可以直接跳转路由到 APP 特定页面。

已安装APP,直接路由到特定页面

未安装APP,不现实跳转按钮或跳转到 APP ATORE

已安装APP,直接路由到特定页面

大部分媒体的猜测:苹果开始抄微信

前端工程师又有活儿干了

苹果的新交互将改变这种情况,我看大部分文章都是这么推测的:当你下次扫描二维码,开发者可能省去这样的逻辑,直接由前端工程师写好交互逻辑提供服务,用户是否安装 APP 不再是必要因素。

当然交互效果肯定会比单纯的 WebView 要更加 native,是不是很熟悉?? 那不就是微信小程序吗??!!

这是 APPLE 爸爸不给 native 开发者活路了,前些年封杀 HOTFIX 还历历在目,说好的 iOS 开发又有人要了呢??

别担心,@Iteatime 推测:苹果要让手机变成服务器

iOS开发又有人要了!

苹果要让手机变成服务器怎么解释?

正常的前端交互逻辑是前端与服务器进行数据、媒体文件交互(流媒体),

而 Apple "Clips"的 API 要做的是下面这种方式:

这种模式下 APP 就充当了服务器的角色。

所以 "Clips"的场景变成了下面这种:

当你第一次扫描二维码,还是会让你下载APP,
但是第二次扫描(你已经安装过APP),你不需要再打开APP,就可以在一个 APPLE 为你提供好的 页面里尽情进行 native 操作,而这些操作的数据来源与逻辑都在后台与 “宿主 APP” 进行着交互。

想象一个场景,饭店的桌角贴着 APPLE 家的二维码,打开iPhone扫一扫,不需要跳转到美团、微信就可以点餐,不需要手机号登陆,直接使用 Apple ID 就可以登陆美团,Apple Pay就可以付款。

苹果要求6/30之前所有App都要支持苹果登录可能就是在给它铺路!

你觉得哪种更有可能,还是说几种方式 Apple 都会带进iOS14?

评论区告诉我!

原文链接:#28

点击“在看”

让 iOS 开发重振雄风

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.