卢克 2022-11-19T16:22:00+08:00 kejinlu@gmail.com 能率燃气热水器智能化改造 2022-10-30T00:00:00+08:00 卢克 http://kejinlu.com/2022/10/noritz-controller 一般燃气热水器是很少需要操作的,设置好温度和水量之后基本长年都不需要调整设置,所以按理来说其智能化联网的需求并不是很迫切,不过能率的这些燃气热水器有一个问题就是每次断电之后再次通电,默认是关闭的状态,都需要手动按一下开关,非常麻烦。 所以需要一个断电恢复之后能够自动开机,以及能够实时监控燃气热水器的燃烧状态。这样也可以对燃气热水器的使用情况做一些分析或者异常检测。 我家用的是能率的室外机GQ-2040W,燃气热水器相比于传统电热水器或者空气能热水器这种储水式的还是方便不少,热水不限量,能够随时用随时加热。

室外机通过两根线连接线控,有一些燃气热水器可以支持同时连接多个线控,我的这台经过测试只能连接一个线控,多个线控连接之后,时间长了线控会出现异常,影响燃气热水器的使用。

能率官方在国外推出了Wi-Fi版的控制器,NWC Adapter,不过国内没有销售,也不知道和国内销售的机型能不能适配,而且动辄四五百元人民币的价格也是相当的惊人,另外需要使用官方自己的App进行控制,无法接入Home Assistant系统。 所以我就寻思能不能对原装线控做一些DIY改造,读取相关的状态信息或者写入相关控制信号来达到目的。 虽然对数字电路一窍不通,但是心想多多少少有编程基础,数字电路相关的东西,不管是软件还是硬件,到了最底层都是0101,低电平高电平的序列,很多思想都是相通的。所以这次趁着国庆假期就着手对线控电路板做了一通研究,当然一开始并没有抱太大的希望。

一.前期准备

数字电路逆向必须准备好万用电表,逻辑分析仪,杜邦线等必备的工具,这些工具不需要高端的,所有东西成本50元以内。 然后需要了解一些基本的电路知识,至少得知道电压,电阻(电阻,排阻),发光二极管,三极管这些电子元器件的基本性质。 我们要知道在数字电路中常见的电压有5V,3.3V; 电阻串联分压;发光二极管要注意正负极方向;三极管这个稍微复杂点,我们重点讲一下,三极管有NPN和PNP两种,不管哪种,都有三极:基极b,集电集c,发射极e。三极管有放大电流的作用(工作在放大区),也可以用作控制开关(一般工作在饱和区)。

NPN型三极管

NPN三极管中,当 Vb 比 Ve大0.7V左右的时候,b到e之间产生电流,然后c到e之间的电路也就通了,反之,电路就处于断开状态。电流为两进一出,bc都流向e。

PNP型三极管

PNP三极管中,当 Vb 比 Ve小0.7V左右的时候,e到b之间就产生电流,然后e到c的电路也就通了,反之,电路就处于断开状态。电流为一进两出,e流向b和c。 总之我们可以通过配置基极和发射极的电压差,来控制集电极和发射极之间电路的通断。

二.线控逆向分析

2.1 几种方案

 对于燃气热水器控制器这种靠电力线来载波通讯的DIY改造的话一般有如下这些改造方案:

  1. PLC(Power Line Communication,电力线通信不是可编程逻辑控制器)方案,这种方案也就是你得完全造出一个自己的线控,接上两根主机出来的电源线就可以控制了。这个方案是最难的,你需要对线载的模拟信号做彻底的逆向解析,工作量极大,近乎不可行。
  2. MCU数字信号拦截方案,也就是在PLC芯片(这里是T6B70BFG)和MCU芯片之间进行桥接,截取数字信号获得状态信息,以及模拟发送控制信号给PLC芯片。
  3. LED,按钮末端方案,这个就是分析LED电路的状态,获取当前状态信息,以及直接控制按钮的线路的通断模拟点击,这个方案简单,但是完整解析状态需要比较多的飞线。
  4. 纯物理的方案,使用智能机械按压设备来模拟人手点击按钮,以及通过摄像头或者感光器件来物理读取控制的状态。从效率上来讲,只有实在没有办法的情况下才会使用此种方案。

我比较偏向于第二种或者第三种方案。 不管怎么样,还是先对板子上的电路以及信号做一些必要的分析。

2.2 线控PCB板初识

首先在闲鱼上30块钱买了一个一样的二手线控,拆出板子进行分析研究。 拆看PCB板,我们可以发现其质量还是非常不错的,标识清晰,焊接做工都很好。 我们先看PCB正面,通过看各个元器件上的标识,查阅资料以及万用表进行检测,标记出下面的一些主要元器件 5v稳压器的作用主要是将热水器主机14V的输入电压转成5V的电压供各个芯片使用,T6B70BFG芯片主要通过两根电力线和主机通讯,MCU则接受T6B70BFG的数据更新LED显示以及按按钮之后发送指令给T6B70BFG。

PCB反面可以看到很多圆形的金属点,这种应该是用于PCB自动化测试的点,所以PCB上的主要节点都可以找到相应的测试点,这些测试点也给我们的线路DIY焊接带来了方便。以下图里标识出了几个重要的测试点。

2.3 T6B70BFG芯片

东芝的T6B70BFG,网上有详细的规格说明书,这款芯片主要用于热水器主机和控制器之间的接口电路,通过datasheet可以知道这个芯片大概的工作流程。 datasheet.pdf 我们可以将关注点放在数字信号的 /DOUT端口,以及/SCTL控制信号端口,从“控制器”的角度来说,/DOUT为数字信号的输入,/SCTL为控制信号的输出。

通过电路板可以看到T6B70BFG这两个端口都是通过一个10K的电阻连到了一颗名为SQE 702 K4C0的芯片(应该是一个定制芯片,查不到相关信息),然后PCB板子通过万用表分析这两个端口的连接。 将相关的电路简化了之后就是如下图的样子:

2.4 MCU数据协议分析

我们先了解下线控的基本功能, 首先就是开关功能,燃气热水器每次断电后再通电,默认是关闭状态的,需要按下控制器的开关开启;然后还可以设置水温,水温的值有37,38,39,40,41,42,43,44,45,46,47,48,60,75。按下水量按钮,可以设置水量,水量有4,6,8,10,12,14,16,18,20,22,24,26,30,35,40,99。 这里可以看到温度60,75度是非常高的温度了,最好能限制,不能将其纳入远程调节的范围,避免安全问题。

逻辑分析仪如下接法,然后通过Logic2软件进行抓包数据,然后进行线控的开机关机操作。

通过Logic2可以看到,没次按下按钮操作时候,/STL 端 Channel0会收到一串数据信号序列,然后紧接着/DOUT会输出一段和/STL 端接收到的一样的数字序列,然后大概27ms之后 又会收到一串数字信号序列。 /DOUT端收到一个和发出去的命令序列一样的序列估计是因为此T6B70BFG是单线接口通信,所以模拟信号发出的时候,T6B70BFG自身也能收到这个信号,所以会在/DOUT端进行重放,内部MCU处理的时候应该是忽略了此信号,而只处理后继收到的那个信号序列。

这边对各种状态下的数字信号做了抓取分析,虽然有一些状态值可以看出规律来,但是像温度这种数值 没有分析出规律,真想做的话只能一一对应的进行枚举分析了,如果真要通过MCU协议的方式来实现,那么只能一一对应的数据来实现了,代码会非常繁复。 或许后面有机会再继续研究,最终破解出其数据协议。 所以我们继续看末端交互的电路,主要是LED显示和按钮控制。

2.5 LED电路分析

我们再来看看线控板的LED实现的细节,如果每一个发光二极管都通过一个电平信号来控制,那么一共17个发光二极管就一共需要18个引脚(一根共用引脚,17根独立的控制引脚),非常浪费。 所以一般实现的时候都是将发光二极管分组,每一组中的LED进行编号,同一分组的所有led公用引脚接分组控制端,不同组的相同编号的LED共用控制引脚。分时轮流显示时,也就是一个微小的时间片段中只有某一组LED能够显示,然后控制引脚此时只控制当前分组的各个led的状态,由于时间片段非常短,循环交替显示各个分组的LED,对于人眼来说,各个分组的LED都得到了显示。 其实现代LED显示器本质上也是这么个原理,所以用手机拍视频或者拍照的时候有时候能看出闪烁。 通过万用电表的分析,这边对LED显示电路做了近乎1比1的还原,具体的电路图可见 https://oshwhub.com/kejinlu/noritz-controller,通过嘉立创EDA可以进行仿真模拟,修改各个控制引脚的电平来控制验证LED的显示。

通过LED电路的分析,我们可以知道 这颗SQE 702 K4C0的芯片其作用就是接受燃气热水状态的数字信号,然后通过LED显示出来;以及接受按钮的操作信号,输出控制数字命令。

2.6 开关电路

按钮的实现比LED电路简单多了,基本都是给某个MCU芯片上的引脚通上低电平然后断开就实现了一次触发。 这里以开关机按钮为例,电路示意图如下:

开关按钮按下松开,也就是开关闭合后断开就完成一次开关机指令的触发。

三.末端数字信号方案实现

方案目标:简单,无损 简单不仅仅是电路改造简单,而且固件代码要简单,稳定且很容易理解。 无损就是不切断修改原有燃气热水器线控的电路通路,不改变原有数字信号序列,不影响原有线控的所有功能和稳定性。

3.1 开关机及燃烧状态

未开机

开机状态

燃烧状态

我们的目标是能够判断当前燃气热水的开机状态,燃烧状态,延迟控制在1s内,通过上面的逻辑信号序列我们可以将问题简化,就是直接通过分析开机/燃烧LED信号这一个信号的状态就可以判断目前燃气热水器的状态。分组信号任何时候就是对LED分组进行轮询。

3.2 电路设计及实现

https://oshwhub.com/kejinlu/noritz-controller

这里我使用的是ESP32-DevKitC 如果你有别的基于ESP32的或者ESP8266的也应该都可以。 关于此电路设计有几个注意点:

  • 约定
    • 我们将智能控制器数据读入端叫做 RX(Receive)
    • 信号输出端叫做TX(Transport),这里TX其实并是不普通意义上的信息输出,只是控制和GND的通断
  • ESP32-DevKitC 开发板
    • 必须和线控共地,也就是线控板的GND需要接到ESP32的GND端。
    • 供电,开发版使用USB单独供电,经过测试如果从线控板的5v供电的话会影响线控的正常运行。
    • GPIO34,35引脚是input only的,所以 输出TX使用了下面的32引脚。
  • 用作TX开关控制的三极管
    • 基极输入端注意设置下拉电阻R3,这里的下拉电阻既可以保证当GPIO32断开或者ESP32断电时候能够有确定的低电平,也可以在GPIO 32 输出高电平的时候进行分压,控制输入电压。
    • 这里的电阻大小都是基于能够让三极管工作在饱和区;且电流足够小。这些都需要通过计算而得,电阻大小并不是唯一,只要满足条件即可。
  • 开关、燃烧LED状态读取端RX
    • 看之前的LED的仿真电路我们可以知道,RX端连接点其实也是和开关、燃烧LED直接相连的,所以我们的智能控制器其实是和线控MCU是一个并联的关系。
    • 当RX是高电平的时候,线控MCU的高电平是5V,所以为了安全做了一个电阻降压,可以选择两个R1,R2比值接近2:1的两个电阻。这样5V降压之后就接近3.3V了。
    • 当RX是高电平的时候,相关LED都应该是不亮的,不过这边接入了我们自己的电路之后有一个问题,如果R1,R2过小,并联之后就会有电流从LED中流过,导致 LED异常亮起。所以我们需要R1,R2的阻值比较大,使得电流极小,不足以使LED点亮。

