Ionic App 使用 Cordova Hot Code Push 实现热更新

本文主要记录 Ionic App 使用 cordova hot code push 实现热更新时遇到问题的解决方法,另外也简单记录下使用方法,方便日后查阅。

Cordova hot code push 插件的原作者已经不维护了,我们可以选择一个可能最好的 fork 来使用。 gitpop2 可以帮助我们选择,我从中选择了当前 star 最多的一个 fork。

Ionic App 使用 cordova hot code push 实现热更新的基本步骤如下:

  1. 在 ionic 工程中添加 cordova hot code push plugin

     $ ionic cordova plugin add https://github.com/snipking/cordova-hot-code-push.git
    
  2. 安装 Cordova Hot Code Push CLI client

     $ npm install -g cordova-hot-code-push-cli
    
  3. 为指定平台编译工程

     $ ionic cordova prepare android
    
  4. 执行插件初始化

     $ cd /path/to/project/root
     $ cordova-hcp init
    
  5. 生成插件配置文件

     $ cordova-hcp build
    
  6. 运行到设备上

  7. 开发和发布应用新版本的 web

     // 1. 开发
     // 2. 为指定平台编译工程生成 web 
     $ ionic build --engine=cordova --platform=android
     // 3. 生成新插件配置文件
     $ cordova-hcp build
     // 4. 部署到服务器
    

在使用的过程中遇到的第一个问题是更新之后白屏。使用 Chrome 的 remote devices 调试 android webview 找到了问题的原因,ionic 应用中 <base href="/" />, cordova hot code push 会将 web 代码拷贝到外部存储上,webview 使用形如 file:///data/user/0/com.tenneshop.liveupdatedemo/files/cordova-hot-code-push-plugin/2020.01.07-16.16.39/www/index.html 的路径来加载应用,此时 document.baseURI = /,加载其他相对路径的 js 文件时,是相对这个路径,例如 <script src="cordova.js"></script>,就是以 /cordova.js 去加载,于是就会提示找不到文件。从上面的分析我们也知道,解决问题的一个办法是修正 base href 的值,我们可以在 index.html 的 head 元素加入下面的代码:

1
2
3
<script>
    document.write('<base href="' + document.location.href + '" />');
</script>

这样我们就修正文件路径的问题,很不巧,虽然文件的路径是对了,但是 ionic 默认不响应 file schema 的请求,我们需要做些工作,先让 WebViewLocalServer.java 支持响应 file schema,将 createHostingDetails 改成如下实现:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private void createHostingDetails() {
  final String assetPath = this.basePath;

  if (assetPath.indexOf('*') != -1) {
    throw new IllegalArgumentException("assetPath cannot contain the '*' character.");
  }

  PathHandler handler = new PathHandler() {
    @Override
    public InputStream handle(Uri url) {
      InputStream stream = null;
      String path = url.getPath();
      try {
        if (url.getScheme().equals("file")) {
          stream = protocolHandler.openFile(path);
        } else if (path.startsWith(contentStart)) {
          stream = protocolHandler.openContentUrl(url);
        } else if (path.startsWith(fileStart) || !isAsset) {
          if (!path.startsWith(fileStart)) {
            path = basePath + url.getPath();
          }
          stream = protocolHandler.openFile(path);
        } else {
          stream = protocolHandler.openAsset(assetPath + path);
        }
      } catch (IOException e) {
        Log.e(TAG, "Unable to open asset URL: " + url);
        Log.e(TAG, e.getLocalizedMessage());
        return null;
      }

      return stream;
    }
  };

  registerUriForScheme(httpScheme, handler, authority);
  registerUriForScheme(httpsScheme, handler, authority);
  if (!customScheme.equals(httpScheme) && !customScheme.equals(httpsScheme)) {
    registerUriForScheme(customScheme, handler, authority);
  }

  registerUriForScheme("file", handler, "");

}

然后是 isLocalFile 方法:

1
2
3
4
5
6
7
private boolean isLocalFile(Uri uri) {
  String path = uri.getPath();
  if (path.startsWith(contentStart) || path.startsWith(fileStart) || uri.getScheme().equals("file")) {
    return true;
  }
  return false;
}

做完这些工作后 ionic 就可以响应 file schema 请求了。

