浅析支付宝钱包插件

转自 Allen

昨天看到唐巧分析了支付宝钱包插件的实现原理,今天也趁热打铁研究了一下支付宝插件的结构和代码,很多时候逆向思考也可以为自己积累很多有用的经验(即便实际实现方式和自己所想有出入)。

承接唐巧的上文,本文同样以彩票插件为例,如果你没有下载该插件的压缩包,用如下命令下载。

wget http://download.alipay.com/mobilecsprod/alipay.mobile/20130601021432806/xlarge/10000011.amr

并改为 zip 后缀,解压。

根目录根目录

  • CERT:内容为各文件的特征值,特征值可以是重复 hash 和 salt 多次后 base64 的结果,解压插件后支付宝理应检查文件特征值以确定来源可靠
  • Manifest.xml:插件的描述文件,都有注释,易懂,其中定义了业务包 ID、名称、介绍、版本等信息,也定义了版本兼容性、能力集、依赖关系、入口文件等

resres

  • res:资源文件,仅有图标,奇怪的是代表 Android 尺寸的目录下没有文件,可能因为唐巧抓的包是 iOS 的?

wwwwww

  • www:前端代码,目录结构跟一般 Web 开发相似,其中 demo 下是 HTML 代码

demodemo

  • demo:HTML 代码,子目录都是拼音缩写,比如 ssq 代表双色球,根据 Manifest.xml 描述,index-alipay-native.html 为插件入口页面

cpcp

唐巧打开 index-alipay-native.html 给大家看过了,你会发现 Cells 都是不能点的,因为页面尚未初始化,代码中可以看到形似 <a rel="ssq/ssqbet.html"> 的代码。显然这代表双色球的子页面,我简单猜测一下用 rel 的原因:

首先,点击 Cell 在客户端触发的必然是 Native 的 pushViewController:,用 href 同样可以通过webView:shouldStartLoadWithRequest:navigationType: 触发,但这样会在某些意外情况下造成点击后页面直接跳转。此外如果最终是用形如 push://10000011/ssq/ssqbet.html 的协议来实现 Native 跳转,用 rel 再注入 JS 处理可以防止客户端逻辑的显式暴露(如长按、拷贝,曾经的新浪微博客户端长按评论可以看到类似 comment:// 的协议,不见得有多大危害,但是值得避免)

页面底部有如下代码:

1
2
3
4
5
6
7
8
9
10
11
    //配置时间戳,解决浏览器或者webview cache问题
    seajs.config({
        map: [
            [ /^(.*\.(?:css|js))(.*)$/i, '$1?000021']
        ]
    });
  function onDeviceReady(){
      seajs.use('../js/appnav/nav',function(Nav){
          Nav.initialize();
      });
  }

略懂前端开发的话,果断猜测 onDeviceReady() 是在页面加载完毕后,由设备调用的初始化方法,于是尝试在 Console 中输入 onDeviceReady() 执行,点击 Cell,报错如下:

onDeviceReadyonDeviceReady

跟到 nav.js 里可以看到是在 pushWindow 中报错的,错误为 alipay 未定义,粗略判断 alipay 为客户端注入的变量,这里看到的调用形如 alipay.navigation.pushWindow(obj.attr('rel')) 用到了上述 rel 属性,最终应该会触发客户端类似 [self pushWebControllerWithURL:url] 的代码来推入下一个 VC,这位我们最终尝试实现能跑这个插件的 Demo 提供了一些思路。

用 onDeviceReady 可以完成大部分页面的初始化工作,如 ssq/ssqbet.html,在浏览器里也能响应下拉机选操作,你会发现,这些插件的屏幕尺寸兼容性和浏览器兼容性极佳:

ssqbetssqbet

再如 jczq/jczqmatch.html,甚至可以读到场次数据并完成数据初始化了

jczqjczq

在这里我们又发现了两个有意思的信息,它们被写在 meta 信息里,根据字面,很容易理解,它们分别定义了navigationBar 的 title 和 rightBarButtonItem,以及点击 rightBarButtonItem 触发的 JS 函数。此时你如果比对着使用客户端就知道这个筛选对应着怎样的表现和交互,这些都为我们写 Demo 提供了线索。

尝试在 Console 输入 rightBar(不带括号)查看其定义,又能得到很多有用的线索,比如点击rightBarButtonItem 触发的是推入 filterpage,这个 filterpage 则是在 js/jczq/jczqmatch.js 中定义的。

jczqjczq

此外,结合 jczqfilter.html 的内容和 rightBar 函数,可以看到主页面利用 HTML5 的 localStorage 为 filter 页面提供了数据(这为纠结于 UIWebView 如何给 push 进来的下一个 View 提供数据的我,提供了很好的思路,值得借鉴)

1
    localStorage.setItem('__tbcp__filter', JSON.stringify(obj))

不知不觉写了 2 个多小时了,发现通过文字表达出来要比分析本身还费时。不过,至此,支付宝插件的大体框架已经比较清晰了,和 Native Code 的交互方式、数据传递方式也有了一定的思路。

接下来的几天我会尝试写一个 Demo 让这样一个插件基本能跑起来(除了核心的下单、支付环节,这显然不是我力所能及的)。跟着我的分析,相信读者对 WebView + Native Code 的混合编程模式应该也有了一些新的认识。

希望我有足够的动力和时间写 Demo,因为这个 Demo 可以帮助大家更清楚地理解支付宝插件中用到的 WebView + Native Code 的 Hybrid 开发方式。

 

一周前简单分析了一下支付宝钱包插件的结构,得到了很多朋友的支持转发。一来纸上得来终觉浅,二来上篇 Blog 里也立言要写个 Demo,就花了点时间更深入地研究了一下支付宝钱包的插件。研究之后发现,这篇文章很可能会变成关于“如何分析一个 App 的实现方式”和“如何写 PhoenGap Plugin”的教程,结果不一定对开发有帮助,但是分析的过程可以帮到一些刚接触 iOS 开发的朋友了解如何逆向思考一些优秀 App 的实现方式,下面基本上赤裸裸地记录了我整个思考和分析的过程,会显得有些啰嗦。

如果你对下文没兴趣,可以直接去 github 下 Demo 看,其实很简单,实际内容不超过 200 行代码。

https://github.com/allenhsu/PortalDemo

PLNavigationPLNavigation

class-dump

略有 obj-c 开发经验的同学应该都知道利器 class-dump。因为 obj-c 的动态特性导致 obj-c 的二进制代码中会保留类名和方法名,所以可以用 otool 得到这些信息,而 class-dump 所做的就是把 otool -ov 得到的信息组织成结构更清晰的信息输出,比 otool 的输出更易读。class-dump 在我的工作中为我带来很多便利,通过查看类的定义,可以帮助我了解一个好的 App 的架构和部分逻辑的实现思路。

首先,从任意网站得到支付宝钱包的 .ipa 文件,改后缀为 .zip,解压得到 Portal.app(或者直接从机器里得到 Portal.app),右键 Show Package Content,其中最大的 Portal 就是二进制文件,可以从 app 目录拷贝到一个干净的目录。然后我一般会分别执行以下两条命令:

class-dump Portal > class-dump.txt
class-dump -H -o ./ Portal

其中第一行把所有 dump 的信息输出到一个文件,方便 ctrl-f,第二行则把所有信息以头文件的形式输出到当前目录,每个类一个文件。

上篇文章提到过点击链接时,console 中提示未定义的方法是 alipay.navigation.pushWindow,所以直接在 class-dump.txt 中尝试搜索了 pushWindow,找到两个相关类:HtmlViewController 和PLNavigationHtmlViewController 是内嵌 Web 的 VC,其中还包含了一个 CDVViewController的变量,而 PLNavigation 则继承自 CDVPlugin,虽然没实际用过 PhoneGap,但这些信息足以说明支付宝钱包的插件是基于 PhoneGap 实现 JS 和 Native 代码通信的(huangzhi 也在给我的留言中提到了这点)

PLNavigationPLNavigation

注:方法的变量可以适当修改为合适的类型和名称,二进制代码不保留变量类型和名称。

PhoneGap

于是对 PhoneGap 做了一些学习(此处略去学习过程),果断在支付宝的 Package 里搜索 cordova,俩文件,Cordova.plist 和 cordovaios.txt,看起来有用就 cp 出来。

先下载了最新的 PhoneGap 2.9.0,发现升级文档中提到了 2.7.0 中 Cordova.plist 变成了config.xml,回头扫了一眼 cordovaios.txt,所幸没有加混淆,这就是 cordova.js,虽去掉了大部分版本信息,但是看到了类似 TODO: remove in 2.0. 的注释,所以用的是 1.x 版本,通过 JS 文件的比对和升级文档的提示,确定 cordovaios.txt 来自 1.6.1 版本的 PhoneGap。(注:PhoneGap 1.6.1 不支持 ARC)

在 cordovaios.txt 的尾部我们也看到了 alipay 的定义,Native 暴露给 JS 的方法一览无余(未混淆)。

Cordova.plist 则是 PhoneGap 的配置文件,其中也包含所有 Plugin 的映射关系,比如NavigationClass => PLNavigation,配合刚才 dump 出来的头文件和 alipay 的定义,思路涌上心头。

cordovaios.txtcordovaios.txt

Demo

https://github.com/allenhsu/PortalDemo

具体的实现看 github 上的代码,下面我简单提几个实现过程中遇到的问题、思考过程和解决方法。

根据 dump 到的头文件,我简单实现了一个 HtmlViewController 和 PLNavigation 插件的 - (void)pushWindow:(NSMutableArray*)arguments withDict:(NSMutableDictionary*)options 方法,后来发现,这个方法很有用,有它就基本能跑了。

问题一:如何注入 JS,如何触发 onDeviceReady()

上文提到过初始化过程由 onDeviceReady() 触发,当然你可以直接 eval 这个方法,但看过 PhoneGap 的 Demo 得知这样不专业专业的做法是:

1
2
3
document.addEventListener("deviceready", onDeviceReady, false);
function onDeviceReady() {
}

所以我在 HtmlViewController 的 - (void) webViewDidFinishLoad:(UIWebView*) theWebView加入了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void) webViewDidFinishLoad:(UIWebView*) theWebView
{
    static NSString* jsString = nil;
    if (!jsString) {
        NSString *jsFile = [[NSBundle mainBundle] pathForResource:@"cordovaios" ofType:@"txt"];
        jsString = [[NSString alloc] initWithContentsOfFile:jsFile encoding:NSUTF8StringEncoding error:NULL];
    }
    [theWebView stringByEvaluatingJavaScriptFromString:jsString];
    [theWebView stringByEvaluatingJavaScriptFromString:@"document.addEventListener(\"deviceready\", onDeviceReady, false)"];
    [self extractMetaInfoFromWebView:theWebView];
    return [super webViewDidFinishLoad:theWebView];
}

先注入 cordovaios.txt 的内容,然后添加 ondeviceready 的事件监听,这些事情要在 call super 之前,这样才能响应到 super 触发的事件。

问题二:如何提取页面标题和 rightBarButtonItem

这里有两种方法,可以由前端 JS 的初始化函数触发 Plugin 来修改标题和 rightBarButtonItem,也可以在webViewDidFinishLoad 时主动提取,方便起见我选择了后者,问题一中引用的extractMetaInfoFromWebView 就是简单地从页面中用 JS 提取内容,他的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)extractMetaInfoFromWebView:(UIWebView *)theWebView
{
    self.title = [theWebView stringByEvaluatingJavaScriptFromString:@"document.title"];
    NSString *jsString = [self jsToGetContentOfMetaNamed:@"right-bar-item"];
    NSString *rightBarItemMeta = [theWebView stringByEvaluatingJavaScriptFromString:jsString];
    if (rightBarItemMeta.length > 0) {
        self.rightItemDict = [self dictionaryFromMetaString:rightBarItemMeta];
        if ([self.rightItemDict objectForKey:@"title"]) {
            NSString *title = [self.rightItemDict objectForKey:@"title"];
            self.storeRightItem = [[[UIBarButtonItem alloc] initWithTitle:title style:UIBarButtonItemStyleBordered target:self action:@selector(didClickOnRightBarItem:)] autorelease];
            [self.navigationItem setRightBarButtonItem:self.storeRightItem animated:YES];
        }
    }
}

其中大部分是前端知识,例如 document.title 取 title,jsToGetContentOfMetaNamed 中的@"$(\"meta[name='%@']\").attr('content')" 则是用按 name 取 meta 信息,取到后我直接用变量保存了这些信息,也可以考虑用 block。当点击 rightBarButtonItem 时触发didClickOnRightBarItem 再根据之前提取的 onclick 信息去 webView 里 eval(这个函数虽然是可定义的,但大部分页面里是 rightBar(),可以在 console 中查看相关定义)。

问题三:如何处理相对路径

pushWindow 传入的路径是相对路径,例如 zqdc 子目录下 zqdcmatch.html 中 pushzqdcfilter.html 传入的是 zqdcfilter.html 不带目录,所以要在 pushWindow 方法中处理相对路径:

1
NSString *startPage = [[currentPage stringByDeletingLastPathComponent] stringByAppendingPathComponent:[arguments objectAtIndex:1]];

问题四:如何触发从 filter 页面确定的事件

实现以上功能后发现,足球单场的过滤页面可以推入和选择,但是返回后没有触发列表内容更新,查看zqdcfilter.html 页面的 rightBar 看到有localStorage.setItem('__tbcp__filter__change', 'true');,顺藤摸瓜找到了 match.js 中响应 frompop 事件的时候用到了这个变量。frompop 则是支付宝在 cordovaios.txt 中自定义的事件。

rightBarrightBar

frompopfrompop

我比较弱地如下处理:

1
2
3
4
5
6
7
8
9
- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    if (firstTime) {
        firstTime = NO;
    } else {
        [self.webView stringByEvaluatingJavaScriptFromString:@"(function() { var channel = cordova.require(\"cordova/channel\"); channel.onPop.fire(); }())"];
    }
}

依然是一些前端知识,用闭包是为了减少变量冲突。

其他

你可以根据 dump 的头文件和 Cordova.plist 实现一些其他的插件,比如 WebAppContext 可以用来做登录(我简单写了一下)。

总结

至此,Demo 基本完成,授人以鱼不如授人以渔,所以我终点记录了过程而非结果,希望能给到大家帮助。

此外,我只是简单的实现了一些最基础的方法,但不足以展现支付宝插件架构的全貌,也不一定是真正的实现方式,比如我简化了 HtmlViewController 直接继承自 CDVViewController,而不是包含关系。通过 dump 到的信息你能得到更多,有很多问题值得更深入的思考,比如 WebappRuntime 的作用,BundleLoader 的使用,HtmlViewController 和 CDVViewController 的包含关系等。如果你有更深入的研究,可以留言或 @许小帅_allen 一起分享。

转载请保留原文链接和作者信息,谢谢。

posted on 2013-06-27 09:54  一梦浮生2012  阅读(1690)  评论(0编辑  收藏  举报