美文网首页iOS学习搜集
iOS【JSPatch热更新】实践一

iOS【JSPatch热更新】实践一

作者: NJ_墨 | 来源:发表于2017-02-25 23:34 被阅读99次

    JSPatch 是一个开源项目(Github链接),只需要在项目里引入极小的引擎文件,
    使用JavaScript调用任何Objective-C的原生接口,替换任意 Objective-C 原生方法。
    目前主要用于下发 JS 脚本替换原生 Objective-C 代码,实时修复线上 bug。

    例如线上 APP 有一段代码出现 bug 导致 crash:
    
    //OC FFEasyLifeHomeCtrl
    self.nameArrays = @[@"0",@"1",@"2",@"3",@"4"];
    ...
    - (void)testArray {//测试数组越界
        NSString *testName = self.nameArrays[5];
    }
    
    //JS
    defineClass('FFEasyLifeHomeCtrl',['data','nameArray','totalCount'], {
            testArray: function() {//修改数组越界
                var testName = self.nameArrays([4]);
           }
    });
    
    除了修复 bug,JSPatch也可以用于动态运营,实时修改线上APP行为,
    或动态添加功能。JSPatch 详细使用文档见 [Github Wiki](https://github.com/bang590/JSPatch/wiki)。
    

    JSPatch优势:

    1、JSPatch更符合Apple的规则。iOS Developer Program License Agreement里3.3.2提到不可动态下发可执行代码,但通过苹果JavaScriptCore.framework或WebKit执行的代码除外,JS正是通过JavaScriptCore.framework执行的

    2、使用系统内置的JavaScriptCore.framework,无需内嵌脚本引擎,体积小巧

    3、支持block

    JSPatch缺点:

    1、JSPatch劣势在于不支持iOS6,因为需要引入JavaScriptCore.framework

    2、持续改进中存在风险:JSPatch让脚本语言获得调用所有原生OC方法的能力,不像web前端把能力局限在浏览器,使用上会有一些安全风险

    3、若在网络传输过程中下发明文JS,可能会被中间人篡改JS脚本,执行任意方法,盗取APP里的相关信息,危及用户信息和APP

    4、若下载完后的JS保存在本地没有加密,在越狱的机器上用户也可以手动替换或篡改脚本

    JSPatch 风险

    1、JSPatch脚本的执行权限很高,若在传输过程中被中间人篡改,会带来很大的安全问题,为了防止这种情况出现,在传输过程中对JS文件进行了RSA签名加密,流程如下:
    服务端:计算JS文件MD5值。用RSA私钥对MD5值进行加密,与JS文件一起下发给客户端。
    客户端:拿到加密数据,用RSA公钥解密出MD5值。本地计算返回的JS文件MD5值。对比上述的两个MD5值,若相等则校验通过,取JS文件保存到本地。
    由于RSA是非对称加密,在没有私钥的情况下第三方无法加密对应的MD5值,也就无法伪造JS文件,杜绝了JS文件在传输过程被篡改的可能。

    2、本地存储
    本地存储的脚本被篡改的机会小很多,只在越狱机器上有点风险,对此JSPatch SDK在下载完脚本保存到本地时也进行了简单的对称加密,每次读取时解密。

    更新能力

    React Native 和 JSPatch 都能对用其开发出来的功能模块进行热更新,这也是这种方案最大的好处。

    React Native: 在热更新时无法使用事先没有做过桥接的原生组件,例如需要加一个发送短信功能,需要用到原生 MessageUI.framework 的接口,若没有在编译时加上提供给 JavaScript 的接口,是无法调用到的。

    JSPatch: 可以调用到任意已在项目里的组件,以及任意原生 framework 接口,不需要事先做桥接,在热更新的能力上,相对来说 JSPatch 的能力和自由度会更高一些。

    性能体验

    JSPatch 的性能问题主要在于 JavaScript 和 Objective-C 的通信,每次调用 Objective-C 方法都要通过 Objective-C Runtime 接口,并进行参数转换。
    runtime 接口调用带来的耗时一般不会成为瓶颈,参数转换则需要注意避免在 JavaScript 和 Objective-C 之间传递大的数据集合对象。
    JSPatch 在性能方面也针对开发功能做了不少优化,尽力减少了 JavaScript 和 Objective-C 的通信,来看并没有碰到太多性能问题。

    集成JSPatch过程——>SDK接入

    第一步 获得 AppKey
    
    在平台上注册帐号,可以任意添加新 App,每一个 App都有一个唯一的 AppKey 作为标识。
    网站:http://jspatch.com/Apps
    
    第二步 集成SDK
    
    通过 cocoapods 集成
    
    在 podfile 中添加命令:
    pod 'JSPatchPlatform'
    再执行 pod install 即可。
    
    手动集成
    若没有使用 cocoapods,也可以手动集成。下载 SDK 后解压,将 JSPatchPlatform.framework 拖入项目中,
    勾选 "Copy items if needed",并确保 "Add to target" 勾选了相应的 target。
    
    添加依赖框架:TARGETS -> Build Phases -> Link Binary With Libraries -> + 添加 libz.dylib 和 JavaScriptCore.framework。
    
    注意:手动集成无法断点调试 JSPatch 核心源码,推荐使用 cocoapods 方式集成。
    
    第三步 运行
    在 AppDelegate.m 里载入文件,并调用 +startWithAppKey: 方法,参数为第一步获得的 AppKey。接着调用 +sync 方法检查更新。
    
    例子:
    #import <JSPatchPlatform/JSPatch.h>
    
    @implementation AppDelegate
    
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        [JSPatch startWithAppKey:@"你的AppKey"];
        [JSPatch sync];
        ...
    }
    @end
    至此 JSPatch 接入完毕,下一步可以开始在后台为这个 App 添加 JS 补丁文件了。
    
    上述例子是把 JSPatch 同步放在 -application:didFinishLaunchingWithOptions: 里,
    若希望补丁能及时推送,可以把 [JSPatch sync] 放在 -applicationDidBecomeActive: 里,每次唤醒都能同步更新 JSPatch 补丁,不需要等用户下次启动。
    

    项目结构

    • 图片 1.png

    本地创建main.js
    终端创建JS文件命令:touch test.js

    项目代码

    #import "AppDelegate.h"
    #import <JSPatchPlatform/JSPatch.h>
    
    @interface AppDelegate ()
    @end
    
    @implementation AppDelegate
    
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
        //[self hotJSPatch];
        //本地测试
       [self hotLocalJSPatch];
    
        return YES;
    }
    
    - (void)hotJSPatch {
    
        //传入在平台申请的 appKey。会自动执行已下载到本地的 patch 脚本。
        [JSPatch startWithAppKey:@"c38c725a42102b45"];
    
    
        /*
         定义用户属性
         用于条件下发,例如:
         [JSPatch setupUserData:@{@"userId": @"100867", @"location": @"guangdong"}];
         在 `+sync:` 之前调用
         */
        //[JSPatch setupUserData:@{@"userId": @"1000876", @"isMale": @(1)}];
    
        #ifdef DEBUG
        //进入开发模式
        [JSPatch setupDevelopment];  
        #endif
    
         //与 JSPatch 平台后台同步,发请求询问后台是否有 patch 更新,如果有更新会自动下载并执行可调用多次(App启动时调用或App唤醒时调)
        [JSPatch sync];
    
        //在状态栏显示调试按钮,点击可以看到所有 JSPatch 相关的 log 和内容
    
        [JSPatch showDebugView];
    }
    
    - (void) hotLocalJSPatch {
    
        //用于发布前测试脚本
        //测试完成后请删除,改为调用 +startWithAppKey: 和 +sync
    
        //加载本地js调试
        [JSPatch testScriptInBundle];
    
        //在状态栏显示调试按钮,点击可以看到所有 JSPatch 相关的 log 和内容
        [JSPatch showDebugView];
    
    }
    
    FFEasyLifeHomeCtrl.m
    ====================
    1. 数组越界;
    2. 未实现按钮事件方法
    ====================
    
    #import "FFEasyLifeHomeCtrl.h"
    
    @interface FFEasyLifeHomeCtrl ()
    @property (nonatomic, strong) NSArray      *nameArrays;
    @property (nonatomic, strong) UIButton     *catButton;
    
    @end
    
    @implementation FFEasyLifeHomeCtrl
    - (void)viewDidLoad {
        [super viewDidLoad];
    
        [self.view addSubview:self.catButton];
        [self testArray];
    
    }
    
    // MARK: - 方法
    - (void)testArray {//数组越界
        NSString *testName = self.nameArrays[5];
    }
    
    // MARK: - getter
    - (UIButton *)catButton {
        if (!_catButton) {
            _catButton = [UIButton buttonWithType:UIButtonTypeCustom];
            _catButton.backgroundColor = k_COLORRANDOM;
            [_catButton setTitle:@"美图" forState:UIControlStateNormal];
    
            /** 未实现事件方法 */
            [_catButton addTarget:self action:@selector(actionPicture:) forControlEvents:UIControlEventTouchUpInside];
       }
        return _catButton;
    }
    

    JS代码

    main.js
    ====================
    1.处理数组越界问题;
    2.添加按钮事件方法;
    3.跳转到一个新控制器(用js创建的新控制器)
    ====================
    
    include(‘FFEasyLifeHomeCtrl.js')
    
    //用js创建的新控制器
    include('FFEasyLifePictureCtrl.js')
    
    FFEasyLifeHomeCtrl.js
    require('UIView, UIColor, UILabel, UIFont, UIImageView, UIImage')
    require('FFEasyLifePictureCtrl')
    
    defineClass('FFEasyLifeHomeCtrl', {
            
         testArray: function() {
               // 1. 处理数组越界问题
               var nameArrays = self.nameArrays().toJS();
               var testName = nameArrays[4];
               console.log('----- testName: ' + testName);       
         },
    
         // 2. 添加按钮事件方法
         actionPicture: function(button) {
              var ctrl = FFEasyLifePictureCtrl.alloc().init();
              ctrl.view().setBackgroundColor(UIColor.lightGrayColor());
              //self.navigationController().pushViewController_animated(ctrl, YES);
              
              3. 跳转到一个新控制器(用js创建的新控制器)
              self.presentViewController_animated_completion(ctrl, YES, null);
         },
    });
    
    FFEasyLifePictureCtrl.js
    require('UIColor');
    
    defineClass('FFEasyLifePictureCtrl : UITableViewController <UIAlertViewDelegate>', ['data'], {
            
            init: function() {
                self = self.super().init()
                return self
            },
            
            viewDidLoad: function() {
            },
            
            dataSource: function() {
            
                //数组
                var data = self.data();
                if (data) return data;
            
                var data = [];
                for (var i = 0; i < 20; i ++) {
                    data.push("cell from js " + i);
                }
            
                self.setData(data)
                console.log('data:'  + self.data());
    
                return data;
            },
            
            // MARK: - tableDelegate
            numberOfSectionsInTableView: function(tableView) {
                return 1;
            },
            
            tableView_numberOfRowsInSection: function(tableView, section) {
                return self.dataSource().length;
            },
            
            tableView_heightForRowAtIndexPath: function(tableView, indexPath) {
                return 200;
            },
            
            tableView_cellForRowAtIndexPath: function(tableView, indexPath) {
                var cell = tableView.dequeueReusableCellWithIdentifier("cell")
                if (!cell) {
                    cell = require('UITableViewCell').alloc().initWithStyle_reuseIdentifier(0, "cell")
                }
                cell.textLabel().setText(self.dataSource()[indexPath.row()])
                cell.setBackgroundColor(UIColor.colorWithRed_green_blue_alpha((Math.random() *255) / 255.0, (Math.random() *255) / 255.0, (Math.random() *255) / 255.0, 1));
    
                return cell
            },
            
            tableView_didSelectRowAtIndexPath: function(tableView, indexPath) {
            
                //弹窗
                var alertView = require('UIAlertView').alloc().initWithTitle_message_delegate_cancelButtonTitle_otherButtonTitles("Alert",self.dataSource()[indexPath.row()], self, "OK",  null);
                alertView.show()
            },
            
            alertView_willDismissWithButtonIndex: function(alertView, idx) {
                console.log('click btn ' + alertView.buttonTitleAtIndex(idx).toJS())
            }
    })
    

    JSPatch 创建应用

    • app.png

    JSPatch执行顺序问题:

    JSPatch所有动态替换的函数,都必须在JS执行完了之后,第二次再执行,才会全面以新替换的js代码进行工作。
    时间顺序
    #• application:didFinishLaunchingWithOptions:
    #• JSPatch发起网络请求拉patch
    #• app的rootViewController触发ViewDidload运行完毕,依然是未修正的错误界面
    #• JSPatch网络请求拉取回来,执行JS
    #• JS已经执行成功ViewDidLoad已经被替换,但是界面已经生成,新的正确的ViewDidLoad并不会再次执行
    效果:我的viewDidLoad为啥不能修改啊?
    比喻:
    #• viewDidload的函数代码就好比建筑设计图
    #• 运行起来后的界面就好比建好的建筑
    时间顺序:
    #• viewDidLoad有bug需要改(建筑设计图图纸错了)
    #• 旧viewDidLoad先执行,并且创建好了界面(工人已经按着错图纸把建筑建好了)
    #• JSPatch执行了hotfix(设计师修改设计图纸)
    #• JSPatch看起来没效果(就算你改好了建筑图纸,已经建好的建筑是不会有任何改变的)

    解决办法:2个(未去实践过)
    #• 在建造建筑之前,把图纸改好
    JSPatch在使用的时候,第一次下载网络请求是要时间的,所以才会发生修改图纸,在建筑建好之后。
    但是补丁已经下载完成,第二次运行app,新的图纸已经存在本地,是可以在创建rootViewController之前,就先把patch运行,让新图纸生效的。

    #•  不要修改图纸了,直接去修改建筑
    当你网络请求在JSPatch下载完Patch之后,通过callback,进行完全自定义的处理,窗户坏了,直接改窗户,门坏了修门,你也可以自定义把房子推倒了重建
    如果你使用的是JSPatchSDK,那么头文件有一个callback的API,JSPatchSDK提供了JS下载完成的这个时机,具体怎么修,纯看使用者自己
    

    帮助网址:
    JSPatch官网:http://jspatch.com
    JSPatch官方文档:http://jspatch.com/Docs/dev

    注意项:
    1:补丁版本号与app版本号一样;
    2:多个js时,放在一个文件夹里压缩成zip;

    相关文章

      网友评论

        本文标题:iOS【JSPatch热更新】实践一

        本文链接:https://www.haomeiwen.com/subject/rurnwttx.html