然后我们就按照设计好的电路图,在线控PCB相应的测试点上引出信号线。

3.3 软件编码

https://github.com/kejinlu/noritz-controller 详细代码见github

3.4 Homeassistant接入

这一步算是最简单的了,在configuration.yaml中进行配置即可,然后再将其加入卡片

mqtt:
  switch:
    - name: "Noritz Power"
      unique_id: "switch.noritz_power"
      state_topic: "noritz/state"
      command_topic: "noritz/cmd"
      payload_on: '{"power":"ON"}'
      payload_off: '{"power":"OFF"}'
      state_on: "ON"
      state_off: "OFF"
      retain: false
      value_template: >
        {% if value_json.power is defined %}
          {{value_json.power}}
        {% endif %}

  binary_sensor:
    - name: "Noritz Heating"
      unique_id: "binary_sensor.noritz_heating"
      state_topic: "noritz/state"
      payload_on: "ON"
      payload_off: "OFF"
      qos: 1
      value_template: >
        {% if value_json.heating is defined %}
          {{value_json.heating}}
        {% endif %}

最后使用的实际情况请看下面的视频: 点击查看【bilibili】

]]>
家庭网络分享 2022-10-29T00:00:00+08:00 卢克 http://kejinlu.com/2022/10/home-network 选型

AP还是Mesh?

对于小户型选一个穿墙能力强的路由器放在房子中央位置基本就能全屋覆盖。对于大户型一般就AC+AP 和Mesh两种方案,AC+AP需要提前布好网线连接AP,Mesh的话则相对自由一些,可以通过无线回程进行Mesh组网,但是稳定性上肯定不如AC+AP的方案(有线回程的Mesh估计好点),而且一般情况下AP的带机量也是远远大于Mesh的。

千兆还是万兆?

目前来说大部分内网使用场景千兆是完全可以满足需求的比如4K视频播放,VR(比如Quest2无线串流),目前家庭万兆的使用场景一般是万兆NAS在线视频剪辑,需要频繁内网设备之间高速拷贝数字内容,多人同时需要超高速内网访问。 外网宽带目前一般最高也就是千兆,除非你拉专线价格非常高或者多个账号叠加拨号,一般都很少用到。 另外现在好一点的万兆网络设备都价格不菲。 虽然现在不上万兆,但是我们也得为日后的万兆升级做好准备。

布线

如果房子是自己装修,那么就可以完全按照自己的想法来,如果是精装修好的房子那么就局限比较多了,顶多就是换换线。 在装修之前我们先要想好弱电箱的位置,然后所有的网线都汇聚在此,如果空间足够,专门整个弱电间也是ok的。机柜或者设备放在柜子里面的,需要提前考虑好散热风道之类的,可以打散热孔加散热风扇。

我这边是设计了储藏间当弱电间,弱电间放机柜,然后由于从地下室机柜端拉网线到二层三层网线会非常长,所以二层设置一个弱电箱 作为一个交换机中继节点,二楼三楼的网络端口都汇聚到这个弱电箱。

网线六类足够了,50米的六类线万兆的速度是绝对没有问题的,另外尽量不要用屏蔽线,除非你能确保屏蔽线都能正确的接地。主干节点之间如有必要布网线的同时也可以布上光纤,万兆网络时,光口光纤无论能耗发热都优于电口网线。 其他要注意的点就是 汇聚到机柜端的网线的收口的美观,你可以使用空白面板, 如果网线太多,86面板盒塞不下也可以不用线盒,直接墙壁上安装防水盒,防水盒开孔来引出网线。

剩下就是所有网线提前做好规划,进行布线

  • 规划好网口面板的位置,比如电视机,电脑,别的有线网络设备等,每个房间都需要预留网口
  • 提前规划好AP的位置,吸顶,壁挂或者面板AP,所有的AP都通过POE进行供电
  • 提前规划好户外POE摄像头位置
  • 特定设备布上网线到弱电箱或者机柜(因为网线八根铜线有时候可以用作信号线,比如用于传输智能电rs485信号线 或者中央空调到空调控制器的信号线)

网络设备

最终选择的是AP模式,AP企业产品比较多,近些年也有不少家用的AP产品,常见的AP品牌也有不少,TPLink,华三,锐捷,aruba,unifi(UBNT),综合工业设计,系统软件等各方面因素,选择了Unifi全家桶。U家的产品特点主要是苹果风格的工业设计,复杂功能抽象封装,一些高级的功能只需要简单的配置即可。 Unifi的AP应该都是胖瘦一体的AP,支持软AC,也支持脱离AC独立运行。目前软AC支持的部署方式也很多,有pc上的软件客户端,或者docker进行部署,ac软件设置好之后,即使脱离了AC,AP也能正常工作,这两年官方都在推自带AC软件的UnifiOS设备,比如UDM,UDM Pro,UDM SE等设备,这些设备集成了路由器,交换机,UnifiOS(包含了AC管理)。

目前的拓扑结构如下:

地下室机柜

路由器和主交换机

其他设备

机柜主要核心设备就是路由器和交换机 路由器 之前一直用的USG3P加上docker部署的unifi controller(unifi network)来对unifi网络进行控制,为了事情变得简单以及更好的和机柜搭配,所以后来升级了UDM SE,自带UnifiOS(包含了软AC,Unifi Network套件),这样就可以摆脱dcoker部署的Unifi Network。 主交换机 一般情况下我们只需要2层交换机就够了,如果存在需要POE驱动的设备比如POE摄像头,AP,那么就需要带POE的交换机了,POE协议发展到今天有大概分三类,802.3af、802.3at、802.3bt,一般交换机用到的进阶特性无非就是链路聚合和VLAN, 机柜中还有的设备包含散热风扇UPS+NAS软路由监控录像机以及其他的一些智能家具的网关设备。 因为NAS中的机械硬盘对突然掉电比较敏感,容器损坏硬盘影响硬盘寿命,所以一般情况下NAS最好都配一个UPS,这边用的是APC的BK650M2-CH,可以和NAS搭配工作,当断电后,NAS会接收到UPS的通知,然后进行关机保护,NAS和主交换机通过两根网线进行链路聚合,提高带宽。另外NAS下面可以放减震垫,毕竟4个硬盘同时工作多多少少有些震动的😂。 软路由则安装了PVE,然后PVE中安装各种需要的虚拟机,比如用于部署各种Docker的RancherOS系统,作为旁路由角色的OpenWRT,用于管理智能家居的Home Assistant OS。

二楼中继弱电箱

二楼弱电箱主要设备就一个POE交换机和散热风扇,主要负责链接主交换机以及二楼和三楼的所有网络设备,由于目前没有使用万兆设备,这边和主交换机使用两根网线进行链路聚合,所以带宽可以达到2GbE,也就是如果两个AP同时访问NAS的话,整体带宽会突破1GbE。

AP

AP算是日常使用用比较核心的设备了,毕竟现在很多设备都是WiFi连接的。Unifi Network可以很方便的对所有AP进行集中管理,一般情况下我们可以根据使用场景来创建多个WiFi Network,不同WiFi网络可以绑定不同的Network(不同的Network可以对应不同的VLAN),比如我们可以单独设置一个WiFi 出来进行科学上网,连上这个无线网就可以科学上网了,而且这个网络所有的设置更改都不影响默认的正常的WiFi网络。 以下是各个位置的AP布置: 地下室 0F(UAP-AC-IW) 面板型AP

餐厅 1F-N(UAP-AC-LR) 吸顶AP 放置在餐厅位置,兼顾厨房餐厅厕所以及厕所外的设备平台等空间,LR型号的穿墙能力稍强

客厅 1F-S(UAP-AC-Pro) 吸顶AP 放置在客厅靠窗户的位置,兼顾客厅和院子, Pro型号的带机量以及带宽会比较高

1F(USW-Flex-Mini) 客厅这边同时布置了一个小的交换机,毕竟无线再快也没有有线稳定,所以Apple TV,PS5啥的还是连的有线,还有IPTV的机顶盒需要使用有线和VLAN的特性,交换机也是必要的。

二楼北 2F-N(UAP-FlexHD) 主要供北面的房间和书房使用,FlexHD的信号强度以及无线速度都比较强

二楼南 2F-S(UAP-AC-M) 南面的房间使用

三楼 3F(UAP-AC-Pro) 顶楼房间使用

入户 Frontyard(UAP-AC-Lite) 这边是通过USW Flex交换机(放在摄像头上方的防水盒内),来同时对摄像头和AP供电,而USW Flex自身也是通过POE(af,at或者bt)进行供电的,这种模式用在户外一分多的情况下很方便,避免了电源线的布置,只需要一根网线即可搞定多个设备的网络和供电。

使用场景举例

影视多媒体

还记得以前看下载的电影或者电视剧都是直接放在NAS上用本地的普通播放器看。后来知道了Jellyfin或者Plex这类媒体服务器的概念,媒体服务器可以对电影电视剧进行统一的管理,自动刮削电影或者电视剧的元数据。在多个客户端上观看记录和状态都可以自动进行同步,你在电视上看的记录,到手机或者电脑上观看进度都在,随时继续观看。 我这边使用的是开源免费的Jellyfin,客户端一般用的Infuse和官方的客户端。 Jellyfin服务直接部署在QNAP NAS的Docker的容器中,这样可以直接挂载NAS的硬盘。

Infuse Pro客户端(Apple TV)

Jellyfin网页端

每一集的简介信息

另外不要使用服务器端解码,端上解码是最好的解决方案。

远程访问

DDNS DDNS 是使用的阿里云的服务,通过OpenWRT的服务来进行更新,当wan口ip地址发生变化的时候就会自动更新域名ip地址。当然这个需要你的互联网提供商能够给你分配外网IP地址。

VPN 有ddns的帮助,我们就可以通过域名访问家里的ip地址了,如果需要访问家中的一些服务,我们可以通过端口转发的方式进行,端口转发这功能一般的路由器都提供了,但是端口转发存在一定的安全性,直接将相关服务暴露在互联网上,所以为了安全起见,还是走VPN比较安全。 一般的路由器也是提供了VPN服务的,Unifi VPN的设置

这样电脑或者手机上就可以通过域名和L2TP相关的信息设置 VPN连入家里的网络了。

科学上网

为了保证默认WiFi网络的稳定,默认网络不提供科学上网,所以单独划出一个WiFi来给科学上网使用,这样这个单独的WiFi再怎么折腾也不会影响正常的网络。 软路由的OpenWRT上安装OpenClash服务(别的类似服务也ok),然后OpenWRT以旁路由的角色接入网络(LAN口接入交换机),如果多个VLAN网络都需要使用相关服务,那么可以添加多个网络接口,保证OpenWRT在相应的VLAN网络中能够访问。

然后我们设置单独划出来的WiFi网络 我们先创建一个Network,对其进行相关配置,设置相应的网段和VLAN ID

设置DHCP的DNS Server和Geteway为旁路由的地址,这样客户端脸上这个网络之后 就会将网关和DNS Server设置为相应的IP地址。

