漫谈0x02:研发视角下的DNS

一、DNS基础

1、Domain Name 和 DNS

  • 域名(Domain Name)是由一串用点分隔的名字组成的Internet上某一台计算机或计算机组的名称,用于在数据传输时对计算机的定位标识(有时也指地理位置);eg: www.baidu.com

  • DNS(Domain Name System, 域名系统) 是Internet上作为将域名和IP地址相互映射的一个分布式数据库,能够根据域名找到对应的IP地址。

  • 根域、顶级域、二级域、子域 域名采用层次化的方式进行组织,每一个点代表一个层级。一个域名完整的格式为www.baidu.com. 最末尾的点(.)代表根域,常常省略;com即顶级域(TLD, Top-Level Domain);baidu.com即二级域。依次类推,还有三级域、四级域等等。子域是一个相对的概念,baidu.comcom的子域,www.baidu.combaidu.com的子域。

  • 根域名通常使用”.”来表示,其实际上也是由域名组成,全世界目前有13组域名根节点,由少数几个国家进行管理,而国内仅有几台根节点镜像。

2、DNS解析相关

  • 正向解析:查找域名对应IP的过程;反向解析: 查找IP对应域名的过程;解析器: 即resolver,处于DNS客户端的一套系统,用于实现正向解析或者反向解析。

  • 权威DNS:处于DNS服务端的一套系统,该系统保存了相应域名的权威信息。权威DNS即通俗上“这个域名我说了算”的服务器。

  • 递归DNS:又叫Local DNS,递归DNS可以理解为是一种功能复杂些的解析器(resolver),其核心是 缓存 和 递归查询。收到域名查询请求后,首先看本地缓存是否有记录,如果没有则一级一级的查询根、顶级域、二级域……直到获取到结果然后返回给用户。日常上网中运营商分配的DNS即是递归DNS
  • 转发DNS:是一种特殊的递归。如果本地的缓存记录中没有相应域名结果时,其将查询请求转发给另外一台DNS服务器,由另外一台DNS服务器来完成查询请求。
  • 公共DNS:属于递归DNS。其典型特征为对外一个IP,为所有用户提供公共的递归查询服务。
    • 国内主流的公共DNS的有114DNS(114.114.114.114)、阿里DNS(223.5.5.5)、百度DNS(180.76.76.76)、腾讯DNS(119.29.29.29)
    • 国外推荐的公共DNS是谷歌DNS(8.8.8.8)

3、DNS常用命令工具

  • host 命令 or nslookup命令:分析域名查询工具,测试域名系统工作是否正常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 查询域名的主机信息
host -a www.baidu.com

# Trying "www.baidu.com"
# ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 27785
# ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

# ;; QUESTION SECTION:
# ;www.baidu.com. IN ANY

# ;; ANSWER SECTION:
# www.baidu.com. 60 IN A 10.15.63.90

# 根据IP反查域名
host 114.114.114.114
# 114.114.114.114.in-addr.arpa domain name pointer public1.114dns.com.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 查询域名对应的IP
nslookup www.baidu.com
# Server: 10.8.8.18
# Address: 10.8.8.18#53

# Non-authoritative answer:
# Name: www.baidu.com
# Address: 10.15.63.90

# 根据IP反查域名
nslookup 114.114.114.114

# Server: 10.8.8.18
# Address: 10.8.8.18#53

# Non-authoritative answer:
# 114.114.114.114.in-addr.arpa name = public1.114dns.com.
# Authoritative answers can be found from:
  • whois命令:查看域名的注册情况。
1
whois www.baidu.com

二、DNS查询

1、DNS查询方式

  • DNS查询分两种:递归查询与迭代查询;
  • 递归查询:Local DNS Server接收到客户端请求后,如果它本地没有被查询的域名的IP地址,就会代替客户端,去向其他域名服务器继续发出查询,直到查询到结果后,然后层层返回给客户;
  • 迭代查询:Local DNS Server接收到客户端请求后,如果它本地没有被查询的域名的IP地址,并不直接回复查询结果,而是告诉客户机另一台DNS 服务器地址,客户机再向这台DNS 服务器提交请求,依次循环直到返回查询的结果。

2、Local DNS的设置

  • 主机(PC或移动设备)上可以通过手动配置 DNS的服务器地址 OR 利用DHCP(Dynamic Host Configuration Protocol,动态主机配置协议)动态获得DNS的服务器地址。
  • Local DNS包括网络服务提供商(ISP)分配给我们的DNS(一般为两个),或者是其他组织提供的公共DNS,比如
  • 有时,PC或者移动设备上,DNS的地使用路由器的地址,这是因为路由器会将DNS请求转发到ISP的DNS服务器上。

3、典型DNS查询过程

当我们在浏览器中输入一个网站如:www.baidu.com时,会发生如下过程:

  • 操作系统会先依次检查缓存中、本地的hosts中是够有这个域名对应的IP地址;如果有,就先调用这个IP地址,完成域名解析,否则进入下一步;
  • 如果缓存中、本地的hosts里都没有这个域名的映射,客户端会向Local DNS Server发起查询。Local DNS Server收到查询请求时,如果本地缓存中有记录,则返回IP地址给客户机,完成域名解析,否则进入下一步;
  • 如果Local DNS Server本地缓存没有记录,则一级一级的查询根、顶级域、二级域……直到获取到IP地址,然后返回给用户。
  • 最终,客户端可以通过 IP 访问到对应的服务器

4、扩展

  • DNS的查询和响应使用的是UDP协议,DNS消息通过UDP数据包发送,如果在一个限定时间内没有收到响应的UDP数据包,那么DNS客户端则会重复查询请求;如果重复一定次数仍然失败,则会尝试域内另一台DNS服务器。

  • TTL(Time To-Live)是一条域名解析记录在DNS服务器中的存留时间;可以通过设置TTL时间来控制DNS缓存的时间;但是客户端也可以域名解析记录,甚至有的客户端忽略DNS Server的TTL时间,而采用自己的固定DNS过期时间。

  • 对于大型网站而言,通过Local DNS返回的IP地址,并不是真正应用服务器的地址,而是网站的反向代理(nginx)服务器地址;反向代理(nginx)服务器会根据 负载均衡策略,将请求分配给对应的应用服务器处理;应用服务器处理后,才会返回给原来的客户端。

三、DNS轮询及应用

1、基本概念

  • DNS轮询:通过对一个域名设置多个IP解析,来扩充Server性能及实施负载均衡的技术;目前,大部分域名注册商都支持多条A(Address)记录的解析,DNS服务器将解析请求按照A记录的顺序,逐一分配到不同的IP上,这样就完成了简单的负载均衡。

  • A(Address)记录:又称IP指向,用户可以在此设置子域名并指向到自己的目标主机地址上,从而实现通过域名找到服务器。(说明:指向的目标主机地址类型只能使用IP地址)

  • 负载均衡(Load Balance)是分布式系统架构设计中必须考虑的因素之一,它通常是指,将请求/数据均匀分摊到多个操作单元上执行。

2、DNS轮询的优点

  • 基本上无成本,因为域名注册商的这种解析都是免费的;
  • 部署方便,除了网络拓扑的简单扩增,新增的Web服务器只要增加一个公网IP即可。

3、DNS轮询的缺点

  • 健康检查缺失:如果某台服务器宕机,DNS服务器是无法知晓的,仍旧会将访问分配到此服务器。修改DNS记录全部生效起码要3-4小时,甚至更久;
  • 请求/数据分配不均:如果几台Web服务器之间的配置不同,能够承受的压力也就不同,但是DNS解析分配的访问却是均匀分配的。其实DNS也是有分配算法的,可以根据当前连接较少的分配、可以设置Rate权重分配等等,只是目前绝大多数的DNS服务器都不支持;
  • 缺少会话保持:如果是需要身份验证的网站,在不修改软件构架的情况下,这点是比较致命的;因为DNS解析无法将验证用户的访问持久分配到同一服务器。虽然有一定的本地DNS缓存,但是很难保证在用户访问期间,本地DNS不过期,而重新查询服务器并指向新的服务器,那么原服务器保存的用户信息是无法被带到新服务器的,而且可能要求被重新认证身份,来回切换时间长了各台服务器都保存有用户不同的信息,对服务器资源也是一种浪费。

4、DNS轮询的应用

  • 常见互联网分布式架构,分为客户端层、反向代理nginx层、站点层、服务层、数据层。每一个下游都有多个上游调用,只需要做到,每一个上游都均匀访问每一个下游,就能实现“将请求/数据【均匀】分摊到多个操作单元上执行”。
  • 水平扩展反向代理层:DNS Server对于一个域名配置了多个解析IP,每次DNS解析请求来访问DNS-Server,会轮询返回这些IP,保证每个IP的解析概率是相同的; 而这些IP就是nginx的外网ip,以做到每台nginx的请求分配也是均衡的。

  • 当然,对于比较简单的系统,甚至可以不需要反向代理层,直接利用DNS轮询实施负载均衡,优缺点都非常明显,根据实际情况来吧。

    • 优点:少了一层网络请求,服务端架构简单
    • 缺点:
      • DNS只具备解析功能,不能保证对应外网IP的可用性,而nginx与应用Server之间有保活探测机制,当应用Server挂掉时,能够自动迁移流量;
      • 应用Server需要扩容时,通过DNS扩容生效时间长,而nginx是服务端完全自己可控的部分,应用Server扩容更实时更方便

5、总结

  • 在网络架构的发展过程中,会有利用DNS轮询来实现简单Web Server的负载均衡,但是Web Server的负载均衡主要是通过nginx、LVS和keepalived来实现的。
  • 使用DNS轮询实现 水平扩展 反向代理层,也是个不错的选择。
  • 在网站架构中,除了有DNS轮询技术,还有智能DNS技术(GSLB(Global Server load Blance、全局负载均衡)一种应用),可以实现根据用户IP来就近访问服务器。

四、HTTPDNS方案

1、简介

  • HTTPDNS:基于HTTP协议DNS服务器发送域名解析请求,替代了基于UDP的DNS协议向运营商Local DNS发起解析请求方式,有效避免Local DNS造成的域名劫持和跨网访问问题,提高域名解析效率。

  • DNS劫持是目前常见的攻击手段,通过攻击运营商的解析服务器,返回假IP地址或让请求失去响应,使得通过域名不能访问或访问的是假网址;

    1
    国内电信运营商 = 中国电信、中国移动、中国联通三大运营商 + 长城宽带等小的运营商
  • HTTPDNS的主要收益是 防止DNS的劫持,保障了服务高可用;精准调度,提升用户体验。

2、HTTPDNS设计中关键点

  • 安全策略:HTTPDNS查询是基于标准的HTTP协议,但是如果为了保证安全,可以使用HTTPS;
  • IP选取策略:HTTPDNS服务将最优IP按照顺序下发,客户端默认选取第一个,校验连通性OK就使用,如果不OK,选用下一个校验;
  • 缓存过期策略:后端动态下发域名的TTL时间,当域名的TTL超时后,如果没有新的IP将继续沿用老的IP,也可以降级成Local DNS返回的IP。

  • 批量拉取策略:在应用冷启动 或 网络切换时候,批量拉取域名和IP列表的映射数据,缓存下来;以便在后续请求中使用到,预期提升精准调度能力。

  • 降级策略:当HTTPDNS服务不可用 & 本地也没有缓存或者缓存失效时,降级成运营商的Local DNS方案;当HTTPDNS服务 & Local DNS服务双双不可用的情况下,可以使用BACKUP IP

3、总结

  • 复杂的网络环境中,在避免DNS劫持攻击,提升解析的准确性,降低网络时延等方面,HTTPDNS比传统的DNS方案优秀很多;移动互联网发展到今天,HTTPDNS方案已经是保障大前端服务高可用的必备手段;
  • 国内如阿里云、腾讯云、百度等厂商都提供此类服务,接入也比较简单(推荐使用阿里云HTTPDNS)。

参考资料


漫谈0x01:技术演进和思考

一、研发架构的演进

1、概述
  • 互联网PC时代没有明确的前后端概念,这个时期,开发人员没有前后端边界,前端页面和后端逻辑写在一个工程中,如早期的JavaWeb开发,既要写HTML、CSS、JavaScript还要写Java;
  • 随着业务的发展,开发者同时处理前后端工作,效率越来越低;而移动互联网Android和iOS的爆发,庞大流量的涌入,业务变得越来越复杂,研发团队急速扩张,这些都急速加剧了前后端的分离;
  • 我们需要一个全新的研发架构,既要保证用户在前端的体验,又要保证在后端服务的高并低延;将团队拆分成前后端团队,让两端的工程师更加专注自己的领域,独立治理,然后构架出一个全栈式、精益求精的团队。
