书籍推荐《iOS应用逆向工程 第2版》

为大家推荐一本《iOS应用逆向工程 第2版》。作者吴航是我敬重的业界前辈和逆向高手(BTW逆向只是他的业余爱好)。

吴航经常讲的一句话,让我印象非常深刻,大致如下:“App就像一个城市。当你处于一个角落,是无法目睹它的全貌的;而当你从高空俯瞰,一切却又是如此的清晰。” 此书居高建瓴,让你以全新的视角重新审视任何一个App。

跟第一版相比,第二版的内容更加翔实生动。此书从概念、工具、理论及实战四个角度,循序渐进地揭开逆向工程的神秘面纱。我强烈建议所有iOS高级工程师及架构师阅读此书,哪怕只看懂三成,也是极大的收获。此书如同独孤九剑,内功越深厚,则威力越大。

读过此书,在没有目标App的源代码的前提下,下列事情将变为可能:

  • 分析App的架构、引用的第三方库
  • 分析App的关键feature的实现思路及细节
  • 对App进行动态调试;改变二进制文件对App打“补丁”
  • 绕开App Store的限制,在越狱设备上对系统和应用进行定制
  • 对App进行安全审计及安全攻击

Good artist copy, great artist steal. 你值得拥有。

PS: 这篇blog迟到了至少一年半,读第一版的时候就准备写一篇“读后感”了,可见拖延症患者的生活是多少悲惨。


唱吧客户端团队的的Git实践

SVN to Git

写在前面:我对Git的理解实在有限,也无力全方位的比较SVN和Git的优缺点。只是从Git初级使用者的角度,分享一下过去一年使用Git的感受和经验。更希望得到更多的指导和反馈,请各位读者不吝赐教。

唱吧一直使用SVN作为SCM,在经历了几次绝望的merge和混乱的版本迭代后,Git作为更适合我们开发模式的SCM工具开始被提及。

使用Git作为SCM工具基于以下的考虑:

  • SVN真的太慢了,repo太大了,而且越来越糟糕
  • SVN的提交太不灵活,导致很多commit的粒度过大且不明确
  • Branching的成本实在太高,Merge的过程太痛苦
  • Git的配套工具甩开SVN一个段位,至少Mac平台上是这样儿

之前做IndieBros Studio这个二人团队时,由于只有我一个工程师,Git对我来说就是单机游戏。作为推动者自己一定要尽可能把所有的情况和风险考虑清楚,并随时充当救火队员的角色,放好Buff是关键。

我们从SVN切换到Git的过程是这样儿的:

  • 最开始一个月,我自己使用git-svn,单机玩得爽
  • 接着号召大家用git-svn一起玩,基本无效
  • 后来开始威逼利诱manager一起玩,有点效果
  • 一起玩了2个月之后,借着6.0大改版的时机,找了个VM做了个bare repo,把iOS SVN仓库迁移了过去
  • 4个半月后,6.0 release,Android团队进来玩
  • 1个月后,Gitlab的使用提上日程,目前正在进行中

介绍下唱吧客户端团队的branching model:

  • Git-flow的简化版
  • Master分支作发布,每个release打一个tag
  • 日常开发在dev分支进行,
  • 与业务无关的重构和改进单独开feature branch
  • 使用feature branch作code review
  • 线上问题及小版本使用hotfix分支,再merge回master和dev
  • 同一分支内,建议使用git-rebase以保持线性提交历史
  • 使用Gitlab后,保持现有的模型并加大code review的比重;避免folk模型以保持简单

再来看看我们遇到过的问题:

  • 本地repo丢了很多commit(事后发现是rebase掉了),后经过git-reflog找回
  • Branch model使用混乱,导致一个release缺少了一个feature branch的部分commit,重新发版
  • 本地未提交就使用了git checkout .,无力回天
  • 误删掉了所有remote repo,从本地重新push
  • 粒度过大的commit,commit log是Fix bug or Bug fix