网络设置好了之后,我们再去设置WiFi,选择刚才创建好的Network,这样创建出来的WiFi就可以进行科学上网了。

iTV

目前iTV也就用来看看比赛,平时也比较少看。 通过VLAN,即使客厅电视和机柜只有一根网线我们可以方便的通过交换机机顶盒来连光猫的IPTV口拨号看电视。不少地方的iTV 你可以进行抓包,然后通过OpenWRT模拟拨号,然后再通过udpxy服务将多播转为内网单播,随处通过rtsp流进行观看,网上资料很多,这里就不多做陈述了。

IoT

IoT网络这块其实没啥好说的,就是如果想要提高安全性,可以为IoT设备单独划一个VLAN网络,和一般上网用的网络进行隔离。我目前为了简单还没有这么做,我一般的诸如智能开关,插座,zigbee网关啥的基本都是买的sonoff家的产品然后刷的tasmota的开源固件,这样就可以得到设备的完全控制权,然后再集成中Home Assistant系统中,其他很多小米的设备也可以集成到HA中,如果有同学想详细了解相关内容,我可以单独再开一篇来写。

]]>
德业除湿机接入Homeassistant 2022-10-28T00:00:00+08:00 卢克 http://kejinlu.com/2022/10/deye-homeassistant 之前已经有人对德业除湿机做过反编译,找到了其背后的实现原理,并通过修改homeassistant相关组件远吗接入homeassistant,https://xiking.win/2020/11/12/3-deye-dehumidifer-add-to-homeassistant/ ,也有将其做成Homekit插件的https://github.com/IcesandSora/homebridge-deye 。我是想将其接入Homeassistant,但是并不想修改Homeassistant源码,所以方案就是通过Node-RED进行德业MQTT消息的桥接转发,然后再接入Homeassistant。

协议

HTTP API

我们可以通过手机上的“德业智能”App抓包来研究德业服务相关的协议,通过登录过程的抓包我们可以发现登录成功后,会得到一个token,这个token是德业其余需要权限验证的API请求时必要的条件,也就是请求头中需要一个名为“Authorization”的参数,value为”JWT “+token。后继我们就可以通过相关API就可以获取德业MQTT服务的配置信息,以及设备列表。 需要了解整个请求的过程可以参见我写的这个python脚本 https://github.com/kejinlu/deyeinfo/blob/main/deyeinfo.py

MQTT 设备状态和命令

MQTT的抓包这边建议使用iOS的App来抓,iOS App的MQTT目前是没有走TLS协议的,可以iOS设备连PC分享出来的Wi-Fi,然后PC上使用Wireshark来抓包分析,找到MQTT协议的数据包。

刷新码

首先我们发现进入App之后即使我们不操作,客户端也是不断地发送MQTT消息,每次发送完就可以接收到,服务器端发过来的MQTT消息。其实这个定时不断发送的就是刷新命令,大概是3秒发送一次,德业这边是通过不停的轮询发送MQTT消息的方式来及时刷新设备的状态信息的。

所以我们就知道刷新命令了,后面我们也会用到。

从刷新命令的详细内容可以看到,MQTT消息内容为0001,而且消息内容的格式并不是字符串而是内存数据,这边还有个注意点就是QoS,这边需要设置为0,德业的服务器端貌似对QoS做了校验,反正我测试的时候如果不是0,则发过去没有作用。

设备状态码

接下来研究下返回的设备状态值,

下发设备状态的MQTT消息体解析之后是一个json格式的数据,里面data的内容是一个字符串,这个字符串就是设备的状态码。 14118108101900000000000000000041450000000000 通过设备不同的状态值的比对,我们可以弄清楚哪些数据对应什么内容,这个是需要慢慢设置,然后抓包看数据,进行比照,比较费时间。 需要额外注意的是,不同的设备某些状态值可能并不相同,比如开关机的状态,这些最好自己MQTT抓包确实下。这里的数值都是以DYD-D50B3型号为准。

数据片段 含义
1411  
8 风扇水箱状态(0风扇停转,8风扇打开,4水箱满)
1 开关机状态(0关机,1开机,B开机并设置了定时关机,6关机并开启了童锁,7开机并开启了童锁)
0  
8 压缩机状态(0待机,8运行)
1 风速(0停转,1低风,2中风,3高风)
0 设备模式(0手动,1干衣)
19 设定湿度(16进制格式)
000000000000000000  
41 环境温度(16进制格式,可能是为了表示零下温度,所以转为10进制再减去40才是真实的温度,比如41hex = 65oct 65-40=25,所以25是当前环境温度)
45 环境湿度(16进制格式)
0000000000  

设备设置码

通过在App上对除湿机进行各种设置,我们可以抓到对应设置的编码。

这里可以看到,和刷新码一样,都是二进制格式(图里面是转成了16进制),并不是字符串。 通过各种App的操作设置我们也可以知道他们的各位的含义。

数据片段 含义
08 02 0  
1 设备开关
1 风速(1低风,2中风,3高风)
0 模式(0手动,1干衣)
4b 设置湿度(16进制)
00 00 00  

Node-RED Flows

然后我们将flows.json导入Node-RED

  • “德业除湿机状态接受” 设置德业官方MQTT服务的配置以及状态的topic
  • “状态转发”节点中设置好本地MQTT服务的配置。
  • “自动刷新”节点可以设置主动刷新状态的时间间隔,德业这方便设计的不是特别好,状态的及时更新依赖于本地的轮询刷新命令
  • 三个“设置”节点们需要配置好本地mqtt服务
  • “德业除湿机命令发送”节点设置好德业官方MQTT命令topic
  • “解析状态”和“设置命令”节点中,需要根据自己设备的MQTT协议抓包结果来做相关的修改适配,因为不同设备一些状态码,或者命令码有细微的差别。

Homeassistant配置及使用

根据configuration.yaml中的示范配置,将除湿机和相关的传感器配置到自己的Homeassistant的配置中。

]]>
深度学习起步 2016-07-27T00:00:00+08:00 卢克 http://kejinlu.com/2016/07/deep-learning-intro 深度学习作为机器学习的一个分支也发展了也很多年了,只是一直感觉特别遥远,高深莫测,所以一般都不会接触到。不过近些年深度学习被越来越多的提起,特别是年初AlphaGo和李世石的比赛,打破了电脑在围棋上无法击败人类的预言,深度学习再次被推上风口浪尖。这两年逐渐火起来的自动驾驶,背后也是深度学习,像Google的自动驾驶汽车都需要不断地行驶在真实的路面上进行“学习”。最近流行的Prisma照片艺术化处理软件,其背后的技术也是深度学习:A Neural Algorithm of Artistic Style

我们先从机器学习开始吧,通俗点讲机器学习就是指计算机程序随着经验积累而自动提高性能。在机器学习界有一个类似于编程语言中的Hello World问题,即手写数字识别问题,一般都是通过MNIST(Mixed National Institute of Standards and Technology)手写数字图片数据库来训练程序,最后再通过对应的测试数据来测试,得出识别率。 也就是说如果一个程序对手写数字图片识别的准确率随着不断的训练识别率不断得到提升,那么可以称计算机程序在从训练中进行学习。这里有几个概念,首先任务,这里就是对输入的某个手写图片进行识别;然后是训练,训练则是训练数据,上面的书写数字识别的训练数据便是大量的手写数字图片以及对应的答案;还有就是识别率,识别率会随着不断的学习而变得越来越高,也就是通过学习,程序自己改了自己内部的一些状态,使得对新的手写图片进行识别的时候更加准确。

再来抽象点进行描述,机器学习就是类比人类认识世界的过程,人类通过历史经验进行归纳总结,碰到新的问题后能够根据归纳总价的规律来解决问题。当然机器没有人类大脑这么牛逼的硬件,而有的只是一堆集成电路和软件程序,对于机器学习来讲,机器学习软件通过训练数据来调整优化软件内部计算模型的参数,然后通过优化后的模型参数来处理新的数据,得到结果。

机器学习可以分为两大类:监督学习和无监督学习。监督学习就是训练数据给出数据以及数据对应的标准答案,学习后,对训练样本外的数据做出分类预测;非监督学习就是对没有答案或者标记的数据进行学习,发现数据的结构特性,将相似的数据聚集在一起,这就是所谓的聚类了,聚类并不关心某一类是什么。

我们再来谈谈机器学习的训练数据库,目前在机器视觉这方面比较好些,除了上面Hello World的MNIST数据库,还有全球最大的图像识别数据库ImageNet,它的缔造者就是斯坦福大学的李飞飞教授,他们利用互联网图片以及众包技术平台来帮助标记这些图片,所以这些图片数据库的构建可以说是“人肉计算”。斯坦福大学每年都会举行一个比赛,邀请谷歌、微软,百度等 IT 企业使用 ImageNet,测试他们的系统运行情况。每年一度的比赛也牵动着各大巨头公司的心弦,过去几年中,系统的图像识别功能大大提高,出错率仅为约 5% ,比人眼还低。还有一个数据库就是MSCOCO ,由微软资助,每年也会举行比赛。

再来说说深度学习,所谓深度是相对于浅层结构的机器学习,这些典型结构包含至多一层或两层非线性特征变换。而深度学习则是增加了计算的层次,原始的数据输入后,会经过多层处理之后才会最终输出得到结果。网络很多都提到深度学习是对人类视觉皮层结构的模拟,但是实际上深度学习的结构更多来源于理论、直觉和经验探索,和神经科学的相关性并没有那么大。拿深度学习中的卷积神经网络来说,确实部分灵感来自于神经科学,但是其和人类大脑的差距还是很大,所以在宣传的时候如果着重强调“神经”这个关键词会给人以错觉。

上面谈到了深度学习的 多层结构,其实里面的每一层都是一些数学计算,有些层里面的节点都包含一些典型的算法,通常我们都是从“回归”相关的算法开始说起,后面会对回归算法进行详细讲解。

]]>
Ubuntu使用环境配置 2016-07-23T00:00:00+08:00 卢克 http://kejinlu.com/2016/07/ubuntu-config

还记得大学那会儿,特别爱折腾系统,装的最多估计就是Ubuntu系统了,也是从那时候起开始接触Linux,工作之后也就懒得折腾了,选择了同属UNIX系得macOS 。所以很长的时间里就一直使用macOS了。

去年家里买了一个NAS,当时考虑的因素有做工,盘位,系统。最后选择QNAP TS-453 Pro,当时为了便宜选择了从computeruniverse海淘,即使加上关税到手也比国内行货便宜一两千。虽然QNAP的系统比不上群晖,但是这两年已经有很大的改观,用户体验有追赶群晖的趋势。

QNAP TS-453 Pro 四核,四盘位,四千兆网口,内存升级到8G。内置的HD Station支持HDMI显示,HD Station支持一些简单的App,比如Chrome,Kodi,还是比较方便的。TS-453 Pro还支持虚拟化技术,通过Virtualization Station可以安装各种虚拟机,应该是基于KVM技术的;另外Container Station还支持Docker容器,所以使用起来想象空间很大。 没想到今年新的系统开始支持Linux Station,可以有完整的Linux的桌面体验了,当然Linux Station开启后,HD Station 会被关闭,因为都是通过HDMI接口来进行显示的输出。Linux Station是基于LXC来实现的,所以性能优越和原生基本无异。