2、从移动端、前端到大前端
  • 移动互联网时代,Android和iOS手机操作系统横空出世,他们被定义为客户端;在客户端高歌猛进的同时,前端也非常热闹,Angular、React、Vue … 等各种各样的新技术层出不穷;
  • 随着业务的急速发展,对业务快速交付的要求越来越高,Hybrid开发模式、跨端技术方案(React Native、Weex、Flutter)也应运而生;
  • 此后,客户端和Web端的边界越来越模糊,渐渐演化成大前端。对于后端、PM等来说,他们都是前端。
3、从后端到大后端
  • 得益于移动互联网的爆发式增长,海量的前端请求都聚集到后端;后端单个接口QPS从0.1k、到1k、1w甚至到10w+;百万、千万PV,百亿级流量已是常态;后端面临越来越多的挑战;
  • 后端不是简单的BBF(Backend For Frontend,服务于前端的后端),而是在接收诸多挑战:高并发、低延迟、负载均衡、分布式、消息队列、安全等。
  • 后端的软件架构也在激进演化,从单体架构到分布式应用,到微服务,甚至走向Serverless架构。

二、后端软件架构的演进

后端的软件架构分以下四种(主流的、炙手可热的依然是微服务架构):

1、单体架构
  • 初级结构,典型的三级架构,前端(Web端/客户端) + 中间业务逻辑层 + 数据库层,这是一种典型的Java Spring MVC或者Python Drango框架的应用;
  • 单体架构的应用比较容易部署、测试,但是缺点也比较明显:
    • 复杂性高
    • 技术债务
    • 部署频率低
    • 可靠性差:某个应用Bug,例如死循环、内存溢出等,可能会导致整个应用的崩溃。
    • 扩展能力受限:单体应用只能作为一个整体进行扩展,无法根据业务模块的需要进行伸缩。
    • 阻碍技术创新:单体应用往往使用统一的技术平台或方案解决所有的问题, 团队中的每个成员都必须使用相同的开发语言和框架,要想引入新框架或新技术平台会非常困难。
2、分布式应用
  • 核心是:中间业务逻辑层分布式 + 数据库分布式,是对单体架构的并发扩展,将一个大的系统划分为多个业务模块,业务模块分别部署在不同的服务器上,各个业务模块之间通过接口进行数据交互。
  • 数据库也大量采用分布式数据库,如Redis。通过 LVS/Nginx 代理应用,将用户请求均衡的负载到不同的服务器上。
  • 分布式应用大大提高了系统负载均衡能力,极大满足高并发的需求,优点如下:
    • 降低了耦合度:把模块拆分,使用接口通信,降低模块之间的耦合度。
    • 责任清晰:把项目拆分成若干个子项目,不同的团队负责不同的子项目。
    • 扩展方便:增加功能时只需要再增加一个子项目,调用其他系统的接口就可以。
    • 部署方便:可以灵活的进行分布式部署。
    • 提高代码的复用性:比如Web、Android,iOS使用同一个Service层。
  • 分布式应用的缺点:
    • 各个系统之间的交互要使用远程通信,接口开发增大工作量,但是利大于弊。
3、微服务架构【当前の主流】
  • 主要是中间业务逻辑层的解耦,将系统拆分成很多小应用(微服务),微服务可以部署在不同的服务器上,也可以部署在相同的服务器上的不同的容器中,单应用的故障和负载都不会影响到其他应用,其代表框架有Spring CloudDubbo等。
  • 微服务架构的优势:
    • 易于开发和维护:一个微服务只会关注一个特定的业务功能,所以它业务清晰、代码量较少
    • 单个微服务启动较快:单个微服务代码量较少, 所以启动会比较快。
    • 局部修改容易部署:对某个微服务进行修改,只需要重新部署这个服务即可。
    • 技术栈不受限:
  • 微服务架构的缺点:
    • 运维要求较高:更多的服务意味着更多的运维投入。在单体架构中,只需要保证一个应用的正常运行。而在微服务中,需要保证几十甚至几百个服务服务的正常运行与协作,这给运维带来了很大的挑战。
    • 分布式固有的复杂性:使用微服务构建的是分布式系统。对于一个分布式系统,系统容错、网络延迟、分布式事务等都会带来巨大的挑战。
    • 接口调整成本高:微服务之间通过接口进行通信。如果修改某一个微服务的API,可能所有使用了该接口的微服务都需要做调整。
    • 重复劳动:很多服务可能都会使用到相同的功能,而这个功能并没有达到分解为一个微服务的程度,这个时候,可能各个服务都会开发这一功能,从而导致代码重复。尽管可以使用共享库来解决这个问题(例如可以将这个功能封装成公共组件,需要该功能的微服务引用该组件),但共享库在多语言环境下就不一定行得通了。
4、Serverless架构
  • Serverless(无服务器架构)是指服务端逻辑由开发者实现,运行在无状态的计算容器中,由事件触发,完全被第三方管理,其业务层面的状态则存储在数据库或其他介质中。
  • Serverless 是云原生技术发展的高级阶段,可以使开发者更聚焦在业务逻辑,而减少对基础设施的关注。
  • Serverless分为以下两个领域:

    • BaaS(Backend as a Service)后端即服务;一个个API调用后端或已经实现好的程序逻辑,这些API可以使用Baas提供的服务(如数据存储)。
    • FaaS(Functions as a Service)函数即服务,FaaS是无服务器计算的一种形式,当前使用最广泛的是AWS的Lambada。
  • Serverless架构能够让开发者在构建应用的过程中无需关注计算资源的获取和运维,由平台来按需分配计算资源并保证应用执行的SLA(服务等级协议),按照调用次数进行计费,有效的节省应用成本。其优点如下所示:

    • 低运营成本
    • 简化设备运维
    • 提升可维护性
    • 更快的开发速度
  • Serverless架构的缺点:

    • 厂商平台绑定,对平台依赖非常高,不能随便迁移或迁移成本很高
    • 目前成功案例比较少,没有行业标准。

三、几点思考

1、业务 VS 技术
  • 业务决定技术的上限:技术是为了解决业务问题的,业务能带来真正的价值,技术才有存在的意义。如果没有双11秒杀活动、春节抢火车票这些事情,流量削峰、削峰填谷这类技术就没有存在的意义。在一个业务高歌猛进的同时,技术也在不断演化;
  • 技术是业务的护城河:没有技术或技术无法支撑业务的快速迭代、快速试错,再美好的业务终究是水中月、镜中花,一纸空谈而已;
  • 技术和业务是相互促进的,相互依存。对于技术人来说,选择一个好的业务,对技术成长非常重要;业务中成长远快于从书本中学习,甚至是碾压。
2、个人 VS 团队
  • 有一定规模的互联网公司,团队职责都会细分,绝大数人在团队中只是做着或负责很小的一部分事情(俗称拧螺丝),但是职责划分是趋势,是团队进化的必然选择;
  • 在一个团队中,做什么很重要;但是更重要的是能看到什么;团队给你提供了一个非常大的平台,因此可以接触到非常多的人或事;
  • 团队中的个体通力合作,共同完成团队目标,不仅提高了个人的影响力,也提高了团队整体的竞争力。
3、技术人的边界
  • 而今,技术方向细分,每个技术人都在自己的方向深挖,这是非常必要的,因为这样才能组成一个全栈式、精益求精的团队,但是这还远远不够;
  • 技术人应该不设置边界(不是说没有边界):技术人应该跳出自己的领域,观察其他人、其他技术团队,甚至业务团队在做什么;主动去了解这条业务线上不同角色的职责,然后完善或扩张自己的边界;
  • 从当今的技术发展趋势来看,每个团队负责”小型”服务,”小型”服务内完成闭环,对外提供服务能力。而”小型”服务内的技术选型,技术栈是灵活的,甚至可以实现在”小型”服务内 技术 + 业务闭环。
4、技术专家 VS 架构师
  • 技术专家追求的是深度,需要专注于某些领域,某一类技术,需要对这方面了解非常透彻,适合做技术专家的领域有:安全、搜索、应用程序框架、缓存、JVM、分布式、RPC框架等,或更加底层的数据库开发,Web服务器开发, 编译器开发等。
  • 架构师追求的是广度,对深度并没有极致要求, 你可能并不了解一个系统的内部细节实现,但是一定要知道这个系统的特点、能力、性能和适用范围;在工作中,由点及面, 着眼系统整体和业务的全局,培养出设计系统架构的能力:把需求转化成合适的技术方案,然后推动方案的落地。

  • 根据自己的优劣势选择合适自己的方向,然后刻意练习,不断完善这方面的能力。

参考文章


7分钟了解PNG图片

Question

  • PNG图片结构是什么样的

  • PNG规范中定义PNG是无损压缩(Lossless Compression)的图片格式,那么PNG支持有损压缩吗?

一、PNG概述

1、PNG简介
  • PNG(Portable Network Graphics,便携式网络图型)是一种无损压缩的图片格式,支持索引、灰度、RGB三种颜色方案以及Alpha通道等特性(iOS的绝大部分切图都是PNG格式)。

  • PNG图片主要有三个类型,分别为 PNG 8、 PNG 24 和 PNG 32;其中 PNG 8支持两种不同的透明形式,索引透明和Alpha透明、 PNG 24不支持透明和 PNG 3224位基础上增加了8bits透明通道。

  • PNG规范可见: Portable Network Graphics (PNG) Specification (Second Edition)
2、PNG结构
  • PNG结构如下: header,chunk,chunk,chunk….chunk; 其中,header代表png图片的头,png文件头一般都是由固定的8个字节组成89 50 4E 47 OD 0A 1A 0Achunk代表png图片的数据块。

PNG编码结构

二进制查看PNG图片

3、PNG的数据块
  • PNG的数据块由四个数据域组成:长度、数据块类型码、数据块数据和循环冗余校验;而数据块分成两种:关键数据块可选数据块,关键数据块包括:文件头数据块、调色板数据块、图像数据块和图像结束数据块
  • 对于用户可见的部分,真正和展现有关就是图像数据块中的数据块区域,因此,我们就需要注意有没有在别的数据块中引入了不必要的数据

  • IHDR数据块:一个PNG图片中只能有一个IHDR数据块,它是PNG图片中第一个数据块,共13个字节(0000 000d),具体信息如下:

域名称 字节数(bytes) 说明
width 4 图像宽度,以像素为单位
height 4 图像高度,以像素为单位
bit depth 1 表示一个通道的总位数
color type 1 颜色类型. [0]:灰度图像, 1,2,4,8或16 [2]:真彩色图像,8或16 [3]:索引彩色图像,1,2,4或8 [4]:带α通道数据的灰度图像,8或16 [6]:带α通道数据的真彩色图像,8或16
compression method 1 压缩方法(LZ77派生算法)
filter method 1 滤波器方法
Interlace method 1 隔行扫描方法. 0:非隔行扫描 1: Adam7(7遍隔行扫描方法)
  • 0000 000d是iHDR数据块的长度,为13,4948 4452是数据块的type,为IHDR,之后紧跟着是data, 0000 02bc是图片的宽度,0000 03a5是高度,08是bit depth,也就是一个通道是8位,06是color type,这里表示图片是真彩色,00是压缩方法,png中目前只有一种,也就是LZ77派生的算法,00是滤波器方法,表示不使用,00是隔行扫描方法,代表不扫描。8f 1434 a4是四个字节的CRC校验码。
  • IDAT数据块:存放的就是图像的一个个像素,一张图像中可以存在多个IDAT数据块,这里的数据会被隔行扫描方法(Interlace method)滤波器(filtering)压缩算法(compression)处理。
3、总结
  • 通过了解PNG的结果,可以知道,减少PNG图片大小可以通过去除PNG文件中不需要的信息,或 选择正确的PNG格式(如,如果图片中没有Alpha通道,那么就应当使用PNG 24,而不是使用PNG 32。类似,如果是灰度的图片,那么应当使用PNG 8)

二、PNG的压缩原理

1、无损压缩 VS 有损压缩
  • 无损压缩(Lossless Compression):数据压缩格式方法之一,指数据经过压缩后,信息不受损失,还能完全恢复到压缩前的原样。
  • 有损压缩(Lossy compression):数据压缩格式方法之一,指数据经过压缩、解压的数据会与原始数据不同但是非常接近。
  • 无损压缩和有损压缩是相对的,有损压缩将次要的信息数据舍弃,牺牲一些质量来减少数据量、提高压缩比。
  • PNG压缩过程分为两个阶段:Prediction(预解析) 和 Compression(压缩)

PNG图片压缩