下面是我个人使用Git的经验,即使在团队内部也只是作为建议,欢迎大家讨论:

  • 保持较小的提交粒度,一个提交只做一件事情
  • 提交记录一定要明确,避免大量重复及语焉不详
  • 多开本地分支,多使用git stash,开remote branch需要跟大家说明
  • 确保团队所有成员对branch model有相同的理解
  • Merge feature branch时请不要使用fast forward
  • git rebase有助于保持线性的提交历史,建议适当使用
  • 对于未提交的本地commits,可以使用rebase -i重写提交历史,以保证提交合适的提交粒度与清晰的提交记录
  • 永远不要改变已经推送到remote的提交历史,这会给团队造成很大麻烦(不要使用rebase及reset)
  • 慎用git-cherry-pick,但有时这是最好的选择
  • 在本地仓库多尝试,只要提交过的记录,基本就没有任何风险
  • 遇到困难时,man gitgit reflog是好帮手

Git的好处和坏处都是灵活:Git带给我们更灵活更合理的开发模型和节奏,也带来了更高的学习和使用成本。随着时间的推移,好处是递增的,成本是递减的,总体还是获得了很大的效率提升。在我看来,使用Git最重要的就是搞清楚并找到最适合团队的branching model,推荐一个我认为最好的教程:Git Tutorial from Atlassian

各位也来分享一下,你的团队如何使用Git?


为什么唱吧iOS 6.0选择了Mantle

Github for Mac

最近唱吧iOS的6.0版本已经成功上线了。18人月的投入,2500个commit,几十万行的代码修改。唱吧iOS已经从内至外焕然一新,感谢一起并肩作战的小伙伴们。

6.0一个很重大的修改就是基于Mantle重建(新建)了Model层。这里不对Mantle作更多介绍,只分享一下使用Mantle的决策及执行过程。

我们遇到的问题

唱吧是一款上线2年多的App,产品形态的演进和迭代非常快。因此不可避免的遗留了各种问题:

  • Model层不健全,没有统一的结构,不同工程师做法差异很大;多数是哑类型,且没有统一的序列化机制
  • 业务逻辑冗余、分散、不一致
  • 模块划分随意,依赖关系混乱,维护困难
  • NSDictionary作为承载业务的数据类型在各处出现(sqlite, Model object, API, Notification, web, OpenURL etc.),参数和值的正确性完全没有编译器检查,字符串很容易写错,风险延后至运行时,易产生低级bug
  • 基本没有文档和注释(结合上一点,不挂debugger很难读懂代码)
  • 几百个API,业务复杂,变动快,重构难;同一个API请求可能有重复和不一致
  • API的一些参数和返回值,同一个参数/返回值可能存在类型差异;由于API需要向前兼容,修改API有成本

除此之外,还有其他工程上的约束:

  • 不能影响现有的API,所有的事情只限于iOS端的修改
  • 代码即文档,因为没有精力维护文档
  • 对不同Model的持久化方式作迁移
  • 避免写大段枯燥的Model的序列化/反序列化代码
  • 没有时间造出足够成熟、健壮可重用的组件及撰写文档

上述的问题都是长期存在且需要解决的,否则严重影响开发效率及代码质量。11年的时候我还在做社交游戏的时候,设计并实现了一套简单的基于Objective-C Runtime的数值表Model结构及转换工具(Model<=>csv)供数值策划使用。但想写出一套成熟的方案还是有一些距离,而且也没有资源和时间作维护、测试和文档。

顺着这个思路找到了JSONModelMantle,前者刚刚1.0,后者在Github for Mac中广泛使用且社区更成熟(甚至Slack上有channel),所以成为了更好的选择。

事实也证明这个选择是对的,6.0上线后,crash率比之前的版本有显示的降低,并且Mantle相关的crash占总crash的比率不到3%,大可以直接用在大型的产品上。