目前Linux Station还只支持Ubuntu 14.04和16.04这两个LTS版本。想想NAS基本24小时开机,配置个简单的Ubuntu的工作环境,方便平时写写代码,写写文档。在QTS的Linux Station中安装好Ubuntu 16.04,以及确认开启Linux Station后,便可以在显示器上进行操作了。

简单的想了下,想搭建下python和Java的开发环境,以及装一些必要的软件。

1. python工作环境配置

Ubuntu 16.04安装好了之后默认就带了2.x和3.x的python版本,但是为了更方便的进行版本管理,决定通过pyenv来安装和管理python版本。 安装pyenv之前先装下git

sudo apt-get install git

然后进行pyenv的安装

git clone https://github.com/yyuu/pyenv.git ~/.pyenv

然后在~/.bashrc 末尾加上下面三行代码

export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

然后重启Terminal,输入pyenv看命令是否起作用。

下面便可以通过pyenv来安装你所需要的python的版本了,比如

pyenv install 3.5.1

这个时候你会发现 悲剧发生了,由于国内的特定网络原因,python安装的下载速度及其缓慢,基本是10k以内的速度无法忍受(突然想到以前python官网一度无法访问),好在还是由办法解决这个问题的,你可以先想办法到官网把安装包单独下载好,比如我讲下载好的包Python-3.5.1.tar.xz放到~/Downloads目录下,然后指定build缓存目录后,再运行install命令

export PYTHON_BUILD_CACHE_PATH=~/Downloads
pyenv install 3.5.1
pyenv rehash

安装多个版本的python之后可以通过下面的命令来切换全局的python的版本

pyenv global 3.5.1

安装后对应版本的pip也是安装好了的。

最近对深度学习有点感兴趣,所以正好打算装个TensorFlow玩一玩,找到官方的安装指南,选择正确的版本进行安装, https://www.tensorflow.org/versions/r0.9/get_started/os_setup.html#pip-installation, NAS的配置注定只能使用CPU的,然后选择对应的python版本和Ubuntu的架构版本

export TF_BINARY_URL=https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.9.0-cp35-cp35m-linux_x86_64.whl
pip install --upgrade $TF_BINARY_URL

这个时候你会发现老问题又来了,pip的库在国内访问慢,所以这个时候你最好指定一个国内的pip源进行安装,这里选择豆瓣的源

pip install --upgrade $TF_BINARY_URL  -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com

另外如果你想选择一款python的ide可以选择Pycharm的社区版,一般情况下社区版够用了。

2.配置Java环境

Java目前一般有open jdk,还有oracle jdk,这里安装oracle jdk,你可以自己到oracle官网下载,手动进行安装,也可以通过添加源的方式使用apt-get工具进行安装

sudo add-apt-repository ppa:webupd8team/java
sudo apt-get update
sudo apt-get install oracle-java8-installer

安装好了之后可以进行配置,比如如果你系统中安装有多个版本的jdk,那么就可以通过下面的方式来指定默认使用的jdk的版本

sudo update-alternatives --config java

3.其他常用软件安装

  • 对应一些独立的第三方下载的deb包,推荐使用GDebi Package Installer (sudo apt-get install gdebi
  • 中文输入法推荐搜狗输入法,安装搜狗输入法前先安装依赖的包 (sudo apt-get install fcitx libssh2-1),系统设置中将输入法系统设置为fcitx,然后下载搜狗输入法的包,可以从这里下载 http://download.pchome.net/utility/lan/ime/download-3955.html ,官网的包安装有问题
  • 编辑器可以使用github的Atom或者Sublime Text 3
  • markdown编辑器推荐使用haroopad
  • 系统优化配置可以使用Unity Tweak Tool (sudo apt-get install unity-tweak-tool), 此工具可以设定诸如Launcher的位置等
  • 实时顶端状态栏显示系统cpu内存等信息可以通过 indicator multiload 来shi实现 (sudo apt-get install indicator-multiload
  • 中文字体可以选择微软雅黑,首先到Windows系统下拷贝过来字体文件,进行下列操作之后,便可以选择微软雅黑字体了

    sudo cp msyh.ttf /usr/share/fonts/
    sudo mkfontscale
    sudo mkfontdir
    sudo fc-cache -fv
    
]]>
关于Xcode7中的tbd文件 2016-03-31T00:00:00+08:00 卢克 http://kejinlu.com/2016/03/tbd-file

tbd 是 text-based stub libraries的意思, 是苹果在Xcode7中使用的一个技术,便于减少Xcode7中SDK的体积。
下面讲解下Xcode7如何通过tbd这个技术减少SDK的大小的。 Xcode7中和各个平台相关的sdk都在/Applications/Xcode.app/Contents/Developer/Platforms 这个目录下,你可以看到如下的一些平台:

这里列出了平台的名字 以及对应的动态链接库所需要的架构

MacOSX (i386,x86_64)
iPhoneOS (armv7, armv7s, arm64)
iPhoneSimulator (i386,x86_64)
AppleTVOS (arm64)
AppleTVSimulator (x86_64)
WatchOS (armv7k)
WatchSimulator (i386)

每个平台的SDK都在对应的Developer/SDKs/的子目录下,比如iPhoneOS的sdk在/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk下, 每一个SDK目录下都会包含相应的动态Framework以及dylib库,分别在SDK目录下的System/Library/Frameworks/usr/lib目录下。
在使用tbd之前不管是哪个平台,Framework以及各个单独的dylib库的二进制都得放进来,数量多,体积大。但是真正有必要的其实只是各个模拟器要用的动态库,因为MacOSX的库系统自带,那些诸如iPhoneOS,AppleTVOS,WatchOS这些设备要用的动态库,也只是在设备上真正运行的时候才需要,编译的时候只需要一些简单的信息,符号表啥的,编译通过就好了,真正到设备上去跑的时候才真正需要整个动态库的二进制文件。
所以为了节省Xcode的体积,苹果创造了一种tbd文件,用作替代那些设备SDK下的动态库,这里我们以/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/CFNetwork.framework这个动态Framework库为例,进入到CFNetwork.framework目录下你会看到一个CFNetwork.tbd文件,cat下

---
archs:           [ armv7, armv7s, arm64 ]
platform:        ios
install-name:    /System/Library/Frameworks/CFNetwork.framework/CFNetwork
current-version: 758.3.15
exports:         
  - archs:           [ armv7, armv7s, arm64 ]
    symbols:         [ '$ld$hide$os4.3$_NSHTTPCookieComment', '$ld$hide$os4.3$_NSHTTPCookieCommentURL', 
                       '$ld$hide$os4.3$_NSHTTPCookieDiscard', '$ld$hide$os4.3$_NSHTTPCookieDomain', 
                       '$ld$hide$os4.3$_NSHTTPCookieExpires', '$ld$hide$os4.3$_NSHTTPCookieLocationHeader', 
                       '$ld$hide$os4.3$_NSHTTPCookieManagerAcceptPolicyChangedNotification', 
                       '$ld$hide$os4.3$_NSHTTPCookieManagerCookiesChangedNotification', 
                       '$ld$hide$os4.3$_NSHTTPCookieMaximumAge', '$ld$hide$os4.3$_NSHTTPCookieName', 
                       '$ld$hide$os4.3$_NSHTTPCookieOriginURL', '$ld$hide$os4.3$_NSHTTPCookiePath', 
                       '$ld$hide$os4.3$_NSHTTPCookiePort', '$ld$hide$os4.3$_NSHTTPCookieSecure', 
                       '$ld$hide$os4.3$_NSHTTPCookieValue', '$ld$hide$os4.3$_NSHTTPCookieVersion', 
                       '$ld$hide$os4.3$_NSNetServicesErrorCode', '$ld$hide$os4.3$_NSNetServicesErrorDomain',

.......

你会发现其实tbd文件就是一个文本文件,其中包含架构信息,以及在真实运行时候二进制所在的位置,以及包含了动态库的符号表还有类的一些信息,这些信息在编译阶段足够了。通过通过这种技术,可以大大减少所有的设备SDK的二进制动态库的体积,其中包含MacOSX,iPhoneOS,AppleTVOS,WatchOS
模拟器SDK的动态库依然是原始的动态库二进制文件,这点你可以进到/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/CFNetwork.framework目录下进行验证。

]]>
iOS VoiceOver Programming Guide 2016-03-07T00:00:00+08:00 卢克 http://kejinlu.com/2016/03/ios-voiceover-programming-guide 前言

VoiceOver是苹果“读屏”技术的名称,属于辅助功能的一部分。VoiceOver可以读出屏幕上的信息,以帮助盲人进行人机交互。 这项技术在苹果的各个系统中都可以看到,OS X,iOS,watchOS,甚至tvOS。 苹果公司的VoiceOver在2015年6月18日获得了美国盲人基金会(American Foundation for the Blind, AFB)颁发的海伦凯勒成就奖,成为全球首家获得此殊荣的科技公司。 单从iOS来说,iOS的VoiceOver功能可以毫不夸张的说是三大移动平台中做的最好的。

虽然说苹果默认的UI组件都已经默认支持VoiceOver功能了,但是通常情况下App还是需要对VoiceOver进行适配和优化的,比如说一些自定义复杂UI组件。

基本使用

iPhone上开启VoiceOver功能后,就可以通过 单指左右轻扫 来遍历当前界面中的所有的AccessibilityElement(可以被VoiceOver访问的UI元素),当一个AccessibilityElement被选中后,VoiceOver会将AccessibilityElement的信息读出来。 单指轻点两次 能够激活当前元素对应的操作,比如当前AccessibilityElement是一个按钮,那么对应的就是按钮的Action事件。

简单点来说在App开发过程中关于VoiceOver我们需要关注如下几点:

  • 界面上的AccessibilityElement有哪些
  • AccessibilityElement的位置和形状
  • AccessibilityElement的信息是什么(就是Element被选中后,被读出来内容)
  • AccessibilityElement所能响应的的事件有哪些

UIKit中的控件基本都是 VoiceOver Ready的,即使是UIView,你也可以通过简单的设置其实变成AccessibilityElement。所以这一小节中所讲的AccessibilityElement其实都是UIView或其子类的实例。
相关属性和方法基本都在UIAccessibility.h这个头文件中进行了声明。

所以简单的情况下通过UIView的isAccessibilityElement属性就可以控制某个View是否是AccessibilityElement,在UIKit的控件中,像UILabel,UIButton 这些控件的isAccessibilityElement属性默认就是true的,UIView这个属性默认是false。

一般情况下AccessibilityElement的位置和形状是通过accessibilityFrame进行设置的,默认值是View在屏幕中的位置,形状就是View的矩形形状。如果你想自己设置accessibilityFrame的值,那么得注意下,这边的frame值是相对于设备Screen的坐标系的,当然可以通过UIAccessibilityConvertFrameToScreenCoordinates函数来帮助转换。此函数有两个参数,一个rect,一个是view, 其含义就是将相对于view这个坐标系的rect转换成相对于screen坐标系的值并返回。所以一般情况下 rect可以是目标Element在父View中的frame,view就为其父view。

public func UIAccessibilityConvertFrameToScreenCoordinates(rect: CGRect, _ view: UIView) -> CGRect

如果你想设置非矩形的形状,你也可以通过给 accessibilityPath 属性指定一个UIBezierPath类型的值来自定义AccessibilityElement的形状。