2、PNG压缩之Prediction
  • 简单理解:在这个阶段,对图片做一些预处理,方便后续的压缩处理;

  • 每次会处理图片中一行的数据,首先通过Filter处理这一行当中每一个的像素点中每条通道的值,然后交由差分处理器来重新计算该通道的值。

  • 差分处理器会根据这个像素点上通道和之前或者之上像素点对应通道值之间的差异,进行差分编码,也就是说,如果原本相邻像素点之间通道的值之间很接近,那么我们就会获得很多的1,0,-1这种很小的值。(差分编码器比较的是像素点之间对应通道的值,而并不是整个像素点)

  • 整个Prediction阶段的目的,也就是选择合适的差分处理器,让最终的编码结果出现尽可能多的零值和重复值,这一结果将会影响到Compression阶段的压缩率。

3、PNG压缩之Compression
  • Prediction处理完毕之后,再将这一转换的结果输出给DeflateDeflate执行真正的压缩操作,它会通过LZ77Huffman对图像进行编码,最后将处理之后的结果保存。在Compression阶段,它最终的压缩率会受到两方面的影响:

    • Prediction 的处理结果:对于颜色相近的区域,也就是有很多零值的区域,那么压缩率将会更高,而如果颜色之间差异很大,那么压缩效果将不尽人意。

    • Deflate 每一行的匹配情况:整个处理过程是按行来处理的。而在处理每一行的数据时,Deflate把处理的符号数限制为3 ~ 258,也就是说,最大的压缩率为1032:1,当出现符号数小于3个时,那么就有可能出现无法匹配的情况,因此,对于图片宽度的改变将有可能影响最终压缩的效果。

4、总结
  • 从上述内容可以知道,减少PNG的图片大小,可以通过PNG压缩优化,压缩优化有两个方向:
    • 优化差分编码器,使得经过差分编码后的图像有尽可能多的零值和相同的值
    • 优化Deflate的算法,获得更高的压缩率

三、PNG的有损压缩方案

1、概述
  • 目前大部分PNG的压缩采用基于LZ77派生算法,使得它压缩比率更高,生成的文件体积更小,并且不损失数据。
  • 但是有些大神不能接受无损压缩的压缩率,搞了些无损压缩算法,并在工程中得到了应用。
2、TinyPNG
  • 这是一个提供PNG有损压缩的平台,地址:https://tinypng.com/

  • 从官网公开的信息来看,TinyPNG主要是使用Quantization的技术,通过合并图片中相似的颜色,通过将 24 位的 PNG 图片压缩成小得多的 8 位色值的图片,并且去掉了图片中不必要的 metadata(元数据,从 Photoshop 等工具中导出的图片都会带有此类信息),这种方式几乎能完美支持原图片的透明度。

  • 很可惜,没有公开PNG的有损压缩算法。
3、pngquant算法及其应用

mgod3T.jpg

  • ImageAlpha也采用了pngquant算法,设计师使用的PNGyu也采用了pngquant算法;甚至有猜测,TinyPNG可能综合利用了pngquant、optipng、advpng,才获得很好的压缩效果。
  • 这类无损压缩方案在压缩PNG图片,对最终的包大小优化,是有收益的(Xcode构建包的时候,会压缩PNG;如果使用ImageOptim这类无损压缩工具来压缩PNG是没有什么收益的

mgoX28.jpg

  • pngquant算法的核心:通过Vector Quantization (矢量量化)减少图片中颜色的种类
    • 如果图像中的颜色种类小于256,那么我们可以把它转换为索引PNG格式,而如果图片原本的颜色大于256种,那么可以通过矢量量化的方法来创建一个索引PNG格式。
    • 在矢量量化的过程中,会把所有的像素基于它们之间颜色的相似程度进行分组,一个组内像素的颜色会比较接近。之后根据组内的所有颜色,计算出一个中心点颜色,组内的所有颜色会被替换成为该中心点的颜色。
    • 矢量量化会通过将相近颜色替换成同一种颜色的方法,来减少图片中颜色的种类,因此有可能会使得图片失真。

参考图片压缩知识梳理(2) - 减小 PNG 大小


Cocoapods使用小记

不识庐山真面目,只缘身在此山中

一、概述

1、CocoaPods是什么?
  • CocoaPods是OS X和iOS下的一个第三类库管理工具,通过CocoaPods工具我们可以为项目添加被称为“Pods”的依赖库(这些类库必须是CocoaPods本身所支持的),并且可以轻松管理其版本。

  • iOS程序依赖管理工具还有Carthage(Carthage 是由 Swift 语言写的,只支持动态框架,只支持 iOS8+),工程中可以Carthage和CocoaPods一起存在使用,但是建议使用一种,个人推荐CocoaPods。

  • CocoaPods项目源码

2、下载和安装CocoaPods
  • CocoaPods需要Ruby环境,安装Ruby和修改ruby镜像命令如下:(Mac自带 Ruby

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #安装ruby 
    gem install ruby

    #升级ruby
    sudo gem update --system

    #检查版本
    ruby -v

    # 更新ruby镜像
    gem sources --remove https://rubygems.org/
    gem sources -a https://ruby.taobao.org/
    #检查镜像是否更新成功
    gem sources -l
  • 下载和卸载命令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #下载最新版本
    sudo gem install cocoapods (sudo gem install -n /usr/local/bin cocoapods)

    #下载指定版本
    sudo gem install cocoapods -v 1.6.1

    #卸载
    sudo gem uninstall cocoapods

    #卸载指定版本
    sudo gem uninstall cocoapods -v 1.6.1

    #检查pod版本
    pod --version
  • 还可以在工程目录下,使用Gemfile来指定使用cocoapods的版本。

    1
    gem "cocoapods", '~> 1.6.1'
3、CocoaPods应用
  • 为项目引入优秀的第三方库,如SDWebImage、AFNetworking、YYCache
  • 创建私有pods,将项目中子模块拆分、沉库(内部使用)
  • 创建公有pods,将好的功能模块分享出去

二、Podfile基础使用

1、基础
  • 创建Podfile文件

    1
    2
    #进入项目根路径中执行,生成Podfile文件(如果有Podfile文件不用执行pod init)
    pod init

    说明:Podfile文件详细描述了一个或多个工程中targets的依赖关系

  • Podfile添加第三方库依赖

    1
    2
    3
    4
    5
    6
    7
    8
    target 'QSAppDemo' do
    pod 'AFNetworking'
    pod 'YYModel', '~> 1.0.4'
    pod 'OOMDetector', '1.3'
    # Debug模式下生效
    pod 'FLEX', '~> 2.0', :configurations => ['Debug']
    pod 'WebViewJavascriptBridge', :git => 'https://github.com/marcuswestin/WebViewJavascriptBridge.git'
    end
  • 下载和安装第三方库

    1
    pod install

    说明1:下载成功后,使用CocoaPods 生成的 .xcworkspace 文件来打开工程;每次更改了 Podfile 文件,要重新执行一次pod update命令;

    说明2:发生执行pod installpod update都卡在Analyzing dependencies的情况,是因为要升级CocoaPods的spec仓库,命令后添加--verbose --no-repo-update 参数可以省略此步。

2、pod指定依赖项版本范围
  • 如果依赖项后不指定版本,默认取最新版本

    1
    pod 'OOMDetector'
  • 如果依赖项后跟上特定版本,就是使用指定版本

    1
    pod 'OOMDetector'
  • > 0.1 高于0.1版本(不包含0.1版本)的任意一个版本

  • >= 0.1 高于0.1版本(包含0.1版本)的任意一个版本

  • < 0.1 低于0.1版本(不包含0.1版本)的任意一个

  • <= 0.1低于0.1版本(包含0.1版本)的任意一个

  • ~> 0.1.2 版本 0.1.2的版本到0.2 ,不包括0.2。这个基于你指定的版本号的最后一个部分。这个例子等效于>= 0.1.2并且 <0.2.0,并且始终是你指定范围内的最新版本。

3、pod制定依赖库的分支或节点
  • 引入master分支(默认)

    1
    pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git'
  • 引入指定的分支

    1
    pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git', :branch => 'develop'
  • 引入某个节点的代码

    1
    pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git', :tag => '2.7.0'
  • 引入某个特殊的提交节点

    1
    pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :commit => '685e31a31bb1ebce3fdb5a752e392dfd8263e169'
4、关于Podfile中一些配置说明
  • Source:指定pod的来源。如果不指定source,默认是使用CocoaPods官方的source

    1
    2
    # 使用官方默认地址(默认)
    source 'https://github.com/CocoaPods/Specs.git'
  • use_frameworks!:使用此命令会在Pods工程下的Frameworks目录下生成依赖库的framework,如果不使用,会在Pods工程下的Products目录下生成.a的静态库。

    1
    2
    3
    target 'QSAppDemo' do
    use_frameworks!
    end

关于Pofile的详细语法可以参考: Podfile Syntax ReferencePodfile文件用法详解

三、Cocoapods建立私有Pod库

1、概述
  • 由于业务需要,需要开发某业务SDK,方便公司内业务接入,选择使用创建私有Pod库;

  • 本质上,创建公有Pod库或者私有Pod库原理是一样的,不一样的是:两者的版本索引查询方式不一样, 公有库的podspec由CocoaPods/Specs管理, 而内部私有使用的pod库需要公司内部建立一个仓库来管理podspec。

  • 私有spec repo的构建形式有两种, 一种是私有git服务器上面创建,一种是本机创建;一般是在公司内部搭建的git服务器上面创建私有spec repo

    1
    2
    3
    4
    #将远程的私有版本仓库添加到本地
    pod repo add qs_private_pods_specs https://github.com/buaa0300/qs_private_pods_specs.git

    #添加成功后,打开~/.cocoapods/repos可以看到新增的qs_private_pods_specs
2、创建私有Pod库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pod lib create QSBizSDK      # QSBizSDK是SDK的名字

What platform do you want to use?? [ iOS / macOS ]
> iOS

What language do you want to use?? [ Swift / ObjC ]
> ObjC

Would you like to include a demo application with your library? [ Yes / No ]
> YES

Which testing frameworks will you use? [ Specta / Kiwi / None ]
> None

Would you like to do view based testing? [ Yes / No ]
> No

What is your class prefix?
> QS

......

说明1pod lib create QSBizSDK其实是下载了一个pod模板,然后在内部通过更改.podspec文件的配置定制化自己的pod,podspec是描述pod的说明信息的。
说明2pod lib create ProjectName其实使用了默认参数,补全的话pod lib create ProjectName --template-url=https://github.com/CocoaPods/pod-template.git

说明3: 将项目托管到Git,私有库一般上传到内部的仓库中。

3、podspec编辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Pod::Spec.new do |s|
s.name = 'QSBizSDK'
s.version = '1.0.0'
s.summary = 'A short description of QSBizSDK.'
s.description = <<-DESC
Add long description of the pod here.
DESC
s.homepage = 'https://github.com/buaa0300/QSBizSDK'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = {'南华coder' => 'buaa0300@qq.com'}
s.source = { :git => 'https://github.com/buaa0300/QSBizSDK.git', :tag => s.version.to_s }

s.ios.deployment_target = '8.0'
s.default_subspec = 'QSCore'

# 各个子模块的个自的源码路径
s.subspec 'QSCore' do |qscore|
qscore.source_files = 'Classes/QSCore/**/*'
end

# A a 一定要不一样,是别名,添加好暴露的头文件
s.subspec 'A' do |a|
a.source_files = 'Classes/QSCore/A/**/*'
a.public_header_files='Classes/QSCore/A/*.h'
end

# 可以添加内部自己模块,或者外部的依赖
s.subspec 'B' do |b|
a.source_files = 'Classes/QSCore/B/**/*'
a.public_header_files='Classes/QSCore/B/*.h'
b.dependency 'Classes/QSCore/A'
b.dependency 'AFNetworking', '~> 3.0'
b.frameworks = 'SystemConfiguration', 'CFNetwork', 'PassKit'
b.libraries = 'c++', 'z'
end

s.subspec 'C' do |c|
c.source_files = 'Classes/QSCore/C/**/*'
c.dependency 'Classes/QSCore/B'
end
end

说明:对于一个功能比较多的SDK,podspec中需要做的事情比较多,如利用subspec将代码模块化,方便使用时按需导入;不同的模块可能依赖framework、静态库、内部其他模块,也需要处理好;

说明2:可以使用pod spec lint xxx.podspec 来检查podspec的配置有效性

4、podspec加入私有Sepc repo
  • 公有库使用trunk方式将.podspec文件发布到CocoaPods/Specs, 内部的pod组件库则是添加到私有的Spec repo中去, 在终端执行:
1
pod repo push qs_private_pods_specs QSBizSDK.podspec

说明:添加成功之后qs_private_pods_specs中会包含QSBizSDK库的podspec信息, 可以前往~/.cocoapods/repos下的qs_private_pods_specs文件夹中查看, 同时git服务器中的远端也更新了.

5、更多参考

四、Cocoapods重要的”新”特性

1、cocoapods1.4.0支持static framework(静态框架)
  • 1.4.0之前,只能通过use_framework!只能发布动态framework,新增的特性解决了过去动态framework不能依赖静态库的弊端,现在的静态framework可以依赖静态库,也可以依赖通过vendored_frameworks发布的第三方框架.(vendored_frameworks和vendored_library是在podspec文件内使用的属性,用法是声明包含的第三方framework和library.

  • static framework和library区别:framework是对于library、头文件和资源等内容的封装;library可以是动态或者静态的,静态库在构建时期链接,但是动态库是在运行时才进行加载。

  • 动态库不能依赖静态库(library)是因为静态库(library)不需要在运行时再次加载,如果多个动态库依赖同一个静态库,会出现多个静态库的拷贝,而这些拷贝本身只是对于内存空间的消耗

  • 在1.4.0之前,资源只能通过动态库的方式构建,所以不能依赖vendored_framework的库.而且对于vendored_framework的二进制库,无法在转换成资源pod时仍保持动态性

  • 使用静态框架的支持,用法简单,只需要在podspec文件内,声明如下即可

    1
    s.static_framework = true
  • 更多参考:cocoapods的静态库和动态库CocoaPods 动/静态库混用封装组件化

2、CocoaPods 1.7 支持generate_multiple_pod_projects
  • 引入了一个 generate_multiple_pod_projects 的选项,可以让每个依赖都作为一个单独的项目引入,大大增加了解析速度
  • 开启的方式很简单,只要在 Podfile 里加入这一行就可以了:
1
install! 'cocoapods', generate_multiple_pod_projects: true
3、CocoaPods 1.8 Beta 将CDN设置为默认值
  • 我们在安装pod时候,会执行pod setup,其本质是https://github.com/CocoaPods/Specs 上的项目克隆到/Users/用户名/.cocoapods/repos目录下,若此目录下已经有这个项目,使用pod setup命令则会将项目更新到最新的状态。

  • CDN支持最初是在1.7版本中引入的,最终在1.7.2中完成。 它旨在大大加快初始设置和依赖性分析。 使用1.8CocoaPods不再需要克隆现在巨大的主规格repo才能运行,用户几乎可以立即将他们的项目与CocoaPods集成。

  • 在1.8 Beta之前,需要拷贝现在庞大的 master specs repo 才能运行,但是之后,可以使用如下方式省略master specs repo的clone.

    • 编辑Podfile以将CDN设置为主要来源:
    1
    2
    - source 'https://github.com/CocoaPods/Specs.git'
    + source 'https://cdn.cocoapods.org/'
  • 运行以下命令将其从托管存储库列表中删除:

    1
    pod repo remove master

注意:如果您希望继续使用基于git的源,则必须确保通过源DSLPodfile中明确指定它,否则CocoaPods将自动使用CDN进行依赖性解析。

4、更多

CocoaPods 1.9 新特性

五、其他

1、pod instal和pod update的区别
  • 执行pod install时,如果Podfile.lock文件存在, 则下载Podfile.lock文件中指定的版本安装,对于不在Podfile.lock文件中的pod库,pod install命令会搜索这个pod库在Podfile文件中指定的版本来安装;
  • 当你想要更新pod库的版本时才使用pod update;它不管Podfile.lock是否存在, 都会读取Podfile文件的的框架信息去下载安装,下载好之后, 再根据下载好的框架信息, 生成Podfile.lock文件
2、第三方库禁止BITCODE
1
2
3
4
5
6
7
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_setting["ENABLE_BITCODE"] = 'NO'
end
end
end
3、清除相关命令
1
2
3
4
5
6
7
8
9
10
11
#查看本地pod缓存
pod cache list

#清除某个库缓存
pod cache clean xxxx

# 清除所有pod缓存
pod cache clean -all

#删除缓存方法
rm ~/Library/Caches/Cocoapods/Pods/Pods/Release
4、其他
1
2
3
4
5
6
7
8
# 查看当前仓库的信息
pod repo

# 更新库命令
pod repo update

# 删除 search_index.json 文件
rm ~/Library/Caches/CocoaPods/search_index.json

OOM问题小记

纸上得来终觉浅,绝知此事要躬行

一、概述

1、OOM是什么
  • OOM,即Out of Memory;是由于iOS的Jetsam机制导致的奔溃,不同于常规Crash,通过 Signal等Crash等监控方案是无法捕获到OOM事件。

  • 造成OOM的原因可能有两个:系统由于整体内存使用较高,系统基于优先级杀死优先级较低的 App;当前 App 达到了 “high water mark”,也就是达到了系统对单个 App 的内存限制,当前App被系统强杀。

2、Jetsam机制是什么
  • Jetsam机制指的是操作系统为了控制内存资源过度使用而采用的一种资源管控机制。
  • 由于iOS设备不存在交换区导致的内存受限,iOS内核不得不把一些优先级不高或者占用内存过大的App杀掉;在杀掉App后会记录一些数据信息并保存到日志。
  • Jetsam产生的这些日志可以在手机设置->隐私->分析中找到,日志是以JetsamEvent开头,日志中有内存页大小(pageSize),CPU时间(cpuTime)等字段。

二、获取App的内存使用上限

1、通过JetsamEvent 日志计算内存限制值
  • 查看设置->隐私->分析中以JetsamEvent开头的系统日志,关注两个重要的信息
1
2
//内存页大小(字节数)(16384Byte = 16KB)
"pageSize" : 16384,
1
2
3
//内存页达到上限
"rpages" : 948, //App 占用的内存页数量
"reason" : "per-process-limit", //App 占用的内存超过了系统对单个 App 的内存限制。
  • 该App内存限制上限:pageSize rpages = 16384 948 /1024/1014 = 14.8MB
  • 【有人可能会有疑问】某App内存使用上限只有区区不到15MB,不太可能吧。其实这是正常的,Jetsam机制会把优先级不高或内存使用过大的App强杀掉,这个App属于优先级不高,系统将其强杀,为优先级高的App提供更多内存资源。
  • App优先级可以这么理解:前台App > 后台App; 占用内存少 > 占用内存多
  • JetsamEvent日志属于系统级别的,是在系统目录下的。App开发者没有权限获得系统目录下内容。只能连接Xcode获取 或 手动进入手机设置->隐私->分析中找到日志并分享出来。
2、收到内存警告通知时获取当前App的内存使用值
  • iOS系统会开启优先级最高的线程 vm_pressure_monitor来监控系统的内存压力情况,并通过一个堆栈来维护所有App的进程。
  • 当监控系统内存的线程发现某App内存有压力了,就发出通知,收到通知的App执行对应的处理方法,在这里可以编写释放内存的逻辑,就可能避免App被强杀。
  • 在收到内存警告通知时,获取当前 App的内存使用值,能获得App内存使用阈值的近似值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//获取当前App的内存使用值
uint64_t qs_getAppMemoryBytes() {
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if (result != KERN_SUCCESS)
return 0;
return vmInfo.phys_footprint;
}

//注册监听内存警告通知
[[NSNotificationCenter defaultCenter]addObserver:self selector:@selector(handleReceiveMemoryWarning:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];

//收到内存警告通知通知的处理
- (void)handleReceiveMemoryWarning:(NSNotification *)notification {
//
NSLog(@"handleReceiveMemoryWarning");
NSInteger appMemoryBytes = qs_getAppMemoryBytes();
//....
}

说明:可以通过模拟器的Debug -> Simulate Memory Warning 模拟内存警告

3、通过XNU获取内存限制值

在XNU中,可以通过memorystatus_priority_entry 这个结构体,得到进程的优先级和内存限制值。结构体如下:

1
2
3
4
5
6
7
8
// 获取进程的 pid、优先级、状态、内存阈值等信息
typedef struct memorystatus_priority_entry {
pid_t pid;
int32_t priority; //线程优先级
uint64_t user_data;
int32_t limit; //进程内存限制值
uint32_t state;
} memorystatus_priority_entry_t;

说明:通过XNU获取内存限制值需要root权限,而App的权限不够,所以要获得App的内存限制需要到到越狱设备上。

4、总结

总的来说,获取App的内存使用阈值,有三种方式,三种方式的对比如下:

  • 通过JetsamEvent 日志计算内存限制值 局限大,不适合线上使用;
  • 收到内存警告通知时获取当前App的内存使用值,这个获取的是上限的近似值,虽然不很准确,但是适合线上使用;
  • 通过XNU获取内存限制值,局限更大,虽然能知道各进程之前的优先级,内存阈值,但是需要设备越狱,更不适合线上使用。

——————————————————————————————————————————————————

很多东西后续完善,现在随便写写, 尴尬

三、内存分配获取

1、概述
  • OOM的排查思路:监控App使用内存增长,在收到内存警告通知时,dump 内存信息,获取对象名称、对象个数、各对象的内存值,并在合适的时机上报到服务器。
2、内存分配函数
  • 内存分配函数malloc和calloc等默认使用的是nano_zone,no_zone 是 256B 以下小内存的分配,大于 256B 的时候会使用 scalable_zone 来分配。
  • 主要针对大内存的分配监控,所以只针对scalable_zone进行分析,可以过滤掉很多小内存分配,比如malloc 函数用的是 malloc_zone_malloc,calloc用的是malloc_zone_calloc。
  • 使用scalable_zone分配内存的函数都会用到malloc_logger函数,它可以统计并管理内存的分配情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void *malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);
void *ptr;
if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
internal_check();
}
if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
return NULL;
}
ptr = zone->malloc(zone, size);
// 在 zone 分配完内存后就开始使用 malloc_logger 进行进行记录
if (malloc_logger) {
malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
}
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
return ptr;
}