除了成熟稳定,Mantle基本解决了我们遇到了的所有问题。下面具体介绍一些通用性Mantle使用经验,基本的使用方法请直接移步Mantle的README

Property名称转换

由于API使用的开发语言与iOS所使用的Objective-C是截然不同的,所以可能将一些保留关键字作为property的名称(如id),或者不小心override掉基类的属性(如description)。还有可能API中使用了一个很糟糕的名称,或者使用了不符合Objective-C命名规范的名称,这些我们都需要作转换。

只需要实现MTLJSONSerializing protocol并在+JSONKeyPathsByPropertyKey方法中定义好新旧名称的映射关系即可,Mantle会在序列化及反序列化时对属性名进行自动的转换。

1
2
3
4
5
6
7
8
9
+ (NSDictionary *)JSONKeyPathsByPropertyKey {
return @{
@"identifier": @"id",
@"displayDiscription": @"description",
@"thisIsANewShit": @"newShit",
@"creativeProduct": @"copyToChina",
@"betterPropertyName": @"m_wired_propertyName"
}
}

好了很多吧?没错,只需要定义一次名称的映射关系就可以了,Mantle负责model与JSON之间的双向转换。不需要将这种逻辑写得到处都是,并且还得维护它的一致性。

Property的类型映射

iOS中处理URL使用的是NSURL类型,但JSON只支持基本的字符串,Mantle可以自动帮你转换成NSURL。