至于AccessibilityElement的信息可以通过下面几个UIAccessibility的属性来决定

  • accessibilityLabel 这是什么
  • accessibilityHint 这个有什么用,会产生什么样的结果
  • accessibilityValue 这个的 是什么
  • accessibilityTraits 这个的类型以及状态,就是通过traits来表征这个Element的特质,数据类型是一个枚举类型,可以通过按位或的方式合并多个特性。

这里有个需要注意的就是,当某个View的是AccessibilityElement的时候 ,其subviews都会被屏蔽掉,这个特性有时候还是有用的,比如一个View中包含多个Label,那么你希望每一个下面的Label不要单独可以访问到,那么你可以将这个View设置成可以访问的,然后将其accessibilityLabel设置为所有子Label的accessibilityLabel的合并值。

至于AccessibilityElement的事件,最简单的莫过于上面提到 单指轻点两次 能够激活当前元素对应的操作了,如果当前AccessibilityElement实现的public func accessibilityActivate() -> Bool这个方法返回true,那边此逻辑将被调用,否则相当于在AccessibilityElement的accessibilityActivationPoint这个位置点上进行了一次Tap操作。

高级特性

Accessibility Container

设想下这样的一个场景,一个UIView,内部包含一组用户可以进行交互的内容,每一个内容之间是独立的,但是这些内容不是以子View的形式存在,而是通过Quarz 2D或者Core Text渲染而成,所以这部分内容无法通过上面的方式变成AccessibilityElement。这种情况UIView需要按照UIAccessibilityContainer的方式,来将内部的每一个独立的内容都描述成UIAccessibilityElement的实例,这个时候这个UIView我们称之为Accessibility Container。

接下来讲解具体的实现步骤了。 先说说iOS 8之后如何实现,首先Accessibility Container的isAccessibilityElement值必须设置为false,另外我们需要创建出所有的UIAccessibilityElement的实例,然后赋给accessibilityElements属性(iOS 8.0+) 假设在一个UIView的子类中,通过叫做updateAccessibleElements的方法来更新维护所有的UIAccessibilityElement实例,当界面上的内容发生变化,或者VoiceOver开启关闭状态发生变化时,调用此方法以更新accessibilityElements,相关伪代码如下:

    internal func updateAccessibleElements() {
        guard UIAccessibilityIsVoiceOverRunning() else {
            self.accessibilityElements = nil
            return
        }
        
        self.isAccessibilityElement = false
        var elements = [AnyObject]()

        let element1 = UIAccessibilityElement(accessibilityContainer: self)
        element1.accessibilityLabel = "element1"
        element1.accessibilityTraits = UIAccessibilityTraitStaticText
        element1.accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(element1FrameInSelf, self)
        elements.append(element1)
        ...
        self.accessibilityElements = elements
    }

如果是iOS 8以下,那么就需要实现下面的几个方法来实现了,比较繁琐:

// accessibilityElement的个数
public func accessibilityElementCount() -> Int

// 返回指定Index的accessibilityElement
public func accessibilityElementAtIndex(index: Int) -> AnyObject?

// 返回指定accessibilityElement的Index
public func indexOfAccessibilityElement(element: AnyObject) -> Int

使用上面第二种方式的时候,往往需要自己维护一个包含所有accessibilityElements的数组,然后通过数组来完成上面的这三个方法的返回。这种方法比较累赘,而且当界面更新需要刷新内容的时候,还需要发送通知系统,告诉系统界面上的accessibilityElements有变动需要更新。所以最小版本定位于iOS 8的情况下还是直接设置accessibilityElements属性的方式比较科学。

关于UIAccessibilityElement这个类的设计还是存在一些疑惑的,既然NSObject的UIAccessibility扩展已经包含了诸如accessibilityLabel,accessibilityHint这些属性,为何UIAccessibilityElement类中还需要重复声明这些属性,有点重复的感觉。

Actions

之前只讲到了最简单的事件,就是单指轻点两下,其实常见的Actions有下面这些,每一个Action都会对应一个方法,可以通过覆盖方法的方式来自定义Action对应的逻辑:

  • Activate 单指轻点两次
    public func accessibilityActivate() -> Bool
  • Escape. 单指 Z-shaped 手势一般用于退出模态界面或者返回导航的上一页界面
    public func accessibilityPerformEscape() -> Bool
  • Magic Tap. 双指轻点两次触发 most-intended action.
    public func accessibilityPerformMagicTap() -> Bool
  • Three-Finger Scroll. 三指滑动触发界面水平或者垂直的滚动
    public func accessibilityScroll(direction: UIAccessibilityScrollDirection) -> Bool
  • Increment. 单指向上滑动,需要设置accessibilityTraits为UIAccessibilityTraitAdjustable,否则对应的方法不会被调用
    public func accessibilityIncrement()
  • Decrement. 单指向下滑动,需要设置accessibilityTraits为UIAccessibilityTraitAdjustable,否则对应的方法不会被调用
    public func accessibilityDecrement()

这些方法中,其中Escape,Magic Tap,Three-Finger Scroll这几种手势支持在响应链中向上寻找对应的Action方法,首先用户在屏幕上进行相应的手势,系统检查当前VoiceOver的Focus的Element有无实现对应的方法,没有实现的话,则向响应链的上一级寻找。比如有些全局性的操作,对应的方法写在上层View或者ViewController中比较合适。 其实很多系统提供的组件都默认实现了一些VoiceOver的手势Action,比如UINavigationController, UIAlertController 都提供了对Escape手势的支持。

Accessibility Notification

Accessibility提供了一系列的通知,可以完成一些特定的需求。比如你可以监听UIAccessibilityVoiceOverStatusChanged通知,来监控Voice Over功能开启关闭的实时通知

或者是你在App中主动发送一些通知,来让系统做出一些变化,比如当你界面上的AccessibilityElement有变动的时候你可以发送UIAccessibilityLayoutChangedNotification通知,通知发送时使用UIAccessibility的专用的函数,UIAccessibilityPostNotification函数有两个参数,第一个是通知名,第二个是你想让VoiceOver读出来的字符串或者是新的VoiceOver的焦点对应的元素。

UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, self.myFirstElement)

建议

  • AccessibilityElement的信息尽量简洁,accessibilityLabel不要包含提示性质的文案,避免信息干扰
  • TableView的每一个Cell的信息尽量合并,使得Cell变成一个整体的AccessibilityElement,避免无意义的冗余元素之间切换的操作。Cell中有多个按钮的时候,可以考虑使用Magic Tap的方式,Magic Tap的Action中弹出sheet样式的UIAlertController来供用户操作。
  • 自定义的模态页面注意设置accessibilityViewIsModal为true,最好支持Escape手势 的方式退出模态页面。
  • 将页面中装饰用的没有实际意义的元素的accessibilityElementsHidden设置成true,减少操作过程中的干扰

参考文档: UIAccessibility Protocol Supporting Accessibility Accessibility Programming Guide for iOS

]]>
Handoff Between iOS App & Website 2015-04-01T00:00:00+08:00 卢克 http://kejinlu.com/2015/04/handoff-between-native-app-and-web-browser 一.Handoff的基本常识

iOS 8以及Mac OS X Yosemite之后引入了一个新的功能特性:Handoff。Handoff也就是Continuity特性,连续互通,比如你用iPhone写邮件写到一半想在Mac上继续写,或者Mac上看到一个网页想在手机上浏览,这些便是Handoff的使用场景了。

Handoff的支持有一些硬性的要求:

  • 互通的所有设备必须支持 Buletooth LE 4.0,Handoff使用BLE信号来传递用户活动数据。
  • 设备处于联网状态,有时候有些数据还是会通过互联网来传递的,比如Mail App的邮件内容的同步。
  • 所有设备必须连到同一个iCloud账户。
  • 当然你还得保证当前设备的Handoff功能打开了(iOS:设置->通用->Handoff 与建议的应用程序。 Mac:系统偏好设置->通用,倒数第二栏有个选项,”允许这台Mac和iCloud设备之间使用Handoff”)

BLE并不像传统的蓝牙,并不需要人工手动进行配对,只要打开就行了,所有的配对数据传输都是自动完成的;设备并不一定需要连在同一个WIFI网络中,Handoff的活动数据通过BLE进行传递,保证及时性以及数据的安全性,你可以在使用过程中尝试将WIFI或者网络关闭,设备还是可以接受到Handoff的通知的。

苹果已经对很多内置的App做了Handoff支持,如Safari浏览器,邮件,电话,消息,提醒事项等都是支持的,在你开始Handoff编程之前可以先使用这些App进行Handoff功能的体验。

二.iOS App 到 Web Browser

Handoff编程的核心类便是NSUserActivity了,代表着一个用户的活动,每一个Activity都有一个activityType,用来标识Activity的类型。当App 到 App之间进行Handoff的话,那么接受方需要满足几个条件

  • App必须是通过发布证书或者开发者证书进行打包的
  • 和发布Activity的App拥有相同的TeamID
  • info.plist中声明了接受的Activity的activityType(key 为 NSUserActivityTypes)

不过很多应用其实也只是在移动设备上有App,在Mac上绝大多数还是走的浏览器,所以iOS App和浏览器的Handoff的需求就变的很常见了。这个时候Activity的另一个叫做webpageURL的属性便有用武之地了,当没有合适的App能够处理当前的Activity的话,系统会转给默认的浏览器进行处理(当然你的这个默认的浏览器的info.plist的NSUserActivityTypes数组中必须声明了 NSUserActivityTypeBrowsingWeb这个type,目前Mac版本的Chrome已经支持了)。

self.myActivity = [[NSUserActivity alloc] initWithActivityType: @"com.taobao.handoff.act.home"];
self.myActivity.webpageURL = [NSURL URLWithString:@"http://www.taobao.com"];
[self.myActivity becomeCurrent];

当上面的代码执行之后,Activity便会进行分发,接受者接受后,若没有App能够处理当前类型的Activity的话便转交给默认的浏览器去处理了,这里需要特别注意的就是activity的生命周期,当activity被invalidate或者被释放了,那么这个Handoff消息也就消失了,相关设备的Handoff消息就会消失。

关于Handoff的调试,由于到目前为止模拟器还是没有支持Handoff的,所以你必须使用开发者证书进行真机调试。

三.Web Browser 到 iOS App