说明:利用fishhook去hook malloc_logger函数,可以掌握内存的分配情况,当出现问题时,将内存分配日志捞上来,可以跟踪到内存不合理增大的原因。

3、阅读

iOS微信内存监控

深入理解内存分配

iOS Out-Of-Memory 原理阐述及方案调研

OOMDetector

iOS爆内存问题解决方案-OOMDetector组件


APM基础小记

天之道,损有余而补不足

一、概述

1、APM是什么
  • 我们平时关注更多的是:需求是否delay,线上bug有多少?每个周期(比如2-3周) 关注下App的DAU、DNU、这些产品指标;但是团队中需要有人去关注App的技术质量指标:如Crash率、启动时间、安装包大小、核心页面的FPS、CPU使用率、内存占用、电量使用、卡顿情况等。
  • 关注App线上质量,从技术维度来判断App是否健康。不健康的App表现为启动时间慢、页面卡顿、耗电量大等,这些App最终会失去用户;
  • APM (Application Performance Manage)旨在建立APP的质量监控接入框架,方便App能快速集成,对性能监控项的异常数据进行采集和分析,输出相应问题的分析、定位与优化建议,从而帮助开发者开发出更高质量的应用。
2、APM工具
  • 微信最近开源了微信的APM工具Matrix, 提供了针对iOS、Android和macOS系统的性能监控方案。这个方案很全面,可以直接接入App,当然也可以吸收其优秀的技术细节,优化自己的APM工具。
  • 本文不是介绍如何定制一个APM工具,而是介绍在APM监控中,比较重要的几个监控维度:CPU使用率、内存使用、FPS和卡顿监控

二、CPU使用率监控

1、Task和CPU
  • 任务(Task)是一种容器(Container)对象;虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。
  • 严格地说,Mach 的任务并不是其他操作系统中所谓的进程,因为 Mach 作为一个微内核的操作系统,并没有提供“进程”的逻辑,而只是提供了最基本的实现。不过在 BSD 的模型中,这两个概念有1:1的简单映射,每一个 BSD 进程(也就是 OS X 进程)都在底层关联了一个 Mach 任务对象。
  • 而每App运行,会对应一个Mach Task,Task下可能有多条线程同时执行任务,每个线程都是利用CPU的基本单位。要计算CPU 占用率,就需要获得当前Mach Task下,所有线程占用 CPU 的情况
2、Mach Task和线程列表
  • 一个Mach Task包含它的线程列表。内核提供了task_threads API 调用获取指定 task 的线程列表,然后可以通过thread_info API调用来查询指定线程的信息,
1
2
3
4
5
6
kern_return_t task_threads
(
task_t target_task,
thread_act_array_t *act_list,
mach_msg_type_number_t *act_listCnt
);

说明task_threadstarget_task 任务中的所有线程保存在act_list数组中,act_listCnt表示线程个数:

3、单个线程信息结构
  • iOS 的线程技术与Mac OS X类似,也是基于 Mach 线程技术实现的,可以通过thread_info这个API调用来查询指定线程的信息,thread_info结构如下:
1
2
3
4
5
6
7
kern_return_t thread_info
(
thread_act_t target_act,
thread_flavor_t flavor, // 传入不同的宏定义获取不同的线程信息
thread_info_t thread_info_out, // 查询到的线程信息
mach_msg_type_number_t *thread_info_outCnt // 信息的大小
);
  • 在 Mach 层中thread_basic_info 结构体封装了单个线程的基本信息:
1
2
3
4
5
6
7
8
9
10
struct thread_basic_info {
time_value_t user_time; // 用户运行时长
time_value_t system_time; // 系统运行时长
integer_t cpu_usage; // CPU 使用率
policy_t policy; // 调度策略
integer_t run_state; // 运行状态
integer_t flags; // 各种标记
integer_t suspend_count; // 暂停线程的计数
integer_t sleep_time; // 休眠的时间
};
4、CPU 占用率计算
  • 先获取当前task中的线程总数(threadCount)和所有线程数组(threadList)
  • 遍历这个数组来获取单个线程的基本信息。线程基本信息的结构是thread_basic_info_t,这里面有CPU的使用率(cpu_usage)字段,累计所有线程的CPU使用率就能获得整个APP的CPU使用率(cpuUsage)。
  • 需要注意的是:cpuUsage是一个整数,想要获得百分比形式,需要除以TH_USAGE_SCALE
1
2
3
4
/*
* Scale factor for usage field.
*/
#define TH_USAGE_SCALE 1000
  • 可以定时,比如2s去计算一次CPU的使用率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
+ (double)getCpuUsage {

kern_return_t kr;
thread_array_t threadList; // 保存当前Mach task的线程列表
mach_msg_type_number_t threadCount; // 保存当前Mach task的线程个数
thread_info_data_t threadInfo; // 保存单个线程的信息列表
mach_msg_type_number_t threadInfoCount; // 保存当前线程的信息列表大小
thread_basic_info_t threadBasicInfo; // 线程的基本信息

// 通过“task_threads”API调用获取指定 task 的线程列表
// mach_task_self_,表示获取当前的 Mach task
kr = task_threads(mach_task_self(), &threadList, &threadCount);
if (kr != KERN_SUCCESS) {
return -1;
}
double cpuUsage = 0;
// 遍历所有线程
for (int i = 0; i < threadCount; i++) {
threadInfoCount = THREAD_INFO_MAX;
// 通过“thread_info”API调用来查询指定线程的信息
// flavor参数传的是THREAD_BASIC_INFO,使用这个类型会返回线程的基本信息,
// 定义在 thread_basic_info_t 结构体,包含了用户和系统的运行时间、运行状态和调度优先级等
kr = thread_info(threadList[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
if (kr != KERN_SUCCESS) {
return -1;
}

threadBasicInfo = (thread_basic_info_t)threadInfo;
if (!(threadBasicInfo->flags & TH_FLAGS_IDLE)) {
cpuUsage += threadBasicInfo->cpu_usage;
}
}

// 回收内存,防止内存泄漏
vm_deallocate(mach_task_self(), (vm_offset_t)threadList, threadCount * sizeof(thread_t));

return cpuUsage / (double)TH_USAGE_SCALE * 100.0;
}
4、为什么关注CPU使用率
  • CPU的使用率是对APP使用CPU情况的评估,App频繁操作,CPU使用率一般在40%-50%;
  • 假如CPU使用过高(>90%),可以认为CPU满负载,此种情况大概率发生卡顿,可以选择上报。
  • 一段时间内CPU的使用率一直超过某个阈值(80%),此种情况大概率发生卡顿,可以选择上报。

三、内存使用监控

1、内存
  • 内存是有限且系统共享的资源,一个App占用地多,系统和其他App所能用的就更少;减少内存占用能不仅仅让自己App,其他App,甚至是整个系统都表现得更好。
  • 关注App的内存使用情况十分重要
2、内存信息结构
  • Mach task 的内存使用信息存放在mach_task_basic_info结构体中 ,其中resident_size 为驻留内存大小,而phys_footprint表示实际使用的物理内存,iOS 9之后使用phys_footprint来统计App占用的内存大小(和Xcode和Instruments的值显示值接近)。
1
2
3
4
5
6
7
8
9
10
11
12
13
struct task_vm_info {
mach_vm_size_t virtual_size; // 虚拟内存大小
integer_t region_count; // 内存区域的数量
integer_t page_size;
mach_vm_size_t resident_size; // 驻留内存大小
mach_vm_size_t resident_size_peak; // 驻留内存峰值

...

/* added for rev1 */
mach_vm_size_t phys_footprint; // 实际使用的物理内存

...
3、内存信息获取
1
2
3
4
5
6
7
8
uint64_t qs_getAppMemoryBytes() {
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if (result != KERN_SUCCESS)
return 0;
return vmInfo.phys_footprint;
}
4、为什么关注内存使用
  • 内存问题影响最大是OOM,即Out of Memory,指的是 App 占用的内存达到iOS系统对单个App占用内存上限时,而被系统强杀的现象,这是一种由iOS的Jetsam机制导致的奔溃,无法通过信号捕获到。
  • 对于监控OOM没有很好的办法,目前比较可行的办法是:定时监控内存使用,当接近内存使用上限时,dump 内存信息,获取对象名称、对象个数、各对象的内存值,并在合适的时机上报到服务器
  • App中会使用很多单例,这些单例常驻内存,需要关注大单例;大图片解码会造成内存使用飙升,这个也需要关注;还有些取巧的方案,比如预创建webview对象甚至预创建ViewController对象,采用此类做法,需要关注对内存造成的压力。

四、FPS监控

  • FPSFrames Per Second ,意思是每秒帧数,也就是我们常说的“刷新率(单位为Hz)。FPS低(小于50)表示App不流畅,App需要优化,iOS手机屏幕的正常刷新频率是每秒60次,即FPS值为60。
  • CADisplayLink是和屏幕刷新频率保存一致,它是CoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。
2、FPS监控实现
  • 注册CADisplayLink 得到屏幕的同步刷新率,记录1s(useTime,可能比1s大一丢丢)时间内刷新的帧数(total),计算total/useTime得到1s时间内的帧数,即FPS值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (void)start {
//注意CADisplayLink的处理循环引用问题
self.displayLink = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(updateFPSCount:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

// 执行帧率和屏幕刷新率保持一致
- (void)updateFPSCount:(CADisplayLink *)displayLink {

if (self.lastTimeStamp == 0) {
self.lastTimeStamp = self.displayLink.timestamp;
} else {
self.total++;
// 开始渲染时间与上次渲染时间差值
NSTimeInterval useTime = self.displayLink.timestamp - self.lastTimeStamp;
//小于1s立即返回
if (useTime < 1){
return;
}
self.lastTimeStamp = self.displayLink.timestamp;
// fps 计算
NSInteger fps = self.total / useTime;
NSLog(@"self.total = %@,useTime = %@,fps = %@",@(self.total),@(useTime),@(fps));
self.total = 0;
}
}

说明:很多团队非常相信(甚至迷信)FPS值,认为FPS值(大于50)就代表不卡顿,这点我是不认可。下面我列举遇到的2个非常典型的Case。

3、错信FPS值Case1
  • 同学A在做频繁绘制需求时, 重写UIView的drawRect:方法,在模拟器上频繁调用setNeedsDisplay来触发drawRect:方法,FPS值还稳定在50以上,但是真机上去掉帧很厉害。我认为这里犯了两个错误。
  • 错误1:drawRect:是利用CPU绘制的,性能并不如GPU绘制,对于频繁绘制的绘制需求,不应该考虑使用重写drawRect:这种方式,推荐CAShapeLayer+UIBezierPath
  • 错误2:不应该关注模拟器FPS来观察是否发生卡顿,模拟器使用的是Mac的处理器,比手机的ARM性能要强,所以造成在模拟器上FPS比较理想,真机上比较差。
4、错信FPS值Case2
  • 同学B在列表滑动时候,观察iPhone 6 plus真机上FPS的值稳定在52左右,感觉不错,但是肉眼明显感觉到卡顿。
  • 是FPS错了吗?我认为没错,是我们对FPS的理解错了;因为FPS代表的是每秒帧数,这是一个平均值,假如前0.5s播放了2帧,后面0.5s播放了58帧,从结果来看,FPS的值依旧是60。但是实际上,它的确发生了卡顿。
5、为什么关注FPS
  • 虽然列举了两个错信FPS的Case,但是FPS依旧是一个很重要的指标,来关注页面的卡顿情况。
  • 和使用监控RunLoop状态来发现卡顿问题不同,FPS关注的是滑动场景下,FPS偏低的场景。
  • 而监控RunLoop状态来发现卡顿问题更加关注的是:在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题

五、卡顿监控

1、卡顿和RunLoop
  • 卡顿监控的本质是,监控主线程做了哪些事;线程的消息事件依赖RunLoop,通过监听RunLoop的状态,从而判断是否发生卡顿。
  • RunLoop在iOS中是由CFRunLoop实现的,它负责监听输入源,进行调度处理的,这里的输入源可以是输入设备、网络、周期性或者延迟时间、异步回调。RunLoop接收两种输入源:一种是来自另一个线程或者来自不同应用的异步消息;另一个事来自预定时间或重复间隔的同步事件
  • 当有事情处理,Runloop唤起线程去处理,没有事情处理,让线程进入休眠。基于此,我们可以把大量占用CPU的任务(图片加载、数据文件读写等) ,放在空闲的非主线程执行,就可以避免影响主线程滑动过程中的体验(主线程滑动时,RunLoop处在UITrackingRunLoopMode模式)
2、如何判断卡顿
  • 已知的RunLoop的7个状态
1
2
3
4
5
6
7
8
9
10
//RunLoop的状态
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 即将退出Loop
kCFRunLoopAllActivities = 0x0FFFFFFFU // loop 所有状态改变
};
  • 由于kCFRunLoopBeforeSources之后需要处理Source0,kCFRunLoopAfterWaiting之后需要处理timer、dispatch 到 main_queue 的 block和Source1,所以可以认为kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting。因为kCFRunLoopBeforeSources之后和kCFRunLoopAfterWaiting之后是事情处理的主要时间段。
  • dispatch_semaphore_t信号量机制特性:信号量到达、或者 超时会继续向下进行,否则等待;如果超时则返回的结果必定不为0,否则信号量到达结果为0。

  • 主线程卡顿发生是因为要处理大量的事情。这就意味着主线程在消耗时间在处理繁重的事件,导致信号超时了(dispatch_semaphore_signal不能及时执行),如果此时发现当前的RunLoop的状态是kCFRunLoopBeforeSources或kCFRunLoopAfterWaiting,就认为主线程长期停留在这两个状态上,此时就判定卡顿发生。

3、卡顿监控的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
//  QSMainThreadMonitor.h
@interface QSMainThreadMonitor : NSObject

+ (instancetype)sharedInstance;

- (void)beginMonitor;

- (void)stopMonitor;


@end

// QSMainThreadMonitor.m
@interface QSMainThreadMonitor()

@property (nonatomic,strong) dispatch_semaphore_t semaphore;
@property (nonatomic,assign) CFRunLoopObserverRef observer;
@property (nonatomic,assign) CFRunLoopActivity runloopActivity;
@property (nonatomic,strong) dispatch_queue_t monitorQueue;

@end

@implementation QSMainThreadMonitor

+ (instancetype)sharedInstance {
static QSMainThreadMonitor *monitor = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
monitor = [[QSMainThreadMonitor alloc]init];
});
return monitor;
}

- (instancetype)init {
self = [super init];
if (self) {
self.monitorQueue = dispatch_queue_create("com.main.thread.monitor.queue", DISPATCH_QUEUE_CONCURRENT);
}
return self;
}

- (void)beginMonitor{

if (self.observer) {
return;
}
__block int timeoutCount = 0;

//创建观察者并添加到主线程
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL,NULL};
self.observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context);
//将self.observer添加到主线程RunLoop的Common模式下观察
CFRunLoopAddObserver(CFRunLoopGetMain(), self.observer, kCFRunLoopCommonModes);

self.semaphore = dispatch_semaphore_create(0);
dispatch_async(self.monitorQueue, ^{
while (YES) {
long result = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC));
if (result != 0 && self.observer) {
//超时判断
if (self.runloopActivity == kCFRunLoopBeforeSources || self.runloopActivity == kCFRunLoopAfterWaiting) {
if (++timeoutCount < 1) {
NSLog(@"--timeoutCount--%@",@(timeoutCount));
continue;
}
//出现卡顿、进一步处理
NSLog(@"--timeoutCount 卡顿发生--");
// todo,eg:获取堆栈信息并上报
}
}else {
timeoutCount = 0;
}
}
});

}