1
2
3
+ (NSValueTransformer *)URLJSONTransformer {
return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

NSValueTransformer负责在不同类型间进行双向转换,请读者研究一下Mantle的实现方式。在此前提下,留给读者一个问题(其实这是一个真实的故事,类似的故事还有很多,详见iOS应用开发之十大坑队友):

假设我们有一个entity,名字且叫KTVConcreteEntity吧,它有一个属性名字叫entityID,类型是NSInteger。问题来了,entityID可能在另外一个API的response中是字符串类型,在不直接修改Mantle的源码的前提下怎么搞?欢迎在下方留言讨论。

空标量异常

有的时候API的response会有空值,比如copyToChina可能不是每次都有的,JSON是这样儿的:

1
2
3
{
"copyToChina": null
}

Mantle在这种情况会将newShit转换为nil,但如果是标量如NSInteger怎么办?KVC会直接raise NSInvalidArgumentException

Mantle是基于KVC给property赋值的,KVC提供了- (void)setNilValueForKey:(NSString *)key方法,让我们为nil指定一个合理的替代值,我们来看一下此方法的解释:

Invoked by setValue:forKey: when it’s given a nil value for a scalar value (such as an int or float).
Subclasses can override this method to handle the request in some other way, such as by substituting 0 or a sentinel value for nil and invoking setValue:forKey: again or setting the variable directly. The default implementation raises an NSInvalidArgumentException.

对于标量来讲,多数情况下合理的值即为0,我们来看下代码:

1
2
3
4
5
6
7
8
@interface MTLModel (KTVNullableScalar)
@end
@implementation MTLModel (KTVNullableScalar)
- (void)setNilValueForKey:(NSString *)key {
[self setValue:@0 forKey:key]; // For NSInteger/CGFloat/BOOL
}
@end

问题完美解决,再也不需要到处写无聊的if/else了。

其它重要特性

Mantle为我们带来的方便不胜枚举:

  • 实现了NSCopying protocol,子类可以直接copy是多么爽的事情
  • 实现了NSCoding protocol,跟NSUserDefaults说拜拜
  • 提供了-isEqual:-hash的默认实现,model作NSDictionary的key方便了许多
  • 简单且把一件事情做好,不掺杂网络相关的操作

如此强大优雅的设计,让我不得不向Github的工程师们致敬!

写在后面

篇幅所限,只介绍了几个典型的问题,欢迎大家讨论。但如果你的App的代码规模只有几万行,或者API只有十几个,或者没有遇到我们这些遗留问题,我建议还是不要引入了,杀鸡用指甲刀就够了,杀不动多磨磨找准要害。Anyway,Mantle的实现和思路是值得每位iOS工程师学习和借鉴的。

附小广告一则:唱吧iOS团队诚招iOS工程师,推荐成功即奖励6000元现金或iPhone 6一部,详见这篇blog


基于Core Animation的KTV歌词视图的平滑实现

KTV歌词视图,只要去过KTV的的朋友一定不会陌生。我们先来看一下最终的效果,再一步步说明唱吧歌词视图的演进。想把事件事情说得清清楚楚的确很难,有很多tricky的地方;另外毕竟不是open source的,只能给大家挑重点分享一下实现的过程和思路。

唱吧6.0歌词视图

歌词视图剖析

一个体验良好的歌词视图,由以下方面组成,这也是我们的设计目标:

  • 有倒计时功能,歌者可以提前作演唱的准备
  • 根据场景的不同,支持多行或者双行显示,为歌者提供演唱的上下文
  • 歌者清晰的了解当前在唱哪一句歌词,我称之为焦点行
  • 焦点行需要染色,并需要精准地作逐字渲染
  • 两句之前使用适当的动画换行过渡
  • 歌词动画平滑不突兀,适应不同节奏的歌曲
  • 根据产品和设计师的要求,灵活地对歌词视图进行字体、颜色调整(1/3/5是绿色,2/4/6是红色,阴历节日是黄色,I’m serious and it’s safe to forget Sunday! Cheers!)

此外我们还需要了解一下歌词信息的结构,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
@interface Line : NSObject
@property (nonatomic, strong) NSArray *words;
@property (nonatomic, strong) NSString *text;
@property CGFloat start;
@property CGFloat length;
@end
@interface Word : NSObject
@property (nonatomic, strong) NSString *text;
@property CGFloat start;
@property CGFloat length;
@end
  • 一首歌的歌词我们称之为Lyrics
  • Lyrics包含多行,每行我们称之为Line; Line有它的start及length,分别代表时间戳以及长度
  • Line包含多个字,每个字我们称之为Word; Word也有它的start以及length,分别代表时间戳以及长度

了解完这些我们看看如何来渲染焦点行歌词,先看简单直接的方式。

基于Core Graphics的实现

我们知道歌曲的开始时间,也有歌词数据提供时间支持,那么就可以计算出当前歌词视图的状态。对于歌词的焦点行,有两部分状态:

  • 歌者已经演唱的部分,渲染成绿色
  • 歌者待演唱的部分,渲染成白色

我们省略计算的过程,假设已经得出绿色、白色歌词的rect及point,就可以直接渲染了:

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
@implementation LyricsView
- (void)drawRect:(CGRect)rect {
// Assume we calcuated them before
CGRect greenRect;
CGRect whiteRect
CGPoint greenPoint;
CGPoint whitePoint;
// We have the focus line and font
Line *line;
UIFont *font;
// Render focus line text
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);
CGContextClipToRect(context, greenRect);
[[UIColor greenColor] set];
[line.text drawAtPoint:greenPoint withFont:font];
CGContextRestoreGState(context);
CGContextSaveGState(context);
CGContextClipToRect(context, whiteRect);
[[UIColor whiteColor] set];
[line.text drawAtPoint:whitePoint withFont:font];
CGContextRestoreGState(context);
}
@end

本质上我们使用了NSString的UIStringDrawing Category搞定了这个事情。既然我们解决了任一时间点的状态,那么把它动起来也很容易:

  • 将这段code snippet放到LyricsView的drawRect中
  • 以60 FPS的频率调用[lyricsView setNeedsDisplay]