继续测试,我发现更新后第二次打开还是显示 App bundle asset 中的 web,这有点奇怪。仔细查看日志,确实有加载外部存储的 web , 但却被 http://localhost/ 的请求覆盖了,这是什么原因呢?经过对代码逻辑的一番梳理,我发现是 IonicWebViewEngine 中 onPageStarted 方法的原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void onPageStarted(WebView view, String url, Bitmap favicon) {
  super.onPageStarted(view, url, favicon);
  String launchUrl = parser.getLaunchUrl();
  if (!launchUrl.contains(WebViewLocalServer.httpsScheme) && !launchUrl.contains(WebViewLocalServer.httpScheme) && url.equals(launchUrl)) {
    view.stopLoading();
    // When using a custom scheme the app won't load if server start url doesn't end in /
    String startUrl = CDV_LOCAL_SERVER;
    if (!scheme.equalsIgnoreCase(WebViewLocalServer.httpsScheme) && !scheme.equalsIgnoreCase(WebViewLocalServer.httpScheme)) {
      startUrl += "/";
    }
    view.loadUrl(startUrl);
  }
}

MainActivity 触发 webview 加载 file:///android_asset/www/index.html,然后 cordova hot code push plugin 启动工作,它会让 webview 加载外部存储的 web,之后 IonicWebViewEngine 的 onPageStarted 收到 file:///android_asset/www/index.html 的请求的回调,它先停止了 webview 的加载工作,即 cordova hot code push plugin 启动加载外部存储的 web 的请求,再开始 http://localhost/ 的请求,也就是打印出来日志的记录。正是这个方法时序的问题导致成功更新之后再重启应用仍然加载 app bundle asset 的 web。一种解决办法是我们直接让 MainActivity 直接加载 http://localhost/,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void onCreate(Bundle savedInstanceState)
{
   super.onCreate(savedInstanceState);

   // enable Cordova apps to be started in the background
   Bundle extras = getIntent().getExtras();
   if (extras != null && extras.getBoolean("cdvStartInBackground", false)) {
       moveTaskToBack(true);
   }
   launchUrl = "http://localhost/";
   // Set by <content src="index.html" /> in config.xml
   loadUrl(launchUrl);
}

这样热更新就可以正常工作了。

我继续做了点测试,又发现一个和 ionic icon 相关的问题,ionic 4 使用了 Fetch API 来请求 ionic icon 的 svg 资源,由于现在是使用 file schema 来指定资源路径,由于 Fetch API 不支持 file schema 所以就报错 Fetch API cannot load file:///xxx/www/svg/md-star.svg. URL scheme "file" is not supported. 我们得想办法来解决这个问题,一个办法替换 fetch 方法的实现,如:

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
29
30
31
32
<script>
   document.write('<base href="' + document.location.href + '" />');

   var originalFetch = window.fetch;

   window.fetch = function() {
       var args = [];
       for (var _i = 0; _i < arguments.length; _i++) {
           args[_i] = arguments[_i];
       }
       var url = args[0];
       if (typeof url === 'string' && url.match(/\.svg/)) {
           return new Promise(function(resolve, reject) {
               var req = new XMLHttpRequest();
               req.open('GET', url, true);
               req.addEventListener('load', function() {
                   resolve({
                       ok: true,
                       status: 200,
                       text: function() {
                           return Promise.resolve(req.responseText);
                       }
                   });
               });
               req.addEventListener('error', reject);
               req.send();
           });
       } else {
           return originalFetch.apply(void 0, args);
       }
   };
</script>

在这些测试过程中,我还发现 cordova hot code push 更新时只做了版本字符是否相等的判断,这在服务器端的版本低于本地版本时,插件仍然会做更新,这是有问题的,我们需要严格这里的判断,让它只有在服务端的版本高于本地版本时才做更新。相关代码位于 UpdateLoaderWorker 的 run 方法中。

最后一个要考虑的问题是如何将我们修改的代码和 ionic 的代码很好的整合起来?我现在的想法是创建一个私有的扩展 IonicWebViewEngine 和 WebViewLocalServer,然后借鉴 ionic 通过 config.xml 的 web 偏好设置的方法,像下面的代码:

1
<preference name="webView" value="com.ionicframework.cordova.webview.IonicWebViewEngine" />

回头测试下这个想法,好了有时间也许可以整理好代码提个 Pull Request。

Reference:

在 macOS 上用 VirtualBox 安装 CentOS

最近为了更好的实践 Linux,决定在 mac 上使用 VirtualBox 安装一个 CentOS,主要是参考鸟哥的这篇安裝 CentOS7.x