相比于App到Web Browser,Web Browser到iOS App的Handoff实现起来就复杂一些了。 首先先描述下大体的流程:

  1. 首先在Mac上使用Safari浏览器浏览目标网站,Handoff消息会通过BLE进行分发
  2. iOS设备接收到Handoff消息后,检查对应的webpageURL,看是否有某个App的associated-domains (entitlement中的一项)中包含了这个webpageURL, associated-domains对应的Handoff的配置URL样式为 activitycontinuation:example.com
  3. 如果某个App的associated-domains存在相应的webpageURL,那么iOS会去这个网站的固定的一个URL(地址为https://example.com/apple-app-site-association)获取一个签名过的文件(源文件为一个JSON文件),如果解密后文件中的App IDs中包含了 之前匹配的App的App ID,那么这个Activity便交给这个匹配的App进行处理。

下面讲解详细的操作步骤

1.客户端

首先当然还是折腾客户端工程,当你创建好工程,创建好App ID,XCode中设置好自己的Developer账户之后,你便可以设置编译的Code Sign的相关东西了,配置都得选自动的,这样就可以通过XCode来管理配置 App ID 以及相应的 Provisioning Profiles了,当你通过developer后台网站就可以看到Provisioning Profiles中有一堆所谓的Managed by Xcode的条目了。

你需要在XCode工程对应的Target的Capabilities这个Tab中开启Associated Domains,这个时候时候你可能会遇到错误提示“You must be a team admin or agent in order to enable this capability.”,其实即使账户是admin还是会报错,这个可能是XCode的bug吧,你需要切换到General这个tab中将Team先选None,然后再切换到你对应的Team,这个时候Team下方显示错误了,其实就是你更改了Entitlements,而这个和Provisioning Profiles有关联,所以你的Provisioning Profiles也需要重新更新,点击Team下方的Fix Issue按钮,等待重新下载新的Provisioning Profiles,然后回到Capabilities这个tab你会发现刚才的错误已经不见了。

其实Capabilities中的操作除了会在本地生成entitlements文件,还会同步到developer后台去,会修改app对应的App ID的配置,以及在developer后台生成新的Provisioning Profiles。这些东西都和打包签名息息相关。

接着在Associated Domains下加上所需要支持handoff的domains

activitycontinuation:taobao.com

activitycontinuation是服务名,taobao.com是支持的域名 当Mac上的浏览器访问一个网站的时候,此网站的域名如果被某个App的Associated Domains包含了,那么Handoff底层会去这个域名一个指定的路径下访问一个文件,这个指定的路径便是 : https://taobao.com/apple-app-site-association ,这个路径需要返回一个签名过的文件数据,里面指定了当前网站所支持Handoff的App ID们,这个下面会提及到

2.服务器端

需要进行Handoff的网站,需要在https的特定的路径下放一个签名过的文件,这个文件里面指明了Handoff支持哪些App(Domain-approved apps IDs),这个文件的明文为JSON格式,在对JSON文件签名前最好去掉所有无用的空格以及检测下JSON格式的正确性,避免后面带来问题

{"activitycontinuation":{"apps":["XN6U3EV979.com.taobao.handoff"]}}

签名则是使用网站的ssl的私钥以及证书进行签名(如果不存在中级证书,那么中级证书可以去掉)

cat json.txt | openssl smime -sign -inkey taobao.com.key
                             -signer taobao.com.pem
                             -certfile intermediate.pem
                             -noattr -nodetach
                             -outform DER > apple-app-site-association

生成的文件放到网站根目录下以及确保可以通过指定的路径进行访问。

3.如何进行本机调试

要想在开发机器上进行网站的Handoff的调试则首先的问题就是SSL证书,你需要自己搞一个CA证书,在Mac上可以通过Keychain Access(钥匙串访问)这个App中的证书助理来生成 。

首先是CA证书,这里生成的是自签名的根证书,CA证书的作用就是给网站的SSL的证书进行签名用的,然后创建网站的SSL证书,一步一步走下去,然后通过刚才的CA证书进行签发,这样生成的证书就可以直接用于网站的SSL证书了。

然后选择一个Web Server,我这里选用的Jetty,直接下载下来然后就可以直接使用自带的demo了,主要是需要自己配置下SSL。

将默认的ssl配置拷贝到demo工程相应的目录下

Luke@LukesMac:~/Workspace/jetty » cp etc/jetty-ssl.xml demo-base/etc/ 

从Keychian Access中导出之前生成的证书文件,导出格式为p12,这样就会包含私钥了。假设导出文件为 lukesmac.p12,导出时候 需要你设置一个密码,你就将其设置为 keypwd 然后需要将这个p12文件导入demo工程的keystore文件中,默认在demo工程的etc目录下已经存在一个keystore文件,直接导入这个keystore

keytool -importkeystore -srckeystore lukesmac.p12 -srcstoretype PKCS12 -destkeystore keystore

默认keystore的密钥库口令为storepwd,导入的过程中你还需要输入你上面设置的私钥密码(因为jetty-ssl.xml中配置的私钥密码以及默认keystore中的私钥密码默认为keypwd ,所以为了方便上面导出私钥所设置的私钥密码保持一致为keypwd)。最后你还需要在demo工程的根目录下的start.ini中加入一行

etc/jetty-ssl.xml

然后你就可以开开心心的启动了,

Luke@LukesMac:~/Workspace/jetty/demo-base » java -jar ../start.jar

然后我便可以通过 https://lukesmac.local:8443/ 进行访问了

下面需要将json.txt进行签名,

首先你需要从上面导出的p12文件中搞出私钥文件,再从Keychain Access中导出一份证书的cer文件

openssl pkcs12 -in lukesmac.p12 -nocerts -out privateKey.pem
cat json.txt | openssl smime -sign -inkey privateKey.pem -signer lukesmac.cer -noattr -nodetach -outform DER > apple-app-site-association

将生成的apple-app-site-association文件放到 demo工程的ROOT目录下,然后重启以及在浏览器中对这个文件进行访问测试。

这个时候你以为一切就绪了,发现手机上handoff的图标依然是safari,打开后发现,网页根本无法打开,其原因就是自己生成的自签名的CA证书不被信任,这个时候你可以讲CA证书按照cer的格式导出,然后通过邮件发送,在iPhone上的邮箱App中点击这个cer的附件,系统会跳转到设置的描述文件的界面去,你需要进行安装证书,之后这个CA证书签发的SSL证书对于这台设备都是可信任的了。

最后就是客户端添加处理逻辑了,可以在Appdelegate中添加如下方法,就可以对传递过来的userActivity进行处理

- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void(^)(NSArray *restorableObjects))restorationHandler NS_AVAILABLE_IOS(8_0);
]]>
Facebook Pop 使用指南 2014-05-22T00:00:00+08:00 卢克 http://kejinlu.com/2014/05/facebook-pop-usage 当听闻Facebook要开源自己的Animation框架的时候,我还以为是基于Core Animation进行的封装,包含了一些动画效果库。等源码真正出来后,才发现完全想错了,Facebook Pop其实是基于CADisplayLink(Mac平台上使用的CVDisplayLink)实现的独立于Core Animation之外的动画方案。这里就不细说其实现原理了,主要讲讲Facebook Pop如何使用。

一.基本概念

在计算机的世界里面,其实并不存在绝对连续的动画,你所看到的屏幕上的动画本质上都是离散的,只是在一秒的时间里面离散的帧多到一定的数量人眼就觉得是连续的了,在iOS中,最大的帧率是60帧每秒。 iOS提供了Core Animation框架,只需要开发者提供关键帧信息,比如提供某个animatable属性终点的关键帧信息,然后中间的值则通过一定的算法进行插值计算,从而实现补间动画。 Core Aniamtion中进行插值计算所依赖的时间曲线由CAMediaTimingFunction提供。 Pop Animation在使用上和Core Animation很相似,都涉及Animation对象以及Animation的载体的概念,不同的是Core Animation的载体只能是CALayer,而Pop Animation可以是任意基于NSObject的对象。当然大多数情况Animation都是界面上显示的可视的效果,所以动画执行的载体一般都直接或者间接是UIView或者CALayer。但是如果你只是想研究Pop Animation的变化曲线,你也完全可以将其应用于一个普通的数据对象,比如下面这个对象:

@interface AnimatableModel : NSObject
@property (nonatomic,assign) CGFloat animatableValue;
@end

#import "AnimatableModel.h"
@implementation AnimatableModel
- (void)setAnimatableValue:(CGFloat)animatableValue{
  _animatableValue = animatableValue;
  NSLog(@"%f",animatableValue);
}

@end

此对象只有一个CGFloat类型的属性,非常简单,这里在AnimatableModel对象上运行几种Pop Animation进行测试,以便统计animatableValue的变化曲线。

由于此对象的属性不在Pop Property的标准属性中,所以需要创建一个POPAnimatableProperty,

  POPAnimatableProperty *animatableProperty = [POPAnimatableProperty propertyWithName:@"com.geeklu.animatableValue" initializer:^(POPMutableAnimatableProperty *prop) {
    prop.writeBlock = ^(id obj, const CGFloat values[]) {
        [obj setAnimatableValue:values[0]];
    };
    prop.readBlock = ^(id obj, CGFloat values[]) {
        values[0] = [obj animatableValue];
    };
}];

统计的数据来自上面属性变化时的Log数据,制图的时候将时间中除了秒之外的时间部分删除了,所有数据都来自真实测试的数据,并使用Number进行了曲线的绘制。图中的每个点代表一个离散的节点,为了方便观看,使用直线将这些离散的点连接起来了。

PopBasicAniamtion With EaseOut TimingFunction

POPBasicAnimation *animation = [POPBasicAnimation animation];
animation.property = animatableProperty;
animation.fromValue = [NSNumber numberWithFloat:0];
animation.toValue = [NSNumber numberWithFloat:100];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut];
animation.duration = 1.5;

_animatableModel = [[AnimatableModel alloc] init];
[_animatableModel pop_addAnimation:animation forKey:@"easeOut"];

从上图可以看到,动画开始的时候变化速率较快,到结束的时候就很慢了,这就是所谓的Ease Out效果。

PopSpringAniamtion

POPSpringAnimation *animation = [POPSpringAnimation animation];
animation.property = animatableProperty;
animation.fromValue = [NSNumber numberWithFloat:0];
animation.toValue = [NSNumber numberWithFloat:100];
animation.dynamicsMass = 5;

_animatableModel = [[AnimatableModel alloc] init];
[_animatableModel pop_addAnimation:animation forKey:@"spring"];

一开始快速向终点方向靠近,然后会在终点附近来回摆动,摆动幅度逐渐变弱,最后在终点停止。

通过上面的两个属性值变化的曲线你可以很好的理解动画的类型和属性的变化曲线之前的关联了。

二.Pop Animation的使用

这里就讲讲Pop Aniamtion自带的几种动画的使用。 Pop Animation自带的动画都是基于POPPropertyAnimation的,POPPropertyAnimation有个很重要的部分就是 POPAnimatableProperty,用来描述animatable的属性。上一节中就看到了如何来创建一个POPAnimatableProperty对象,在初始化的时候,需要在初始化的block中设置writeBlock和readBlock

void (^readBlock)(id obj, CGFloat values[])
void (^writeBlock)(id obj, const CGFloat values[])

这两个block都是留给动画引擎来使用的,前者用于向目标属性写值,使用者需要做的就是从values中提取数据设置给obj;后者用于读取,也就是从objc中读取放到values中。values[] 最多支持4个数据,也就是说Pop Aniamtion属性数值的维度最大支持4维。 为了使用便捷,Pop Animation框架提供了很多现成的POPAnimatableProperty预定义,你只需要使用预定义的propertyWithName来初始化POPAnimatableProperty便可,比如以下一些预定义的propertyWithName:

kPOPLayerBackgroundColor
...
kPOPViewAlpha
...

这样预定义的POPAnimatableProperty已经帮你设置好writeBlock和readBlock。 下面的一些基于POPPropertyAnimation的动画都提供了快捷的方法,直接传入propertyWithName便创建好了特定property的动画了。 下面列举的各个实例都可以在这里找到:https://github.com/kejinlu/facebook-pop-sample

1.POPBasicAnimation

基本动画,接口方面和CABasicAniamtion很相似,使用可以提供初始值fromValue,这个 终点值toValue,动画时长duration以及决定动画节奏的timingFunction。timingFunction直接使用的CAMediaTimingFunction,是使用一个横向纵向都为一个单位的拥有两个控制点的贝赛尔曲线来描述的,横坐标为时间,纵坐标为动画进度。 ​ 这里举一个View移动的例子:

NSInteger height = CGRectGetHeight(self.view.bounds);
NSInteger width = CGRectGetWidth(self.view.bounds);

CGFloat centerX = arc4random() % width;
CGFloat centerY = arc4random() % height;

POPBasicAnimation *anim = [POPBasicAnimation animationWithPropertyNamed:kPOPViewCenter];
anim.toValue = [NSValue valueWithCGPoint:CGPointMake(centerX, centerY)];
anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
anim.duration = 0.4;
[self.testView pop_addAnimation:anim forKey:@"centerAnimation"];

这里self.view上放了一个用于动画的testView,然后取一个随机坐标,进行动画。

2.PopSpringAnimation

弹簧动画是Bezier曲线无法表述的,所以无法使用PopBasicAniamtion来实现。PopSpringAnimation便是专门用来实现弹簧动画的。

POPSpringAnimation *anim = [POPSpringAnimation animationWithPropertyNamed:kPOPViewCenter];

NSInteger height = CGRectGetHeight(self.view.bounds);
NSInteger width = CGRectGetWidth(self.view.bounds);

CGFloat centerX = arc4random() % width;
CGFloat centerY = arc4random() % height;

anim.toValue = [NSValue valueWithCGPoint:CGPointMake(centerX, centerY)];
anim.springBounciness = 16;
anim.springSpeed = 6;
[self.testView pop_addAnimation:anim forKey:@"center"];

<img src=”http://ww1.sinaimg.cn/mw1024/65cc0af7gw1egqpgva69rg208u0fpjtx.gif” style=”width: 25%; height: 25%”;/>​

这个例子的动画和上面的基本动画很相似,都是一个view的移动,但是这里有弹簧效果。POPSpringAnimation主要就是需要注意下几个参数的含义:

  • springBounciness 弹簧弹力 取值范围为[0, 20],默认值为4
  • springSpeed 弹簧速度,速度越快,动画时间越短 [0, 20],默认为12,和springBounciness一起决定着弹簧动画的效果
  • dynamicsTension 弹簧的张力
  • dynamicsFriction 弹簧摩擦
  • dynamicsMass 质量 。张力,摩擦,质量这三者可以从更细的粒度上替代springBounciness和springSpeed控制弹簧动画的效果

3.PopDecayAnimation

基于Bezier曲线的timingFuntion同样无法表述Decay Aniamtion,所以Pop就单独实现了一个 PopDecayAnimation,用于衰减动画。衰减动画一个很常见的地方就是 UIScrollView 滑动松开后的减速,这里就基于UIView实现一个自己的ScrollView,然后使用PopDecayAnimation实现 此代码可以详细参见 KKScrollView 的实现,当滑动手势结束时,根据结束的加速度,给衰减动画一个初始的velocity,用来决定衰减的时长。

<img src=”http://ww3.sinaimg.cn/mw1024/65cc0af7gw1egmzoapnqwg206i0bm7nn.gif” style=”width: 25%; height: 25%”;/>​

4.POPCustomAnimation

POPCustomAnimation 并不是基于POPPropertyAnimation的,它直接继承自PopAnimation用于创建自定义动画用的,通过POPCustomAnimationBlock类型的block进行初始化,

typedef BOOL (^POPCustomAnimationBlock)(id target, POPCustomAnimation *animation);

此block会在界面的每一帧更新的时候被调用,创建者需要在block中根据当前currentTime和elapsedTime来决定如何更新target的相关属性,以实现特定的动画。当你需要结束动画的时候就在block中返回NO,否则返回YES。

四.Pop Animation相比于Core Animation的优点

Pop Animation应用于CALayer时,在动画运行的任何时刻,layer和其presentationLayer的相关属性值始终保持一致,而Core Animation做不到。
Pop Animation可以应用任何NSObject的对象,而Core Aniamtion必须是CALayer。

]]>
Custom Container View Controller 2014-05-08T00:00:00+08:00 卢克 http://kejinlu.com/2014/05/custom-container-view-controller 什么是Container View Controller?苹果文档是这么描述的:

A container view controller contains content owned by other view controllers.

也就是说一个View Controller显示的某部分内容属于另一个View Controller,那么这个View Controller就是一个Container,比如UIKit中的UINavigationController,UITabBarController。
在iOS 5之前苹果是不允许出现自定义的Container的 ,也就是说你创建的一个View Controller的view不能包含另一个View Controller的view,这对于逻辑复杂的界面来说,不易于功能拆分。也许曾经你为了某个公用的显示逻辑,直接将某个View Controller的view添加到另一个View Controller的view上,然后发现可以正常显示和使用,但实际上这种行为是非常危险的。

iOS 5.0 开始支持Custom Container View Controller,开放了用于构建自定义Container的接口。如果你想创建一个自己的Container,那么有一些概念还得弄清楚。Container的主要职责就是管理一个或多个Child View Controller的展示的生命周期,需要传递显示以及旋转相关的回调。其实显示或者旋转的回调的触发的源头来自于window,一个app首先有一个主window,初始化的时候需要给这个主window指定一个rootViewController,window会将显示相关的回调(viewWillAppear:, viewWillDisappear:, viewDidAppear:, or viewDidDisappear: )以及旋转相关的回调(willRotateToInterfaceOrientation:duration: ,willAnimateRotationToInterfaceOrientation:duration:, didRotateFromInterfaceOrientation:)传递给rootViewController。rootViewController需要再将这些callbacks的调用传递给它的Child View Controllers。

一.父子关系范式

实现一个Custom Container View Controller并不是一个简单的事情,主要分为两个阶段:父子关系的建立以及父子关系的解除。如果pVC将cVC的view添加为自己的subview,那么cVC必须为pVC的Child View Controller,而反过来则不一定成立,比如UINavigationController,一个View Controller被push进来后便和navigationController建立父子关系了,但是只有最上面的View Controller 是显示着的,底下的View Controller的view则被移出了容器的view的显示层级,当一个View Controller被pop之后,便和navigationController解除了父子关系了。

展示一个名为content的child view controller

 [self addChildViewController:content];  //1
 content.view.frame = [self frameForContentController]; 
 [self.view addSubview:self.currentClientView]; //2
 [content didMoveToParentViewController:self]; //3

1.将content添加为child view controller,addChildViewController:接口建立了逻辑上的父子关系,子可以通过parentViewController,访问其父VC,addChildViewController:接口的逻辑中会自动调用 [content willMoveToParentViewController:self];
2.建立父子关系后,便是将content的view加入到父VC的view hierarchy上,同时要决定的是 content的view显示的区域范围。
3.调用child的 didMoveToParentViewController: ,以通知child,完成了父子关系的建立

移除一个child view controller

 [content willMoveToParentViewController:nil]; //1
 [content.view removeFromSuperview]; //2
 [content removeFromParentViewController]; //3

1.通知child,即将解除父子关系,从语义上也可以看出 child的parent即将为nil
2.将child的view从父VC的view的hierarchy中移除
3.通过removeFromParentViewController的调用真正的解除关系,removeFromParentViewController会自动调用 [content didMoveToParentViewController:nil]

二.appearance callbacks的传递

上面的实现中有一个问题,就是没看到那些appearance callbacks是如何传递的,答案就是appearance callbacks默认情况下是自动调用的,苹果框架底层帮你实现好了,也就是在上面的addSubview的时候,在subview真正加到父view之前,child的viewWillAppear将被调用,真正被add到父view之后,viewDidAppear会被调用。移除的过程中viewWillDisappear,viewDidDisappear的调用过程也是类似的。
有时候自动的appearance callbacks的调用并不能满足需求,比如child view的展示有一个动画的过程,这个时候我们并不想viewDidAppear的调用在addSubview的时候进行,而是等展示动画结束后再调用viewDidAppear。也许你可能会提到 transitionFromViewController:toViewController:duration:options:animations:completion: 这个方法,会帮你自动处理view的add和remove,以及支持animations block,也能够保证在动画开始前调用willAppear或者willDisappear,在调用结束的时候调用didAppear,didDisappear,但是此方式也存在局限性,必须是两个新老子VC的切换,都不能为空,因为要保证新老VC拥有同一个parentViewController,且参数中的viewController不能是系统中的container,比如不能是UINavigationController或者UITabbarController等。
所以如果你要自己写一个界面容器往往用不了appearence callbacks自动调用的特性,需要将此特性关闭,然后自己去精确控制appearance callbacks的调用时机。
那如何关闭appearance callbacks的自动传递的特性呢?在iOS 5.x中你需要覆盖automaticallyForwardAppearanceAndRotationMethodsToChildViewControllers,然后返回NO,iOS6+中你需要覆盖 shouldAutomaticallyForwardAppearanceMethods方法并返回NO.
手动传递的时候你并不能直接去调用child 的viewWillAppear或者viewDidAppear这些方法,而是需要使用 beginAppearanceTransition:animated:endAppearanceTransition接口来间接触发那些appearance callbacks,且begin和end必须成对出现。
[content beginAppearanceTransition:YES animated:animated]触发content的viewWillAppear,[content beginAppearanceTransition:NO animated:animated]触发content的viewWillDisappear,和他们配套的[content endAppearanceTransition]分别触发viewDidAppear和viewDidDisappear。 (AppearanceTransition的这两个接口之前在苹果描述的文档中一开始还存在问题,因为文档中一开始说是iOS5不支持这两个接口,其实是支持的,后来苹果纠正了文档中的这个错误)。

三.rotation callbacks的传递

也许在iPhone上很少要关心的屏幕旋转问题的,但是大屏幕的iPad上就不同了,很多时候你需要关心横竖屏。rotation callbacks 一般情况下只需要关心三个方法 willRotateToInterfaceOrientation:duration:在旋转开始前,此方法会被调用;willAnimateRotationToInterfaceOrientation:duration: 此方法的调用在旋转动画block的内部,也就是说在此方法中的代码会作为旋转animation block的一部分;didRotateFromInterfaceOrientation:此方法会在旋转结束时被调用。而作为view controller container 就要肩负起旋转的决策以及旋转的callbacks的传递的责任。

当使用框架的自动传递的特性的时候,作为容器的view controller 会自动 将这些方法传递给所有的child viewcontrollers, 有时候你可能不需要传递给所有的child viewcontroller,而只需要传递给正在显示的child viewcontroller,那么你就需要禁掉旋转回调自动传递的特性,和禁掉appearance callbacks自动传递的方式类似,需要覆盖相关方法并返回NO,在iOS5.x中,appearance callbacks和rotation callbacks禁掉是公用一个方法的就是 automaticallyForwardAppearanceAndRotationMethodsToChildViewControllers,在iOS6之后分成两个独立的方法,旋转的则是 shouldAutomaticallyForwardRotationMethods
旋转相关的除了上面的几个rotation callbacks方法外,还有一个十分重要的概念,就是一个view controller可以决定自己是否支持当前取向的旋转,这个东西在iOS6前后的实现方式还不一样,iOS6之前使用的方法是 shouldAutorotateToInterfaceOrientation,就是一个view controller覆盖此方法,根据传入的即将旋转的取向的参数,来决定是否旋转。而iOS6.0之后的实现则拆分成两个方法 shouldAutorotatesupportedInterfaceOrientations,前者决定再旋转的时候是否去根据supportedInterfaceOrientations所支持的取向来决定是否旋转,也就是说如果shouldAutorotate返回YES的时候,才会去调用supportedInterfaceOrientations检查当前view controller支持的取向,如果当前取向在支持的范围中,则进行旋转,如果不在则不旋转;而当shouldAutorotate返回NO的时候,则根本不会去管supportedInterfaceOrientations这个方法,反正是不会跟着设备旋转就是了。
而作为界面容器你要注意的就是你需要去检查你的child view controller,检查他们对横竖屏的支持情况,以便容器自己决策在横竖屏旋转时候是否支持当前的取向,和上面的callbacks传递的方向相比,这其实是一个反向的传递。

四.创建自己的容器基类

当你需要构建自己的Container View Controller的时候,每一个Container都会有一些相同的逻辑,如果你每一个都写一遍会存在很多重复代码,所以最好你创建一个Container基类,去实现容器都需要的逻辑。那到底有哪些逻辑是每一个Container都需要做的呢?关闭Appearance和Rotation相关方法的自动传递;当Container的Appearance和Rotation相关方法被调用时,需要将方法传递给相关的Child View Controller;以及当前Container是否支持旋转的决策逻辑等。下面为一个容器基类的示范:

#import "ContainerBaseController.h"

@implementation ContainerBaseController

#pragma mark -
#pragma mark Overrides
//NS_DEPRECATED_IOS(5_0,6_0)
- (BOOL)automaticallyForwardAppearanceAndRotationMethodsToChildViewControllers{
return NO;
}

//NS_AVAILABLE_IOS(6_0)
- (BOOL)shouldAutomaticallyForwardAppearanceMethods{
    return NO;
}

//NS_AVAILABLE_IOS(6_0)
- (BOOL)shouldAutomaticallyForwardRotationMethods{
return NO;
}

- (void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];

    NSArray *viewControllers = [self childViewControllersWithAppearanceCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        [viewController beginAppearanceTransition:YES animated:animated];
    }
}

- (void)viewDidAppear:(BOOL)animated{
    [super viewDidAppear:animated];

    NSArray *viewControllers = [self childViewControllersWithAppearanceCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        [viewController endAppearanceTransition];
    }
}

- (void)viewWillDisappear:(BOOL)animated{
    [super viewWillDisappear:animated];

    NSArray *viewControllers = [self childViewControllersWithAppearanceCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        [viewController beginAppearanceTransition:NO animated:animated];
    }
}

- (void)viewDidDisappear:(BOOL)animated{
    [super viewDidDisappear:animated];

    NSArray *viewControllers = [self childViewControllersWithAppearanceCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        [viewController endAppearanceTransition];
    }
}


- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration{
    [super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];

    NSArray *viewControllers = [self childViewControllersWithRotationCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        [viewController willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];

}
}

- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration{
    [super willAnimateRotationToInterfaceOrientation:toInterfaceOrientation duration:duration];

    NSArray *viewControllers = [self childViewControllersWithRotationCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
            [viewController willAnimateRotationToInterfaceOrientation:toInterfaceOrientation duration:duration];
    }
}

- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation{
    [super didRotateFromInterfaceOrientation:fromInterfaceOrientation];

    NSArray *viewControllers = [self childViewControllersWithRotationCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        [viewController didRotateFromInterfaceOrientation:fromInterfaceOrientation];
    }
}

/*
 NS_AVAILABLE_IOS(6_0) 
 向下查看和旋转相关的ChildViewController的shouldAutorotate的值
 只有所有相关的子VC都支持Autorotate,才返回YES
 */
- (BOOL)shouldAutorotate{
    NSArray *viewControllers = [self childViewControllersWithRotationCallbackAutoForward];
    BOOL shouldAutorotate = YES;
    for (UIViewController *viewController in viewControllers) {
        shouldAutorotate = shouldAutorotate &&  [viewController shouldAutorotate];
    }

    return shouldAutorotate;
}

/*
 NS_AVAILABLE_IOS(6_0) 
 此方法会在设备旋转且shouldAutorotate返回YES的时候才会被触发
 根据对应的所有支持的取向来决定是否需要旋转
 作为容器,支持的取向还决定于自己的相关子ViewControllers
 */
- (NSUInteger)supportedInterfaceOrientations{
    NSUInteger supportedInterfaceOrientations = UIInterfaceOrientationMaskAll;

    NSArray *viewControllers = [self childViewControllersWithRotationCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
        supportedInterfaceOrientations = supportedInterfaceOrientations & [viewController supportedInterfaceOrientations];
    }

    return supportedInterfaceOrientations;
}


/*
 NS_DEPRECATED_IOS(2_0, 6_0) 6.0以下,设备旋转时,此方法会被调用
 用来决定是否要旋转
 */
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation{
    BOOL shouldAutorotate = YES;
    NSArray *viewControllers = [self childViewControllersWithRotationCallbackAutoForward];
    for (UIViewController *viewController in viewControllers) {
    shouldAutorotate = shouldAutorotate &&  [viewController shouldAutorotateToInterfaceOrientation:toInterfaceOrientation];
    }
    return shouldAutorotate;
}

#pragma mark -
#pragma mark 下面两个方法是在需要的情况下给基类覆盖用的,毕竟不是所有的容器都需要将相关方法传递给所有的childViewControllers
- (NSArray *)childViewControllersWithAppearanceCallbackAutoForward{
    return self.childViewControllers;
}

- (NSArray *)childViewControllersWithRotationCallbackAutoForward{
    return self.childViewControllers;
}

@end

五.创建自己的Container

####设计要点 创建一个Container,首先你得设计好Container View Controller的行为和公开的API,你可以好好参考UIKit中自带的一些Container的设计风格,比如UINaivgationController就是管理着一组Content View Controller的堆栈的Container,且正在显示的是栈顶的View Controller。

主要接口有View Controller的推入,此过程中viewController会和navigationController建立父子关系,并将viewController显示出来,如果animated是YES的话,则会有过场动画:

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated

pop操作,移除栈顶的内容,会解除和navigationController的父子关系:

- (UIViewController *)popViewControllerAnimated:(BOOL)animated;

当然关于pop还有一些其他的便捷接口,这里就不赘述了。

另外需要提供一些快捷的接口方便获取特定的Child View Controller,比如topViewController可以获取栈顶的View Controller。

另外如有必要,Container还需要留有delegate接口,便于通知外面Container的相关行为阶段,便于外部做出相关操作,比如UINaivgationController就会在即将要push一个新的View Controller,已经push了一个新的View Controller等时机留有delegate方法。

还有一个需要考虑的问题就是直接或者间接的Child View Controller如何快速的检索到相应的Container呢?一般Container在实现的时候就需要考虑此问题并提供相应的接口,实现的方法一般就是实现一个UIViewController的Category,比如UINavigationController,在某个View Controller中访问其navigationController属性,会向上遍历,直到找到最近的类型为UINavigationController的祖先,如果找不到则为nil:

@interface UIViewController (UINavigationControllerItem)
...
@property(nonatomic,readonly,retain) UINavigationController *navigationController;

@end

####实现一个简单的模态窗口Container
模态展示 则至少存在present,dismiss的接口,以及获取模态View Controller的属性

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

@interface SimpleModalContainerController : ContainerBaseController

@property (nonatomic, readonly) UIViewController *simpleModalViewController;

- (void)presentSimpleModalViewController:(UIViewController *)viewControllerToPresent
                            animated:(BOOL)animated;

- (void)dismissSimpleModalViewControllerAnimated:(BOOL)animated;

@end

//实现如下
#import "SimpleModalContainerController.h"

@interface SimpleModalContainerController ()
@property (nonatomic, readwrite) UIViewController *simpleModalViewController;
@property (nonatomic, strong) UIButton *backgroundButton;
@end

@implementation SimpleModalContainerController

- (void)buttonTapped:(id)sender{
    [self dismissSimpleModalViewControllerAnimated:YES];
}

- (UIButton *)backgroundButton{
    if (!_backgroundButton) {
        _backgroundButton = [UIButton buttonWithType:UIButtonTypeCustom];
        _backgroundButton.backgroundColor = [UIColor blackColor];
        _backgroundButton.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
        _backgroundButton.alpha = 0.3;
        [_backgroundButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];

    }
    _backgroundButton.frame = self.view.bounds;
    return _backgroundButton;
}

- (void)presentSimpleModalViewController:(UIViewController *)viewControllerToPresent
                            animated:(BOOL)animated{
    if (!self.simpleModalViewController && viewControllerToPresent) {
        self.simpleModalViewController = viewControllerToPresent;
    
        [self addChildViewController:viewControllerToPresent];
    
        [viewControllerToPresent beginAppearanceTransition:YES animated:animated];
    
        [self.view addSubview:self.backgroundButton];
    
        viewControllerToPresent.view.center = CGPointMake(CGRectGetWidth(self.view.bounds) / 2.0, CGRectGetHeight(self.view.bounds) / 2.0);
        [self.view addSubview:viewControllerToPresent.view];
    
        if (animated) {
            viewControllerToPresent.view.alpha = 0;
            self.backgroundButton.alpha = 0;
        
            [UIView animateWithDuration:0.3 animations:^{
                viewControllerToPresent.view.alpha = 1;
                self.backgroundButton.alpha = 0.3;
            } completion:^(BOOL finished) {
                [viewControllerToPresent endAppearanceTransition];
                [viewControllerToPresent didMoveToParentViewController:self];
            }];
        } else {
            self.backgroundButton.alpha = 0.3;
            [viewControllerToPresent endAppearanceTransition];
            [viewControllerToPresent didMoveToParentViewController:self];
        }
    
    }
}

- (void)dismissSimpleModalViewControllerAnimated:(BOOL)animated{
    if (self.simpleModalViewController) {
        [self.simpleModalViewController willMoveToParentViewController:nil];
        [self.simpleModalViewController beginAppearanceTransition:NO animated:animated];
    
        if (animated) {
            [UIView animateWithDuration:0.3 animations:^{
                self.backgroundButton.alpha = 0;
                self.simpleModalViewController.view.alpha = 0 ;
            } completion:^(BOOL finished) {
                [self.backgroundButton removeFromSuperview];
            
                [self.simpleModalViewController.view removeFromSuperview];
                self.simpleModalViewController.view.alpha = 1.0;
                [self.simpleModalViewController endAppearanceTransition];
                [self.simpleModalViewController removeFromParentViewController];
                self.simpleModalViewController = nil;
            }];
        } else {
            [self.backgroundButton removeFromSuperview];

            [self.simpleModalViewController.view removeFromSuperview];
            self.simpleModalViewController.view.alpha = 1.0;
            [self.simpleModalViewController endAppearanceTransition];
            [self.simpleModalViewController removeFromParentViewController];
            self.simpleModalViewController = nil;
        }
    }
}

@end

UIViewController的Category用于Child View Controller 获取上层的SimpleModalContainerController

@interface UIViewController (SimpleModalContainerController)

@property (nonatomic, readonly) SimpleModalContainerController *simpleModalContainerController;

@end

@implementation UIViewController (SimpleModalContainerController)

- (SimpleModalContainerController *)simpleModalContainerController{
    for (UIViewController *viewController = self.parentViewController; viewController != nil; viewController = viewController.parentViewController) {
        if ([viewController isKindOfClass:[SimpleModalContainerController class]]) {
            return (SimpleModalContainerController *)viewController;
        }
    }
    return nil;
}

@end
]]>