一切看起来很直观,但问题来了,这个歌词视图根本跑不到60 FPS(我保证这个效果看起来像癫痫一样儿,v4.9之前就是一直这么癫过来的),即使在目前性能最强的iPhone 5S上。我们来分析一下原因:

  • Core Graphics使用CPU作渲染
  • 这个界面是CPU intensive,需要播放伴奏,还需要录制歌者的声音,甚至需要给声音加“滤镜”
  • 还有对歌者进行实时打分的task及动画
  • 回望过去5年iPhone的硬件发展,GPU的提升也远高于CPU,不能指望短期设备升级解决这个问题

5S上毕竟还可以跑到50FPS,但低端设备的FPS对我来讲是实在是没法接受的。唱吧是线上KTV的应用的用户体验标准,不解决这个问题是说不过去的。既然CPU不给力,那么我们让GPU来做这件事情。

基于Core Animation的实现

14年初的时候,Facebook open source了惊艳的Shimmer。由于跟我设想的实现机制是相同的,直接拖了几百个shimmer view作了一下profile,在4S上都可以达到完美的60FPS。

让我们先理一下思路,看看基于Core Animation的焦点行的视图结构:

1
2
- GreenLineLabel: UILabel
- WhiteLineLabel: UILabel

没错,就是简单的把绿色的UILabel置于白色的之上,剩下的问题就是如何控制绿色的UILabel按我们的时间控制进行部分渲染。

部分渲染就是加一个mask,我们来看一下CALayer的mask property:

1
2
3
4
5
6
7
8
9
10
11
@interface CALayer : NSObject <NSCoding, CAMediaTiming>
/* A layer whose alpha channel is used as a mask to select between the
* layer's background and the result of compositing the layer's
* contents with its filtered background. Defaults to nil. When used as
* a mask the layer's `compositingFilter' and `backgroundFilters'
* properties are ignored. When setting the mask to a new layer, the
* new layer must have a nil superlayer, otherwise the behavior is
* undefined. Nested masks (mask layers with their own masks) are
* unsupported. */
@property(strong) CALayer *mask;
@end

我们可以知道,mask layer的alpha用来与CALayer的content进行alpha blending,如果alpha为1则content显示,反之不显示。受Shimmer的启发,我们可以对mask作动画,让它从左到右移动到绿色歌词的layer上,并最终与之重合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@interface GreenLineLabel: UILabel
@end
@implementation GreenLineLabel {
CALayer *_maskLayer;
}
- (instance)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_maskLayer = [CALayer layer];
_maskLayer.backgroundColor = [[UIColor whiteColor] CGColor]; // Any color, only alpha channel matters
_maskLayer.anchorPoint = CGPointZero;
_maskLayer.frame = CGRectOffset(self.frame, -CGRectGetWidth(self.frame), 0);
self.layer.mask = _maskLayer;
self.backgroundColor = [UIColor clearColor];
}
return self;
}

上面这段代码我们将_maskLayer的anchorPoint设置为CGPointZero,便于后面的动画计算坐标。

下面我们对_maskLayer的position作CAKeyframeAnimation动画,根据歌词数据我们可以算出每个字渲染的时间(keyTimes)和动画总时长(duration)。假设每个字是等宽的,我们可以算出_maskLayer在每一个keyTime的position,也就是values。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)startAnimation {
// Assume we calculated keyTimes and values
NSMutableArray *keyTimes;
NSMutableArray *values;
CGFloat duration;
CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
animation.keyTimes = keyTimes;
animation.values = values;
animation.duration = duration;
animation.calculationMode = kCAAnimationLinear;
animation.fillMode = kCAFillModeForwards;
animation.removedOnCompletion = NO;
[_maskLayer addAnimation:animation forKey:@"MaskAnimation"];
}

至此我们完成了基于Core Animation的歌词焦点行染色动画。

写在后面

很抱歉我提供的code snippet不是production ready,歌词动画是一个非常复杂的系统,很难单独抽离出来介绍给大家,所以只能管窥一豹地介绍下。

附小广告一则:唱吧iOS团队诚招iOS工程师,推荐成功即奖励6000元现金或iPhone 6一部,详见这篇blog