安装之后打开系统出现闪屏,英语应该是称为 screen flicker,google 之后在 VirtualBox 的论坛找到解决方法:

  1. 进入单用户维护模式

     a. 重启系统
     b. 在菜单选择界面键入 e,进入 grub2 的指令编辑模式
     c. 在指定内核和根文件系统这行最后加上 systemd.unit=rescue.target
     d. 键入 ctrl + x 进入系统
    
  2. 强制使用 Xorg

      a. 用 vim 打开 /etc/gdm3/custom.conf
      b. 删除 WaylandEnable=false 前的 # 注释符号
      c. 保存文件后,systemctl default 来进入正常模式 
    

解决了闪屏之后,想通过虚拟机菜单中的调整窗口大小来让系统的屏幕全屏发现无用,想起来应该要安装 VirtualBox Guest Additions,于是插入虚拟机提供的光盘来安装。

首先是提示 kernel headers not found for target kernel 的错误,也提示详细的错误信息位于 /var/log/vboxadd-setup.log,我们可以通过查看该错误日志来找到对应解决方法。于是尝试安装对应的内核头文件,命令为 yum install kernel-headers kernel-devel,之后执行 /sbin/rcvboxadd setup.

仍然提示 kernel headers not found for target kernel,通过 uname -r 和 rpm -q kernel-headers 发现版本不一致,于是重启系统选择最新的内核版本。

再次尝试安装,提示 Error building the module,查看错误日志提示需要安装 libelf-dev, libelf-devel or elfutils-libelf-devel ,CentOS 上只有 elfutils-libelf-devel ,安装之后再次安装 VirtualBox Guest Additions。

提示

1
2
3
ValueError: File context for /opt/VBoxGuestAdditions-6.0.14/other/mount.vboxsf already defined
VirtualBox Guest Additions: Running kernel modules will not be replaced until
the system is restarted

这个问题暂时没找到解决方法,但是可以让 CentOS 全屏了,就暂时先不管这个问题了。

释放虚拟机硬盘空间

在虚拟机使用过程中硬盘的空间会慢慢增加,但是即使虚拟机中删除了文件实际占用空间减少,外部的硬盘文件大小仍然没有减少,这对小硬盘电脑可伤不起,于是想办法释放虚拟机磁盘空间。大前提是虚拟机的硬盘类型是 Dynamically allocated storage,主要分为两大步:

  1. 在虚拟机寄主系统(如 CentOS)中删除文件释放空间并压缩硬盘
  2. 在虚拟机宿主系统(如 macOS)中压缩硬盘文件

下面以 Windows 10 为例:

  1. 开始按钮 > 设置 > 系统 > 存储空间 > 根据空间占用选择删除无用的文件释放之间
  2. 在左下方搜索框中搜索 Defragment ,然后打开 Defragment and Optimize Drives,选择想要压缩的硬盘进行压缩;
  3. 从微软下载 SDelete 助手
  4. 使用 sdelete 填充释放的硬盘空间,假设 SDelete 下载之后的放在 Downloads 目录下,我们想压缩 c 盘
1
2
cd "C:\Users\bob\Downloads"
sdelete.exe c: -z
  1. 最后在宿主系统中压缩硬盘文件,例如我是 macOS:
1
2
3
4
$ /Applications/VirtualBox.app/Contents/MacOS/VBoxManage list hdds
# 找到想要压缩的硬盘文件路径 

$ /Applications/VirtualBox.app/Contents/MacOS/VBoxManage modifymedium disk /Users/meiliang/VirtualBox\ VMs/Windows\ 10/Windows\ 10.vdi --compact

修改记录

  • 2020/10/02:增加释放虚拟机硬盘空间的方法
  • 2020/01/04:第一次完成

Reference:

创建 Cordova Plugin 及其 Ionic Native

本文介绍如何创建 Cordova plugin 及其 Ionic Native,主要内容如下:

  • Cordova Plugin 的工作原理
  • 如何创建 Cordova Plugin
  • 如何创建 Cordova Plugin 对应的 Ionic Native

Cordova Plugin 的工作原理

我们简要介绍下 Cordova Plugin 的工作原理,这样我们才能解决在开发中遇到的问题。

Plugin js 端的入口方法签名形式如下:

1
exec(<successFunction>, <failFunction>, <service>, <action>, [<args>]);