- (void)stopMonitor{

if (!self.observer) {
return;
}

CFRunLoopRemoveObserver(CFRunLoopGetMain(), self.observer, kCFRunLoopCommonModes);
CFRelease(self.observer);
self.observer = NULL;

}

#pragma mark -Private Method

/**
* 观察者回调函数
*/
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
//每一次监测到Runloop状态变化调用
QSMainThreadMonitor *monitor = (__bridge QSMainThreadMonitor *)info;
monitor.runloopActivity = activity;
if (monitor.semaphore) {
dispatch_semaphore_signal(monitor.semaphore);
}
}

@end
4、卡顿时间阈值说明
  • 这里卡顿时间阈值是2s,连续1次超时且RunLoop的状态处于kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 状态就认为卡顿。
  • 利用的RunLoop实现的卡顿方案,主要是针对那些在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题。
  • 卡顿时间阈值(timeoutThreshold)和超时时间次数(timeoutCount)可以通服务器下发控制,用来控制上报卡顿情况的场景。

六、电量监控

1、手动查看电量
  • 我们可以通过手机的设置-电池查看过去一段时间(24小时或2天)查看Top耗电量的App;
  • 对于用户来说,还有更直接的方式,使用某App时候,手机状态栏右上角电池使用量嗖嗖往下掉或手机发热,那么基本可以判断这个App耗电太快,赶紧卸了。
  • 对于开发者来说,可以通过Xcode左边栏的Energy Impact查看电量使用,蓝色表示–合理,黄色–表示比较耗电,红色–表示仅仅轻度使用你的程序,就会很耗电。
  • 还可以使用手机设置-开发者-Logging-Energy的startRecording和stopRecording来记录一段时间(3-5minutes)某App的耗电量情况。导入Instrument来分析具体耗电情况。
2、电量监控方案1
  • 利用UIDevice 提供了获取设备电池的相关信息,包括当前电池的状态以及电量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //开启电量监控
    [UIDevice currentDevice].batteryMonitoringEnabled = YES;
    //监听电量使用情况
    [[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryLevelDidChangeNotification object:nil queue:[NSOperationQueue mainQueue]
    usingBlock:^(NSNotification *notification) {
    // Level has changed
    NSLog(@"");
    //UIDevice返回的batteryLevel的范围在0到1之间。
    NSUInteger batteryLevel = [UIDevice currentDevice].batteryLevel * 100;
    NSLog(@"[Battery Level]: %@", @(batteryLevel));
    }];

说明:使用 UIDevice 可以非常方便获取到电量,但是经测试发现,在 iOS 8.0 之前,batteryLevel 只能精确到5%,而在 iOS 8.0 之后,精确度可以达到1%

3、电量监控方案2
  • 利用iOS系统私有框架IOKit, 通过它可以获取设备电量信息,精确度达到1%。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#import "IOPSKeys.h"
#import "IOPowerSources.h"

-(double) getBatteryLevel{
// 返回电量信息
CFTypeRef blob = IOPSCopyPowerSourcesInfo();
// 返回电量句柄列表数据
CFArrayRef sources = IOPSCopyPowerSourcesList(blob);
CFDictionaryRef pSource = NULL;
const void *psValue;
// 返回数组大小
int numOfSources = CFArrayGetCount(sources);
// 计算大小出错处理
if (numOfSources == 0) {
NSLog(@"Error in CFArrayGetCount");
return -1.0f;
}

// 计算所剩电量
for (int i=0; i<numOfSources; i++) {
// 返回电源可读信息的字典
pSource = IOPSGetPowerSourceDescription(blob, CFArrayGetValueAtIndex(sources, i));
if (!pSource) {
NSLog(@"Error in IOPSGetPowerSourceDescription");
return -1.0f;
}
psValue = (CFStringRef) CFDictionaryGetValue(pSource, CFSTR(kIOPSNameKey));

int curCapacity = 0;
int maxCapacity = 0;
double percentage;

psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSCurrentCapacityKey));
CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &curCapacity);

psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSMaxCapacityKey));
CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &maxCapacity);

percentage = ((double) curCapacity / (double) maxCapacity * 100.0f);
NSLog(@"curCapacity : %d / maxCapacity: %d , percentage: %.1f ", curCapacity, maxCapacity, percentage);
return percentage;
}
return -1.
}

说明

  • 因为IOKit.framework是私有类库,使用的时候,需要通过动态引用的方式,没有具体实践,UIDevice获取的方案在iOS 8.0` 之后,精确度可以达到1%, 已经满足项目需要(我们项目最低支持iOS 9)。
4、耗电量大的操作
  • CPU使用率高的操作

    1
    2
    3
    4
    线程过多 (控制合适的线程数)
    定位 (按需使用,降低频次)
    CPU任务繁重 (使用轻量级对象,缓存计算结果,对象复用等)
    频繁网络请求(避免无效冗余的网络请求)
  • I/O操作频繁的操作

    1
    直接读写磁盘文件 (合理利用内存缓存,碎片化的数据在内存中聚合,合适时机写入磁盘)

七、End

1、总结
  • 对APP的质量指标的监控,是为了更早地发现问题;发现问题是为了更好地解决问题。所以监控不是终点,是起点。

  • 在17年时候,在简书中写了iOS实录14:浅谈iOS Crash(一)iOS实录15:浅谈iOS Crash(二)两篇文章;时隔两年之后,书写此文,是为了纪念过去大半年时候在App质量监控上花的努力。

  • 文章篇幅有限,没有介绍具体的优化办法。
2、推荐的阅读资料

iOS 性能监控方案 Wedjat(上篇)

教你开发省电的 iOS app


捕获NSLog日志小记

既往不恋,纵情向前

一、NSLog概述

1、NSLog是什么
  • NSLog是一个C函数,函数声明如下:
1
2
//Logs an error message to the Apple System Log facility.
FOUNDATION_EXPORT void NSLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2) NS_NO_TAIL_CALL;
  • 根据苹果的文档介绍,NSLog的作用是输出信息到标准的Error控制台和 苹果的日志系统(ASL,Apple System Log)里面(iOS 10之前)。
  • iOS10之后,苹果使用新的统一日志系统(Unified Logging System)来记录日志,全面取代ASL的方式,此种方式,是把日志集中存放在内存和数据库里,并提供单一、高效和高性能的接口去获取系统所有级别的消息传递。
  • 新的统一日志系统没有ASL那样的接口可以让我们取出全部日志。
2、NSLog日常使用
  • NSLog在调试阶段,日志会输出到到Xcode中,而在iOS真机上,它会输出到系统的/var/log/syslog这个文件中。

  • 在日常开发中,很多人喜欢使用NSLog来输出调试信息,但是都知道NSLog是比较消耗性能呢,NSLog输出的内容或次数多了之后,甚至会影响App的体验。

  • 于是乎,比较常见的手段是,线上不使用NSLog,DEBUG下才真正使用NSLog。
1
2
3
4
5
#if DEBUG
#define MYLOG(fmt, ...) NSLog((@"%s [Line %d] " fmt), PRETTY_FUNCTION, LINE, ##VA_ARGS);
#else
#define MYLOG(fmt,...) {}
#endif
3、常见的日记收集框架
  • 日志收集主要用了两个开源框架来实现:PLCrashReporterCocoaLumberjackPLCrashReporter主要用来崩溃日志收集,CocoaLumberjack是用来收集非崩溃日志。
  • CocoaLumberjack中实现了对NSLog日志的捕获。
4、捕获NSLog日志有三种方式
  • iOS 10以前可以通过ASL接口来获取
  • 通过fishhook库hook NSLog方法重定向NSLog函数
  • 使用dup2函数和STDERR句柄重定向NSLog函数

二、获取NSLog的日志输出(iOS 10前)

参考CocoaLumberjack中的DDASLLogCapture实现

1、流程介绍
  • 执行DDASLLogCapturestart方法,启动一个异步全局队列去捕获ASL存储的日志;
1
2
3
4
5
6
+ (void)start {
//...
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){
[self captureAslLogs];
});
}
  • 当日志被保存到ASL的数据库时候,syslogd(系统里用于接收分发日志消息的日志守护进程)会发出一条通知。因为发过来的这一条通知可能有多条日志,需要先将几条日志进行合并。
1
2
3
+ (void)captureAslLogs {
//....
}
  • 将获得到的数据转成char 字符串类型,再转成NSString类型,最后封装成DDLogMessage对象,通过[DDLog log: message:] 方法将日志记录下来。
1
2
3
+ (void)aslMessageReceived:(aslmsg)msg {
//...
}

说明:以上方法不会影响Xcode控制台的输出,无侵入。

2、注册进程间的系统通知
  • captureAslLogs中通过notify_register_dispatch来注册监听进程间的系统通知;
1
2
3
4
notify_register_dispatch(kNotifyASLDBUpdate, &notifyToken, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^(int token)
{
//...
});
  • 其中宏kNotifyASLDBUpdate表示:日志被保存在ASL数据库发出的跨进程通知;
1
2
3
4
5
6
/*
* ASL notifications
* Sent by syslogd to advise clients that new log messages have been
* added to the ASL database.
*/
#define kNotifyASLDBUpdate "com.apple.system.logger.message"
  • 将日志保存到ASL数据库时还有很多通知,比如宏kNotifyVFSLowDiskSpace表示:系统磁盘空间不足,捕获到这个通知时,可以去清理缓存空间,避免缓存写入磁盘失败的情况。
1
#define kNotifyVFSLowDiskSpace "com.apple.system.lowdiskspace"

三、NSLog重定向

1、介绍
  • 在iOS10之后,新的统一日志系统(Unified Logging System)全面取代ASL,没有ASL那样的接口可以让我们取出全部日志,所以为了兼容新的统一日志系统,你就需要对NSLog日志的输出进行重定向。
  • NSLog 进行重定向,可以采用 Hook的方式。因为 NSLog 本身就是一个 C 函数,可以使用fishhook进行重定向。
  • fishhook是Facebook提供的一个动态修改链接Mach-O文件的工具,能够hook C函数。
2、fishhook原理
  • APP运行时,Mach-O文件被dyld(动态加载器)加载进内存

  • ASLR(地址空间布局随机化)让Mach-O被加载时内存地址随机分配

  • 苹果的PIC位置与代码独立技术,让Mach-O调用系统库函数时,先在Mach-O表中的_DATA段建立一个指针指向外部库函数,dyld加载MachO时知道外部库函数的调用地址,会动态的把_DATA段的指针指向外部库函数
  • fishhook能够替换NSLog等库函数,这事是因为Mach-O的符号表里有NSLog等,可以通过符号表找到NSLog字符串。

说明:具体原理参考iOS逆向工程 - fishhook原理

3、利用fishhook hook NSLog函数

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//申明一个函数指针用于保存原NSLog的真实函数地址
static void (*orig_nslog)(NSString *format, ...);

//NSLog重定向
void redirect_nslog(NSString *format, ...) {

//可以添加自己的处理,比如输出到自己的持久化存储系统中

//继续执行原来的 NSLog
va_list va;
format = [NSString stringWithFormat:@"[hook success]%@",format];
va_start(va, format);
NSLogv(format, va);
va_end(va);
}

int main(int argc, const char * argv[]) {
@autoreleasepool {
struct rebinding nslog_rebinding = {"NSLog",redirect_nslog,(void*)&orig_nslog};
rebind_symbols((struct rebinding[1]){nslog_rebinding}, 1);
NSLog(@"%@, hello word!",@"ss");
}
return
}

//[hook success]ss, hello word!
  • 利用fishhook对方法的符号地址进行了重新板顶,从而只要是NDSLog的调用就会转向redirect_nslog方法调用。

参考使用fishhook hook NSLog 函数

四、dup2重定向

1、介绍
  • NSLog最后重定向的句柄是STDERR,NSLog输出的日志内容,最终都通过STDERR句柄来记录,而dup2函数式专门进行文件重定向的;
  • 可以使用dup2重定向STDERR句柄,将内容重定向指定的位置,如写入文件,上传服务器,显示到View上。
2、核心代码
  • 实现重定向,需要通过NSPipe创建一个管道,pipe有读端和写端,然后通过dup2将标准输入重定向到pipe的写端。再通过NSFileHandle监听pipe的读端,最后再处理读出的信息。
  • 之后通过printf或者NSLog写数据,都会写到pipe的写端,同时pipe会将这些数据直接传送到读端,最后通过NSFileHandle的监控函数取出这些数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (void)redirectSTD:(int )fd {

NSPipe * pipe = [NSPipe pipe] ;
NSFileHandle *pipeReadHandle = [pipe fileHandleForReading] ;
int pipeFileHandle = [[pipe fileHandleForWriting] fileDescriptor];
dup2(pipeFileHandle, fd) ;

[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(redirectNotificationHandle:)
name:NSFileHandleReadCompletionNotification
object:pipeReadHandle] ;
[pipeReadHandle readInBackgroundAndNotify];
}

- (void)redirectNotificationHandle:(NSNotification *)nf {
NSData *data = [[nf userInfo] objectForKey:NSFileHandleNotificationDataItem];
NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ;
//可以添加自己的处理,可以将内容显示到View,或者是存放到另一个文件中等等
//todo


[[nf object] readInBackgroundAndNotify];
}

//使用
[self redirectSTD:STDERR_FILENO];

Webview加载H5优化小记

行到水穷处,坐看云起时

一、概述

1、背景
  • 鉴于H5的优势,客户端的很多业务都由H5来实现,Webview成了App中H5业务的唯一载体。
  • WebView组件是iOS组件体系中非常重要的一个,之前的UIWebView 存在严重的性能和内存消耗问题,iOS 8之后推出WKWebView,旨在代替UIWebView;
  • WKWebView在性能、稳定性、内存占用上有很大的提升,支持更多的HTML5特性,高达60fps的滚动刷新率以及内置手势;可以通过KVO监控网络加载的进度,获取网页title;
  • 实践中,大部分App的H5业务将由WKWebview承载。
2、H5页面的体验问题

从用户角度,相比Native页面,H5页面的体验问题主要有两点:

  • 页面打开时间慢:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
  • 响应流畅度较差:由于 WebKit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。

这里讨论的是:第一点,怎样减少白屏时间。

二、Webview打开H5

通过Webview打开H5页面,请求并得到 HTML、CSS 和 JavaScript 等资源并对其进行处理从而渲染出 Web 页面。

1、加载流程
  • 初始化Webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 ->DOM 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片-> 页面完整展示

H5页面加载流程.png

  • DOM渲染之前耗时主要在两部分:初始化Webview数据请求,一般Webview首次初始化在400ms这个量级,二次加载能少一个量级。
  • 数据请求依赖网络,网络请求一般经过:DNS查询、TCP 连接、HTTP 请求和响应。数据包括HTML、JS和CSS资源,这些都是在webview在loadRequest:之后做的,这一阶段,用户所见到的都是白屏。(虽然4G已经成为主流,但是4G延迟明显高于Wifi)。
2、H5页面渲染

对H5页面的渲染,主要包括:渲染树构建、布局及绘制,具体可分为:

  • 处理 HTML 标记并构建 DOM 树。

  • 处理 CSS 标记并构建 CSSOM(CSS Object Model) 树。

  • 将 DOM 与 CSSOM 合并成一个渲染树。

  • 根据渲染树来布局,以计算每个节点的几何信息。

  • 将各个节点绘制到屏幕上。

说明:这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM。具体参考:DOM渲染机制与常见性能优化

3、总结
  • 分析Webview打开H5打开的过程,我们发现,在H5优化中,前端重任在肩;
1
2
3
4
降低请求量:合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
加快请求速度:预解析DNS,减少域名数,并行加载,CDN 分发。
缓存:HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存localStorage。
渲染:JS/CSS优化,加载顺序,服务端渲染,pipeline。
  • 但是客户端也很重要,主要优化DOM渲染之前这些事情,可以做有:减少DNS时间预初始化WebView 以及 HTML、JS、CSS等资源离线下载
  • 列举在某业务中笔者实践过的比较trick的优化方案,然后再引出笔者认为理想的方案。

二、WebView的客户端优化(trick版)

由于是接入第三方的H5页面,接入离线包方案,需要比较繁杂的商务沟通和技术挑战(业务逻辑和代码超级诡异),临时采用如下优化方案

1、预加载资源
  • 将首页面需要的JS文件CSS文件等资源放在一个URL地址(和业务url同域名);
  • 启动App后,间隔X秒去加载;加载的策略是,检查当前和上一次间隔时间,超时则加载,有效期忽略预加载请求。
2、预初始化Webview
  • 首次初始化Webview,需要初始化浏览器内核,需要的时间在400ms这个量级;二次初始化时间在几十ms这个量级;

  • 根据此特征:选择在APP 启动后X秒,预创建(初始化)一个 Webview 然后释放,这样等使用到 H5 模块,再加载 Webview时,加载时间也少了不少。

  • 结合步骤一中预加载公共资源,也需要Webview,所以选择在加载公共资源包时候,首次初始化Webview,加载资源,然后释放。

3、最终方案(迫不得已)

​ 由于第三方业务H5很多问题,和人力上不足;不得不需要客户端强行配合优化,在产品的要求下,不得不采用如下方案,方案的前提是:业务H5尽可能少修改,甚至不修改,客户端还要保证首屏加载快;

  • 预加载资源
  • 预创建Webview并加载首页H5,驻留在内存中,需要的时候,立刻显示。
4、方案的后遗症
  • 我不建议这种trick做法,因为自从开了这个口子,后续很多H5需求不走之前既定的离线包方案,在内存中预创建多个Webview (最多4个),加载H5时候不用新建Webview,从Webview池中获取;
  • 此种Webview池方案带来诸多隐患:内存压力、诡异的白屏、JS造成的内存泄露,页面的清空等等问题(填坑填到掉头发)。

三、离线包方案

1、概述
  • 离线包方案才是业务主流的H5加载优化方案,非常建议在客户端团队和前端团队推广,类似预创建Webview加载H5不应该成为主流。

  • 将每个独立的H5功能模块,相关HTML、Javascript、CSS 等页面内静态资源打包到一个压缩包内,客户端可以下载该离线包到本地,然后打开Webview,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。

  • 离线包可以提升用户体验(页面加载更快),还可以实现动态更新(在推出新版本或是紧急发布的时候,可以把修改的资源放入离线包,通过更新配置让应用自动下载更新)
2、方案描述

引用bang的离线包方案,简单描述如下

  • 后端使用构建工具把同一个业务模块相关的页面和资源打包成一个文件,同时对文件加密/签名。

  • 客户端根据配置表,在自定义时机去把离线包拉下来,做解压/解密/校验等工作。

  • 根据配置表,打开某个业务时转接到打开离线包的入口页面。

  • 拦截网络请求,对于离线包已经有的文件,直接读取离线包数据返回,否则走 HTTP 协议缓存逻辑。

  • 离线包更新时,根据版本号后台下发两个版本间的 diff 数据,客户端合并,增量更新。

说明:目前WKWebView已经能成为主流,但是WKWebView在实现离线包方案时,拦截网络请求有坑。

3、WKWebView拦截网络请求的坑
  • 虽然NSURLProtocol可以拦截监听每一个URL Loading System中发出request请求,记住是URL Loading System中那些类发出的请求,也支持AFNetwoking,UIWebView发出的request,NSURLProtocol都可以拦截和监听。
  • 因为WKWebView 在独立进程里执行网络请求。一旦注册 http(s) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。
  • 但是在 WebKit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC(进程间通信) 发送给 App Process。出于性能的原因,encode 的时候 将HTTPBody 和 HTTPBodyStream 这两个字段丢弃掉()
  • 因此,如果通过 registerSchemeForCustomProtocol 注册了 http(s) scheme, 那么由 WKWebView 发起的所有 http(s)请求都会通过 IPC 传给主进程 NSURLProtocol 处理,导致 post 请求 body 被清空
1
2
3
4
5
6
7
8
9
10
11
//苹果开源的 WebKit2 源码暴露了私有API:
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]

//通过注册 http(s) scheme 后 WKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求:
Class cls = NSClassFromString(@"WKBrowsingContextController”);
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
// 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}

说明1:名目张胆使用私有API,是过不了AppStore审核的,具体使用什么办法,想来你也懂(hun xiao)。

说明2:一旦打开ATS开关:Allow Arbitrary Loads 选项设置为NO,通过 registerSchemeForCustomProtocol 注册了 http(s) scheme,WKWebView 发起的所有 http(s) 网络请求将被阻塞(即便将Allow Arbitrary Loads in Web Content 选项设置为YES);

说明3:iOS11之后可以通过WKURLSchemeHandler去完成对WKWebView的请求拦截,不需要再调用私有API解决上述问题了。

4、WKWebView自定义资源scheme
  • 向WKWebView 注册 customScheme, 比如 dynamic://, 而不是https或http,避免对https或http请求的影响
  • 保证使用离线包功能的请求,没有post方式,遇到customScheme请求,比如dynamic://www.dynamicalbumlocalimage.com/,通过 NSURLProtocol 拦截这个请求并加载离线数据。
  • iOS 11上, WebKit 提供的WKURLSchemeHandler可实现拦截,需要注意的只允许开发者拦截自定义 Scheme 的请求,不允许拦截 “http”、“https”、“ftp”、“file” 等的请求,否则会crash。

四、其他

1、LocalWebServer
  • 离线包方案中,除了拦截请求加载资源的方式,还有种在项目中搭建local web server,用以获得本地资源。市面有比较完善的框架
1
2
3
CocoaHttpServer (支持iOS、macOS及多种网络场景)
GCDWebServer (基于iOS,不支持 https 及 webSocket)
Telegraph (Swift实现,功能较上面两类更完善)
2、WKWebView loadRequest 问题
  • 在 WKWebView 上通过 loadRequest 发起的 post 请求 body 数据会丢失:
1
2
3
4
//同样是由于进程间通信性能问题,HTTPBody字段被丢弃
[request setHTTPMethod:@"POST"];
[request setHTTPBody:[@"bodyData" dataUsingEncoding:NSUTF8StringEncoding]];
[wkwebview loadRequest: request];

解决:假如想通过-[WKWebView loadRequest:]加载 post 请求 (原始请求)request1: h5.nanhua.com/order/list,可以通过以下步骤实现:

  • 替换请求 scheme,生成新的 post 请求 request2: post://h5.nanhua.com/order/list, 同时将 request1 的 body 字段复制到 request2 的 header 中(WebKit 不会丢弃 header 字段);
  • 通过-[WKWebView loadRequest:] 加载新的 post 请求 request2;
  • 并且通过 +[WKBrowsingContextController registerSchemeForCustomProtocol:]注册 scheme: post://;
  • 注册 NSURLProtocol 拦截请求 post://h5.nanhua.com/order/list ,替换请求 scheme, 生成新的请求 request3: h5.nanhua.com/order/list,将 request2 header的body 字段复制到 request3 的 body 中,并使用 NSURLSession 加载 request3,最后将加载结果返回 WKWebView;
3、推荐资料

WKWebView的Cookie问题

往者不可谏,来者犹可追

一、Cookie和Session概述

Cookie和Session都是为了保存客户端和服务端之间的交互状态,实现机制不同,各有优缺点。

1、Cookie
  • Cookie是客户端请求服务端时,服务器会将一些信息以键值对的形式返回给客户端,保存在浏览器中,后续交互的时候可以带上这些Cookie值。用Cookie就可以方便的做一些缓存。
  • Cookie的缺点是大小和数量都有限制;Cookie是存在客户端的可能被禁用、删除、篡改,是不安全的;Cookie如果很大,每次要请求都要带上,这样就影响了传输效率。
  • Cookie的内容主要包括:名字过期时间路径路径一起构成Cookie的作用范围。若不设置过期时间,则表示这个Cookie的生命期为浏览器会话期间,关闭浏览器窗口,Cookie就消失。这种生命期为浏览器会话期的Cookie被称为会话Cookie会话Cookie一般不存储在硬盘上而是保存在内存里。
  • 若设置了过期时间,浏览器就会把Cookie保存到硬盘上,关闭后再次打开浏览器,这些Cookie仍然有效直到超过设定的过期时间。存储在硬盘上的Cookie可以在不同的浏览器进程间共享,比如两个IE窗口。而对于保存在内存里的Cookie,不同的浏览器有不同的处理方式 。
2、Session
  • Session是基于Cookie来实现的,不同的是Session本身存在于服务端,但是每次传输的时候不会将数据传输给客户端,只是把代表一个客户端的sessionid(jsessionid只是Tomcat中对sessionid的叫法)写在客户端的Cookie中,这样每次传输这个ID就可以了。

  • Session的优势就是传输数据量小,比较安全。Session有缺点,就是如果Session不做特殊的处理容易失效、过期、丢失或者Session过多导致服务器内存溢出,并且要实现一个稳定可用安全的分布式Session框架也是有一定复杂度的。在实际使用中就要结合Cookie和Session的优缺点针对不同的问题来设计解决方案。

3、理解
  • Session是一种服务器端的机制,服务器使用一种类似于散列表的结构(也可能就是使用散列表)来保存信息。
  • 当Server程序要为某个客户端的请求创建一个session时,服务器首先检查这个客户端的请求里是否已包含了一个session id,如果已包含则说明以前已经为此客户端创建过session,服务器就按照session id把这个session检索出来使用(检索不到,会新建一个);如果客户端请求不包含session id,则为此客户端创建一个session并且生成一个与此session相关联的session id,session id的值应该是一个既不会重复,又不容易被找到规律以仿造的字符串,这个session id将被在本次响应中返回给客户端保存。
  • 保存这个session id的方式可以采用Cookie,这样在交互过程中浏览器可以自动的按照规则把这个标识发送给服务器。一般这个cookie的名字都是类似于SEEESIONID。但Cookie可以被人为的禁止,则必须有其他机制以便在cookie被禁止时仍然能够把session id传递回服务器
  • 经常被使用的一种技术叫做URL重写,就是把session id直接附加在URL路径的后面。还有一种技术叫做表单隐藏字段。就是服务器会自动修改表单,添加一个隐藏字段,以便在表单提交时能够把session id传递回服务器。

二、WKWebView和Cookie

1、起因
1
WKWebView 发起的请求不会自动带上存储于 NSHTTPCookieStorage 容器中的 Cookie
  • 目前许多 H5 业务都依赖于 Cookie 作登录态校验,如果登陆是在 WebView 里做的,不会有什么问题;但是在很多场景下,在Native做登录,需要将登录信息带给WebView;但是在Native做了登录,也获取了Cookie信息,也使用 NSHTTPCookieStorage 将Cookie存到了本地;但是WKWebView在打开时候,不会自动去NSHTTPCookieStorage获取Cookie信息,这就是著名的首次 WKWebView 请求不携带 Cookie 的问题

  • WKWebView 实例其实会将 Cookie 存储于 NSHTTPCookieStorage 中,但存储时机有延迟,在iOS 8上,当页面跳转的时候,当前页面的 Cookie 会写入 NSHTTPCookieStorage 中,而在 iOS 10 上,JS 执行 document.cookie 或服务器 set-cookie 注入的 Cookie 会很快同步到 NSHTTPCookieStorage 中。

  • 其实,iOS11 可以解决首次 WKWebView 请求不携带 Cookie 的问题只要是存在 WKHTTPCookieStore 里的 cookie,WKWebView 每次请求都会携带

2、获取Cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 方法一
NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[response allHeaderFields] forURL:response.URL];
NSLog(@"response-cookies = %@",cookies);

//方法二
NSString *cookieString = [[response allHeaderFields] valueForKey:@"Set-Cookie"];
NSLog(@"cookieString = %@",cookieString);

//方法三(如果有的话)
NSArray<NSHTTPCookie *> *httpCookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
NSLog(@"httpCookies = %@",httpCookies);

//方法四
if(@available(iOS 11, *)){
//WKHTTPCookieStore的使用
WKHTTPCookieStore *cookieStore = self.wkWebView.configuration.websiteDataStore.httpCookieStore;
//获取 cookies
[cookieStore getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
[cookies enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSLog(@"cookieStore-cookies_%@:%@",@(idx),obj);
}];
}];
}
//将cookie设置到本地
for (NSHTTPCookie *cookie in cookies) {
//NSHTTPCookie cookie
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
}

decisionHandler(WKNavigationResponsePolicyAllow);
}

查看WKHTTPCookieStore中的某个cookie信息,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
version:1
name:Hm_lvt_0c0e9d9b1e7d617b3e6842e85b9fb068
value:1554970993,1554971029,1554971246,1554971319
expiresDate:'2020-04-10 08:28:38 +0000'
created:'2019-04-11 08:28:38 +0000'
sessionOnly:FALSE
domain:.jianshu.com
partition:none
sameSite:none
path:/
isSecure:FALSE
path:"/"
isSecure:FALSE
3、未过期Cookie持久化
  • 未过期的 Cookie被持久化存储在 NSLibraryDirectory 目录下的 Cookies/文件夹。
  • Cookie 持久化文件地址在 iOS 9+ 上在NSLibraryDirectory/Cookies,但是在 iOS 8 上 cookie 被保存在两部分,一部分如上所述,还有一部分保存在 App 无法获取的地方,/Users/Mac/Library/Developer/CoreSimulator/Devices/D2F74420-D59B-4A15-A50B-774D3D01FADE/data/Library/Cookies,大概就是后者的 Cookie 是 iOS 的 Safari 使用 。

  • 在 Cookies 目录下两个文件比较重要;

    1
    2
    Cookies.binarycookies
    <appid>.binarycookies

    两者的区别是 .binarycookies 是 NSHTTPCookieStorage 文件对象;.binarycookies 对应 WKWebview 的实例化对象。

三、WKWebView的Cookie注入

1、Javascript注入Cookie

在初始化 WKWebView 的时候,通过 WKUserScript 设置,使用Javascript 注入 Cookie

1
2
3
4
5
6
7
8
//js注入
WKUserContentController* userContentController = [[WKUserContentController alloc]init];
WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource: @"document.cookie ='CookieKey=CookieValue';"injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
[userContentController addUserScript:cookieScript];

WKWebViewConfiguration* webViewConfig = [[WKWebViewConfiguration alloc]init];
webViewConfig.userContentController = userContentController;
WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:webViewConfig];
  • 通过 document.cookie 设置 Cookie (JS注入)解决后续页面(同域)Ajax、iframe 请求的 Cookie 问题;但是会遇到跨域丢失的问题,
  • 无法解决302请求的Cookie问题,假设第一个请求是 www.a.com,我们通过在 request header 里带上 Cookie 解决该请求的 Cookie 问题,接着页面302跳转到 www.b.com,这个时候 www.b.com 这个请求就可能因为没有携带 cookie 而无法访问。
  • 每一次页面跳转前都会调用回调函数decidePolicyForNavigationAction, 在这里拦截302请求,copy request,在 request header 中带上 cookie 并重新 loadRequest。
1
2
3
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler{
//
}
  • 但是这种方法依然解决不了页面 iframe 跨域请求的 Cookie 问题,毕竟-[WKWebView loadRequest:]只适合加载 mainFrame 请求。
1
2
3
4
5
//request携带
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
//[request setHTTPShouldHandleCookies:YES];
[request setValue:[NSString stringWithFormat:@"%@=%@",@"CookieKey", @"CookieValue"] forHTTPHeaderField:@"Cookie"];
[webView loadRequest:request];

说明:WKWebView loadRequest 前,在 request header 中设置 Cookie,可以解决(首个)请求 Cookie 带不上的问题;

3、WKHTTPCookieStore (iOS 11 later)
  • 利用iOS11 API WKHTTPCookieStore 解决 WKWebView 首次请求不携带 Cookie 的问题;这是因为:WKWebView每次请求都会携带 WKHTTPCookieStore 里的 Cookie。(WKWebView 使用 NSURLProtocol 拦截请求无法获取 Cookie 信息)
  • 在执行 [WKWebView loadRequest:] 前将 NSHTTPCookieStorage中的Cookie信息复制到 WKHTTPCookieStore 中,以此来达到 WKWebView中注入Cookie 的目的。示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
if(@available(iOS 11, *)){
//发送请求前插入cookie
NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
WKHTTPCookieStore *cookieStore = self.wkWebView.configuration.websiteDataStore.httpCookieStore;
for (NSHTTPCookie *cookie in cookies) {
[cookieStore setCookie:cookie completionHandler:^{
//
}];
}
[self.wkWebView loadRequest:request];
}
4、多WKWebView实例共享Cookie
  • Session 级别的 cookie 是保存在 WKProcessPool 里的,每个 WKWebview 都可以关联一个 WKProcessPool 的实例,如果需要在整个 App 生命周期里访问 h5 保留 h5 里的登录状态的,可以将使用 WKProcessPool 的单例来共享登录状态。

  • 让所有 WKWebView 共享同一个 WKProcessPool 实例,可以实现多个 WKWebView 之间共享 Cookie(session Cookie and persistent Cookie) 数据。不过 WKWebView WKProcessPool 实例在 App 杀进程重启后会被重置,导致 WKProcessPool 中的 Cookiesession Cookie 数据丢失,目前也无法实现 WKProcessPool 实例本地化保存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//WKProcessPool+SharedProcessPool.h
@interface WKProcessPool (SharedProcessPool)

+ (WKProcessPool*)sharedProcessPool;

@end

//WKProcessPool+SharedProcessPool.m
@implementation WKProcessPool (SharedProcessPool)

+ (WKProcessPool*)sharedProcessPool {
static WKProcessPool* shared;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
shared = [[WKProcessPool alloc] init];
});
return shared;
}
@end

//use
config.processPool = [WKProcessPool sharedProcessPool];
self.wkWebView = [[WKWebView alloc]initWithFrame:self.view.bounds configuration:config];
[self.view addSubview:self.wkWebView];
5、其他
  • H5地址是非Https,遇到奇怪的Cookie丢失问题。原因未知。

四、WKWebView中Cookie的清除

1、按内容删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (@available(iOS 9, *)){
// 以www.baidu.com为例,是否包含baidu.com
NSString *displayName = @"baidu.com";
WKWebsiteDataStore *dataStore = [WKWebsiteDataStore defaultDataStore];
[dataStore fetchDataRecordsOfTypes:[WKWebsiteDataStore allWebsiteDataTypes] completionHandler:^(NSArray<WKWebsiteDataRecord *> * __nonnull records) {
for (WKWebsiteDataRecord *record in records){
if ([displayName containsString:record.displayName]){
[[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:record.dataTypes forDataRecords:@[record] completionHandler:^{
NSLog(@"Cookies for %@ deleted successfully",record.displayName);
}];
}
}
}];
}
2、按时间删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)removeWebViewDataCache:(NSDate *)sinceDate {

if (@available(iOS 11.0, *)) {
// iOS 9 以后终于可以使用 WKWebsiteDataStore 来清理缓存
NSSet *websiteDataTypes = [WKWebsiteDataStore allWebsiteDataTypes];
[[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:websiteDataTypes modifiedSince:sinceDate completionHandler:^{
NSLog(@"clear webView cache");
}];
} else {
// iOS 8 可以通过清理 Library 目录下的 Cookies 目录来清除缓存
NSString *libraryPath = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES).firstObject;
NSString *cookiesFolderPath = [libraryPath stringByAppendingString:@"/Cookies"];
[[NSFileManager defaultManager] removeItemAtPath:cookiesFolderPath error:nil];
}
}

五、IP 直连方案对Cookie的影响

1、 存在的问题
  • 采用 IP 直连方案后,服务端返回的 Cookie 里的 Domain 字段也会使用 IP 。如果 IP 是动态的,就有可能导致一些问题:由于许多 H5 业务都依赖于 Cookie 作登录态校验,而 WKWebView 上请求不会自动携带 Cookie。
2、解决问题办法

六、推荐参考


计算广告相关术语

一、广告分类

1、品牌广告

主要是为了品牌宣传形象用,主要是为了争取曝光度,提高品牌正面的形象,重在长远。

2、效果广告
  • 按效果付费的广告方式,通过广告竞价平台,广告主通过上传广告视频与相关的信息,设定价格,平台动态地以竞价的方式推荐广告;
  • 通过广告马上带来购买或其他转化行为,重在眼前

二、重要指标

1、ROI

Return On Investment,投入产出比。

2、CTR

Click Through Rate,点击通过率,广告点击次量占广告展现量的比例

3、CVR

Conversion Rate,转化率,转化次数占点击次数的比例,表示用户点击广告到成为一个有效激活或者注册甚至付费用户的转化率

4、CPM

Cost Per Mile,一种计费方式, 广告主为千次展示出价,参与竞价,按展现结算

5、CPC

Cost Per Click,一种计费方式,广告主为单次点击出价,参与竞价,按点击结算

6、CPT

Cost Per Time,一种计费方式,估计每天的总量,为流量单价定价,然后核算 CPT 价格报价,按日结算

7、CPA

Cost Per Action,一种计费方式,广告主为转化出价,参与竞价,按转化结算

8、GD

Guaranteed Delivery,保量广告,广告主与平台在投放广告时已经确认一定量的广告

9、RPM

Revenue Per Mille,千次展示收益

三、其他术语

1、RTB

Real Time Bidding,实时竞价

2、ADX

Ad Exchange,广告交易平台

3、DSP

Demand Side Platform,需求方平台

https://blog.csdn.net/avaloon/article/details/78185018