ReactNative热更新&拆包

作者: 玄策 | 来源:发表于2018-01-24 12:15 被阅读103次

    目录

    • 1)全量热更新-Android
    • 2)拆包增量更新-Android
    • 3)图片增量更新-Android
    • 4)全量热更新-iOS

    流程图

    1)全量热更新-Android

    全量热更新

    -打更新包bundle(包括更新的图片和代码)

    react-native bundle --entry-file index.android.js --bundle-output ./bundle/index.android.bundle --platform android --assets-dest ./bundle --dev false
    
    • 运行此命令会将代码和图片打入根目录下的bundle文件夹,将这些文件压缩至zip包
      => patches.zip
      =>将zip包放入远程文件服务器待下载


      打更新包bundle

    -根据业务判断是否需要更新

    private void checkVersion() {
      if (true) {
         // 有最新版本
         Toast.makeText(this, "开始下载", Toast.LENGTH_SHORT).show();
         initDownloadManager();  //开启广播接收器
         downLoadBundle();   //开始下载任务
      }
    }
    

    -下载zip 至指定的sdcard地址

        //注册广播接收器
        private void initDownloadManager() {
            mDownloadReceiver = new DownloadReceiver();
            registerReceiver(mDownloadReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
        }
    
        //下载任务
        private void downLoadBundle() {
            // 1.检查是否存在pat压缩包,存在则删除
            // /storage/emulated/0/Android/data/包名/cache/patches.zip
            zipfile = new File(FileConstant.get_JS_PATCH_LOCAL_PATH(this));
            if(zipfile != null && zipfile.exists()) {
                zipfile.delete();
            }
            // 2.下载
            DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
            //远程下载地址http://192.168.1.127/patches.zip
            DownloadManager.Request request = new DownloadManager
                    .Request(Uri.parse(FileConstant.JS_BUNDLE_REMOTE_URL));
            request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE);
            request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE| DownloadManager.Request.NETWORK_WIFI);
            //下载目标地址 /storage/emulated/0/Android/data/包名/cache/patches.zip
            request.setDestinationUri(Uri.parse("file://"+ FileConstant.get_JS_PATCH_LOCAL_PATH(this)));
            mDownloadId = downloadManager.enqueue(request);
        }
    

    -解析zip并写入sdcard

        private class DownloadReceiver extends BroadcastReceiver {
            @Override
            public void onReceive(Context context, Intent intent) {
                //下载完成,收到广播
                long completeDownloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
                if(completeDownloadId == mDownloadId){
                    // 1.解压并写入sdcard对应地址
                    RefreshUpdateUtils.decompression(getApplicationContext());
                    zipfile.delete();
                }
            }
        }
    
    //~/RefreshUpdateUtils.java
        //解析压缩包,并写入手机存储位置
        public static void decompression(Context context) {
            try {
                //从下载目标地址 /storage/emulated/0/Android/data/包名/cache/patches.zip 获取压缩包
                ZipInputStream inZip = new ZipInputStream(new FileInputStream(FileConstant.get_JS_PATCH_LOCAL_PATH(context)));
                ZipEntry zipEntry;
                String szName;
                try {
                    while((zipEntry = inZip.getNextEntry()) != null) {
    
                        szName = zipEntry.getName();
                        //如果是目录则创建,并写入/storage/emulated/0/Android/data/包名/cache/patches/目录下
                        if(zipEntry.isDirectory()) {
    
                            szName = szName.substring(0,szName.length()-1);
                            File folder = new File(FileConstant.get_JS_PATCH_LOCAL_FOLDER(context) + File.separator + szName);
                            folder.mkdirs();
    
                        }
                        //如果是文件则创建,并写入/storage/emulated/0/Android/data/包名/cache/patches/目录下
                        else{
                            File folder = new File(FileConstant.get_JS_PATCH_LOCAL_FOLDER(context) + File.separator);
                            if (!folder.exists()){
                                folder.mkdir();
                            }
                            File file1 = new File(FileConstant.get_JS_PATCH_LOCAL_FOLDER(context) + File.separator + szName);
    
                            boolean s = file1.createNewFile();
                            FileOutputStream fos = new FileOutputStream(file1);
                            int len;
                            byte[] buffer = new byte[1024];
    
                            while((len = inZip.read(buffer)) != -1) {
                                fos.write(buffer, 0 , len);
                                fos.flush();
                            }
    
                            fos.close();
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                inZip.close();
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    

    -RN调用JSBundle的时候判断,当sdcard对应位置的bundle不为空时加载sdcard中的bundle,否则加载原包内Assets位置的bundle

    
    public class MainApplication extends Application implements ReactApplication {
        private ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
            @Override
            public boolean getUseDeveloperSupport() {
                //Debug模式,这个模式才能在JS里作调试
                return BuildConfig.DEBUG;
            }
    
            @Override
            protected List<ReactPackage> getPackages() {
                //返回带有官方已有的package的集合
                return Arrays.<ReactPackage>asList(
                        new MainReactPackage(),
                        new MyReactPackage()  //加入自定义的Package类
                );
            }
    
            @Nullable
            @Override
            protected String getJSBundleFile() {
                //判断sdcard中是否存在bundle,存在则加载,不存在则加载Assets中的bundle
                //路径 /storage/emulated/0/Android/data/包名/cache/patches/index.android.bundle
                File file = new File (FileConstant.get_JS_BUNDLE_LOCAL_PATH(getApplicationContext()));
                if(file != null && file.exists()) {
                    return FileConstant.get_JS_BUNDLE_LOCAL_PATH(getApplicationContext());
                } else {
                    return super.getJSBundleFile();
                }
            }
        };
    
        @Override
        public ReactNativeHost getReactNativeHost() {
            return mReactNativeHost;
        }
    
        @Override
        public void onCreate() {
            super.onCreate();
            SoLoader.init(this, /* native exopackage */ false);
        }
    }
    

    -注意点:

    • 1-退出的时候需要杀死进程,否则不会初始化Application就无法更换bundle的加载路径了
    @Override
      protected void onDestroy() {
        super.onDestroy();
        //杀死进程,否则就算退出App,App处于空进程并未销毁,再次打开也不会初始化Application
        //从而也不会执行getJSBundleFile去更换bundle的加载路径 !!!
        android.os.Process.killProcess(android.os.Process.myPid());
        //解除广播接收器
        unregisterReceiver(mDownloadReceiver);
    }
    
    • 2-权限,由于我们采用App扩展存储方式,若无需兼容6.0以下,则无需申请权限。
    <!--代码中使用getExternalCacheDir(), API >=19 是不需要申请的,若需兼容6.0以下则需写此权限 -->
    <!--但写此权限若不加maxSdkVersion="18",会导致6.0已上机型会在设置中看到此权限开关,从而可能会关闭此权限-->>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
            android:maxSdkVersion="18"/>
    
    • 3-不能在开发环境调试,需要打包调测
    //创建assets目录 ./android/app/src/main/assets
    //创建离线bundle和打包本地资源
    react-native bundle --entry-file index.android.js --bundle-output ./android/app/src/main/assets/index.android.bundle --platform android --assets-dest ./android/app/src/main/res/ --dev false
    //打签名包即可
    cd android && ./gradlew assembleRelease
    //进入目录安装apk  ./android/app/build/outputs/apk/release
    adb install app-release.apk 
    

    -其他代码

    ~/FileConstant.java

    public class FileConstant {
        //远程下载服务地址
        public static final String JS_BUNDLE_REMOTE_URL = "http://192.168.1.127/patches.zip";
        //本地bundle文件名
        public static final String JS_BUNDLE_LOCAL_FILE = "index.android.bundle";
    
        //sdcard中bundle的加载路径
        public static String get_JS_BUNDLE_LOCAL_PATH(Context context){
            return context.getExternalCacheDir().getPath() + File.separator+ "patches/index.android.bundle";
        }
        //sdcard中下载后文件的存放文件夹路径
        public static String get_JS_PATCH_LOCAL_FOLDER(Context context){
            return context.getExternalCacheDir().getPath() + File.separator+ "patches";
        }
        //sdcard中下载的zip包存放位置
        public static String get_JS_PATCH_LOCAL_PATH(Context context){
            return context.getExternalCacheDir().getPath() + File.separator+ "patches.zip";
        }
        //sdcard中下载后的增量包pat存放位置
        public static String get_JS_PATCH_LOCAL_FILE(Context context){
            return context.getExternalCacheDir().getPath() + File.separator+ "patches/patches.pat";
        }
    }
    

    2)拆包增量更新-Android

    • 虽然通过zip压缩减小了一部分bundle体积,但是每次需要热更新去打全量包在不更新图片的情况下再小也有几百kb,其中业务部分的代码也只占一部分,着实浪费且不科学。
    • 所以在每次原生迭代版本发布时,保留其附属的RN版本bundle,并在此原生版本周期内需要热更新时,生成新的bundle,使用google-diff-match-patch与原版本bundle比对,生成差异化补丁。
    • App判断有热更新时,下载此补丁,与Assets中的初始版本合并,生成新的index.android.bundle文件写入sdcard中。

    -打更新包bundle(同-1)

    -生成差异化补丁文件

    将初始版本old.bundle和热更版本new.bundle进行比对,生成patches.pat
    => 将pat和图片压缩成 patches.zip
    => 将zip包放入远程文件服务器待下载


    生成patches.pat
    patches.pat
        public static void main(String[] args) {
            String o = getStringFromPat("/Users/tugaofeng/Desktop/old.bundle");
            String n = getStringFromPat("/Users/tugaofeng/Desktop/new.bundle");
            // 对比
            diff_match_patch dmp = new diff_match_patch();
            LinkedList<diff_match_patch.Diff> diffs = dmp.diff_main(o, n);
            // 生成差异补丁包
            LinkedList<diff_match_patch.Patch> patches = dmp.patch_make(diffs);
            // 解析补丁包
            String patchesStr = dmp.patch_toText(patches);
    
            try {
                // 将补丁文件写入到某个位置
                Files.write(Paths.get("/Users/tugaofeng/Desktop/patches.pat"), patchesStr.getBytes());
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    

    -根据业务判断是否需要更新(同-1)

    -下载zip 至指定的sdcard地址(同-1)

    -解析zip并写入sdcard

        private class DownloadReceiver extends BroadcastReceiver {
            @Override
            public void onReceive(Context context, Intent intent) {
                //下载完成,收到广播
                long completeDownloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
                if(completeDownloadId == mDownloadId){
                    // 1.解压并写入sdcard对应地址
                    RefreshUpdateUtils.decompression(getApplicationContext());
                    zipfile.delete();
    
                    // 2.将下载好的patches文件与assets目录下的原index.android.bundle合并,得到新的
                    // bundle文件,并写入sdcard中
                    mergePatAndAsset();
                }
            }
        }
    

    -将Assets内的index.android.bundle和下载完成的差异化补丁pat合并,并生成新的index.android.bundle写入sdcard对应位置

    //拆包增量更新bundle
        private void mergePatAndAsset() {
            // 1.获取本地Assets目录下的bunlde
            String assetsBundle = RefreshUpdateUtils.getJsBundleFromAssets(getApplicationContext());
            // 2.获取.pat文件字符串
            // /storage/emulated/0/Android/data/包名/cache/patches/patches.pat
            String patcheStr = RefreshUpdateUtils.getStringFromPat(FileConstant.get_JS_PATCH_LOCAL_FILE(this));
            if (patcheStr == null || "".equals(patcheStr)){
                return;
            }
            // 3.初始化 dmp
            diff_match_patch dmp = new diff_match_patch();
            // 4.转换pat
            LinkedList<diff_match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>) dmp.patch_fromText(patcheStr);
            // 5.与assets目录下的bundle合并,生成新的bundle
            Object[] bundleArray = dmp.patch_apply(pathes,assetsBundle);
            // 6.保存新的bundle
            // 至/storage/emulated/0/Android/data/包名/cache/patches/index.android.bundle
            try {
                Writer writer = new FileWriter(FileConstant.get_JS_BUNDLE_LOCAL_PATH(this));
                String newBundle = (String) bundleArray[0];
                writer.write(newBundle);
                writer.close();
                // 7.删除.pat文件
                // 路径为/storage/emulated/0/Android/data/包名/cache/patches/patches.pat
                File patFile = new File(FileConstant.get_JS_PATCH_LOCAL_FILE(this));
                patFile.delete();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    

    -其他代码

    ~/diff_match_patch.java

    //~/RefreshUpdateUtils.java
        //将.pat or bundle文件转换为String
        public static String getStringFromPat(String patPath) {
            FileReader reader = null;
            String result = "";
            try {
                reader = new FileReader(patPath);
                int ch = reader.read();
                StringBuilder sb = new StringBuilder();
                while (ch != -1) {
                    sb.append((char)ch);
                    ch  = reader.read();
                }
                reader.close();
                result = sb.toString();
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
            return result;
        }
    
        //从本地Assets获取bundle
        public static String getJsBundleFromAssets(Context context) {
            String result = "";
            try {
                InputStream is = context.getAssets().open(FileConstant.JS_BUNDLE_LOCAL_FILE);
                int size = is.available();
                byte[] buffer = new byte[size];
                is.read(buffer);
                is.close();
                result = new String(buffer,"UTF-8");
    
            } catch (IOException e) {
                e.printStackTrace();
            }
            return result;
        }
    

    3)图片增量更新-Android

    图片增量更新需要修改RN源码。

    -修改RN源码。
    注意:RN库版本升级时别忘了修改。。

    渲染图片的方法在:node_modules / react-native / Libraries / Image /AssetSourceResolver.js 下:

    defaultAsset(): ResolvedAssetSource {  
      if (this.isLoadedFromServer()) {  
        return this.assetServerURL();  
      }  
      
      if (Platform.OS === 'android') {  
        return this.isLoadedFromFileSystem() ?  
    //存在离线Bundle文件时,从Bundle文件所在目录加载图片
          this.drawableFolderInBundle() :  
    //否则从Asset资源目录下加载
          this.resourceIdentifierWithoutScale();  
      } else {  
        return this.scaledAssetPathInBundle();  
      }  
    }  
    

    对源码做如下修改:

    ...
    import type { PackagerAsset } from 'AssetRegistry';
    // 1-新增全局变量
    // !!!注意:每次基于某个原生版本的RN热更版本新增的图片都要在此处新增加(不是覆盖哦)
    // 比如原生版本1.0.0,RN热更版本1.0.0-1时新增a.png
    //var patchImgNames = '|a.png|';
    // 比如原生版本1.0.0,RN热更版本1.0.0-2时新增b.png
    //var patchImgNames = '|a.png|b.png|';
    // 比如原生版本2.0.0(2.0的原生版本asset里已经会包含a和b.png),暂无RN热更版本时
    //var patchImgNames = '';
    var patchImgNames = '|src_res_images_offer_message_red.png|src_res_images_banner_default.png|'; 
    
    ...
    // 2-修改此函数
      isLoadedFromFileSystem(): boolean {
        // return !!this.bundlePath;  //注释此处,新增如下代码
        var imgFolder = getAssetPathInDrawableFolder(this.asset);  
        var imgName = imgFolder.substr(imgFolder.indexOf("/") + 1);  
        var isPatchImg = patchImgNames.indexOf("|"+imgName+"|") > -1;  
        return !!this.bundlePath && isPatchImg; 
      }
    

    -打增量代码包和增量图片

    => 打更新包
    => 生成差异化补丁文件pat
    => 将pat和本次热更新增的图片压缩成patches.zip
    => 将zip包放入远程文件服务器待下载


    第一次热更包 1.0.0-1
    第二次热更包1.0.0-2
    • 注意:图片每次热更的时候可以只将当次热更新增的图片打入zip包中,而不需要像修改源码全局变量patchImgNames一样需要追溯当次原生版本的所有热更图片的文件名 !!!

    -效果展示

    版本1.0.0热更至1.0.0-1.gif 版本1.0.0-1的sdcard图片目录.png
    版本1.0.0-1热更至1.0.0-2.gif
    版本1.0.0-2的sdcard图片目录.png

    4)全量热更新-iOS

    demo

    -修改podfile,新增SSZipArchive【解压】和AFNetworking【文件下载】

      pod 'SSZipArchive'
      pod 'AFNetworking', '~> 3.0'
    
    cd /ios && pod install
    

    -打更新包bundle(包括更新的图片和代码)

    react-native bundle --entry-file index.ios.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_ios/
    
    • 运行此命令会将代码和图片打入根目录下的release_ios文件夹,将这些文件压缩至zip包
      => patches.zip
      =>将zip包放入远程文件服务器待下载

    -创建bundle存放路径,使用plist文件去存储版本号和下载路径

    //创建bundle路径
    -(void)createPath{
        
        NSFileManager *fileManager = [NSFileManager defaultManager];
        if ([fileManager fileExistsAtPath:[self getVersionPlistPath]]) {
            return;
        }
        
        NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES);
        NSString *path = [paths lastObject];
        NSString *directryPath = [path stringByAppendingPathComponent:@"IOSBundle"];
        [fileManager createDirectoryAtPath:directryPath withIntermediateDirectories:YES attributes:nil error:nil];
        NSString *filePath = [directryPath stringByAppendingPathComponent:@"Version.plist"];
        [fileManager createFileAtPath:filePath contents:nil attributes:nil];
    }
    

    -根据业务判断是否需要更新

    //获取版本信息
    -(void)getAppVersion{
        
        //从服务器上获取版本信息,与本地plist存储的版本进行比较
        //1.获取本地plist文件的版本号 
        NSString* plistPath=[self getVersionPlistPath];
        NSMutableDictionary *data = [[NSMutableDictionary alloc] initWithContentsOfFile:plistPath];
        
        NSInteger localV=[data[@"bundleVersion"]integerValue];
      
        //本地plist的版本号
        printf("%ld ", (long)localV);
    
        //保留业务,根据当前热更版本号与本地比对,进行判断是否下载
        if(true){
            //下载bundle文件 存储在 Doucuments/IOSBundle/下
            NSString*url=@"http://192.168.1.127/patches.zip";
            [[DownLoadTool defaultDownLoadTool] downLoadWithUrl:url];
        }
    }
    

    -下载zip 至指定沙盒路径地址

    -(void)downLoadWithUrl:(NSString*)url{
        //根据url下载相关文件
        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
        AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration];
        NSURL *URL = [NSURL URLWithString:url];
        NSURLRequest *request = [NSURLRequest requestWithURL:URL];
        NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request progress:^(NSProgress * _Nonnull downloadProgress) {
            //获取下载进度
            NSLog(@"Progress is %f", downloadProgress.fractionCompleted);
        } destination:^NSURL *(NSURL *targetPath, NSURLResponse *response) {
            //有返回值的block,返回文件存储路径
            NSURL *documentsDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
            
            // file:///Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/kiOSFileName
            NSURL* targetPathUrl = [documentsDirectoryURL URLByAppendingPathComponent:@"IOSBundle"];
            return [targetPathUrl URLByAppendingPathComponent:[response suggestedFilename]];
            
        } completionHandler:^(NSURLResponse *response, NSURL *filePath, NSError *error) {
            if(error){
                //下载出现错误
                NSLog(@"%@",error);
                
            }else{
                // [self showPromptWithStr:@"更新完毕。请重新启动******!"];
                //下载成功
                //  file:///Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/kiOSFileName/patches.zip
                NSLog(@"File downloaded to: %@", filePath);
                self.zipPath = [[filePath absoluteString] substringFromIndex:7];
                //下载成功后更新本地存储信息
                NSDictionary*infoDic=@{@"bundleVersion":@3,@"downloadUrl":url};
                [UpdateDataLoader sharedInstance].versionInfo=infoDic;
                
                [[UpdateDataLoader sharedInstance] writeAppVersionInfoWithDictiony:[UpdateDataLoader sharedInstance].versionInfo];
                
                //解压并删除压缩包
                [self unZip];
                [self deleteZip]; 
            }
        }];
        [downloadTask resume];
    }
    

    -解压&删除压缩包

    //解压压缩包
    -(BOOL)unZip{
        if (self.zipPath == nil) {
            return NO;
        }
    //Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/kiOSFileName/patches.zip
        NSString *zipPath = self.zipPath;
        
        // /Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/IOSBundle
        NSString *destinationPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]stringByAppendingString:@"/IOSBundle"];
        BOOL success = [SSZipArchive unzipFileAtPath:zipPath
                                       toDestination:destinationPath];
        return success;  
    }  
    
    //删除压缩包  
    -(void)deleteZip{  
        NSError* error = nil;  
        [[NSFileManager defaultManager] removeItemAtPath:self.zipPath error:&error];  
    }  
    
    下载前沙盒文件
    下载解压后沙盒文件

    -RN调用JSBundle的时候判断,当沙盒对应位置的bundle不为空时加载其bundle,否则加载原包内的bundle

        NSURL *jsCodeLocation;
        
        NSString* iOSBundlePath = [[UpdateDataLoader sharedInstance] iOSFileBundlePath];
        NSString* filePath = [iOSBundlePath stringByAppendingPathComponent:@"/main.jsbundle"];
        if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
                jsCodeLocation = [NSURL URLWithString:[iOSBundlePath stringByAppendingString:@"/main.jsbundle"]];
        
        }else{
            jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
        }
    

    参考资料

    React Native 实现热部署、差异化增量热更新
    React-Native开发iOS篇-热更新的代码实现

    相关文章

      网友评论

        本文标题:ReactNative热更新&拆包

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