successFunction, failFunction 是成功和失败的回调函数,args 则是传递给原生端的参数。service, action 则是用来映射到原生端的对象和方法。这个映射是通过 plugin.xml 建立起来的。在 plugin.xml 中我们有如下元数据信息:

1
2
3
<feature name="<service_name>">
    <param name="android-package" value="<full_name_including_namespace>" />
</feature>

service 就是对应 service_name 指定的对象,而 action 则是该对象能处理方法。

具体是怎么映射的呢?

以 android 为例,我们安装的 plugin 信息会保存在 config.xml 中,cordova prepare android 命令会将 config.xml 在 android 的资源文件目录中生成一个同名文件,两个文件内容大致相同,plugin 的信息会改成如下形式表示:

1
2
3
<feature name="<service_name>">
    <param name="android-package" value="<full_name_including_namespace>" />
</feature>

这个形式就是我们上面见过的形式。

当 js 端调用 exec 方法时,它会通过 webview 建立的通信通道(通常是用 WebView.addJavascriptInterface)调用 PluginManager 的 exec 方法,PluginManager 则根据 service_name 查找或创建 plugin ,然后调用 plugin 的 exec 方法,并将 action 作为参数传入,于是我们便可按需响应 action 请求。

如何创建 Cordova Plugin

安装 plugman

1
$ npm install -g plugman

创建 cordova plugin

1
2
3
4
$ plugman create --name <pluginName> --plugin_id <pluginID> --plugin_version <version> [--path <directory>] [--variable NAME=VALUE]

eg.
$plugman create --name cordova-plugin-onsite-signature --plugin_id cordova-plugin-onsite-signature --plugin_version 0.0.1

添加 platform

1
$plugman platform add --platform_name android

创建 package.json 文件

1
2
3
4
$ plugman createpackagejson <directory>

eg.
$plugman createpackagejson .

安装 cordova plugin

1
2
3
4
5
// 方法一:这种方式的命令和添加官方的插件类似,个人推荐此方法,可以少记一个命令
$cordova plugin add git+ssh://git@192.168.8.91/git/cordova-plugin-onsite-signature.git

// 方法二:
$ plugman install --platform android --project platforms/android --plugin ../LogicLinkPlugin/

卸载 cordova plugin

1
2
3
4
5
// 与安装的方法对应有两种卸载方法
// 方法一:$cordova plugin remove cordova-plugin-onsite-signature

// 方法二:
$ plugman uninstall --platform android --project platforms/android --plugin ../LogicLinkPlugin/

发布

1
2
3
4
5
// Create a tag
$git tag <tagname>

// Push to repository
$git push origin master

升级 cordova plugin

现在暂时没有直接升级的命令,采用的是先卸载后安装新版本的方法。

如何创建 Cordova Plugin 对应的 Ionic Native

Creating Plugin Wrappers

1
2
3
4
5
6
7
8
9
10
11
12
// Navigate to ionic-native root path
$cd  ionic-native

// Install dependencies first time
$npm install

// Create plugin wrapper
// When gulp installed locally
$npx gulp plugin:create -n PluginName

// When gulp installed globally
$gulp plugin:create -n PluginName

安装

1
2
3
4
5
// You need to run npm run build in the ionic-native project, this will create a dist directory. The dist directory will contain a sub directory @ionic-native with all the packages compiled in there.
$npm run build

//Copy the package(s) you created/modified to your app's node_modules under the @ionic-native directory.
$cp -r dist/@ionic-native/plugin-name ../my-app/node_modules/@ionic-native/

使用

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
29
30
31
32
33
34
35
36
37
// Import the plugin in a @NgModule and add it to the list of Providers. 
// app.module.ts
import { APIClient } from '@ionic-native/api-client/ngx';
...

@NgModule({
  ...

  providers: [
    ...
    APIClient
    ...
  ]
  ...
})
export class AppModule { }

// After the plugin has been declared, it can be imported and injected like any other service:

// login.service.ts
import { APIClient } from '@ionic-native/api-client/ngx';
import { ServiceName } from '@ionic-native/api-client/ngx';

constructor(private apiClient: APIClient) { }

this.apiClient.get(ServiceName.Login, JSON.stringify(user))
      .then((result: string) => {
        console.log('api client login:', result);
        //TODO: Parse server return json to UserExt object
        const routePath = this.simulateLogin(username);        
        resolve(routePath);
      })
      .catch((error: string) => {
        console.log('api client login error:', error);

        reject(error);
      });

Reference: