JavaScript
Android
iOS
ハイブリッドアプリ

ハイブリッドアプリでWebフォントをダウンロードせずアプリに梱包して使う方法

ハイブリッドアプリは起動が重い

ハイブリッドアプリは起動時にJavaScript、CSSなどのリソースをガバっととるから起動が重くなりがち。
中でも日本語Webフォントはひときわ重く、一つのタイプ(レギュラー、ボールドなど)だけで数メガバイトある。

東京にいると気づきづいらいが、日本でも未だに3G回線の人はまあまあいる。
3G回線で数メガバイトのダウンロードは非常に重く、日本語Webフォントの通常使用は現実的ではない。

フォントをダウンロードせずネイティブアプリに梱包する

この問題を解消するため、日本語Webフォントはダウンロードするのではなく、ネイティブアプリに梱包したものを使うようにする。
しかしWebViewからそのままローカルのリソースは参照できないので、Base64でローカルからWebViewにフォントデータを連携してやる必要がある。
(Androidはhtmlをassetsに置くと一応ローカルリソースを参照できるが)

おすすめはしない

この方法は結構強引な上に手順が多く面倒くさい。
色々と面倒なので、現状ではそもそもハイブリッドアプリに日本語Webフォントを使うことをお勧めしない。

フォントファイルの準備

ここから実践。

フォントのダウンロード

フォントはNoto Sans JPを使う。
リンク先のDownloadから落としたzipを解凍して、使用するフォントファイルを取り出す。

実用にはRegularとBold2種類のフォントがあった方が良いと思うが、ここでは簡単にするためRegularフォントのみ扱う。

  • NotoSansJP-Regular.otf (2.3MB)

ちなみにNoto Sans CJK JPというフォントもあるが、こちらはおそらく含む文字が多くサイズがでかすぎるので、そのまま使うのは厳しい。

フォーマットをwoff2に変換する

落としたファイルはotfのフォーマットなのでWebで使えるwoff2の形に変換する。
変換にはWOFFコンバータというツールを使わせて頂いた。
使い方は非常に簡単で、otfファイルを設定したら「WOFF2を作成する」にチェックを入れて「変換開始」ボタンを押すだけ。

スクリーンショット 2018-07-08 18.35.19.png

「変換開始」を押すとアプリが固まるが、変換に時間がかかっているだけなのでしばらく待とう。

ファイルをBase64に変換する

上で作成したwoff2ファイルをBase64形式に変換する。

WebにBase64変換ツールはたくさんあるが、数MBのバイナリを変換できるものは意外と少ない。
変換にはこちらのエンコーダーを使用した。
ファイルでダウンロードできるとありがたいが、変換結果はブラウザにテキストで出力されるので、コピーしてエディターに貼り付けて保存する。

ファイル名は任意で良いが、ここでは以下のようにしておく。

  • NotoSansJP-Regular.woff2.base64 (2.3MB)

ネイティブアプリの実装

Cordovaなど使うフレームワークによって変わってくるが、ここでは素のWebViewで実装する。

ネイティブ側がやるのはBase64のフォントデータをWebViewのJavaScript側に渡すこと。
JavaScript側はwindow.setBase64FontData()でデータを受け取る前提にしているので、ネイティブ側でこの関数を呼び出す。

iOSの実装

Base64にしたフォントファイルは、ドラッグアンドドロップでプロジェクトに突っ込めばOK。
あとは、JavaScript側からtransferFontDataという名前でネイティブの処理を呼び出せるようにして、呼び出されたらwindow.setBase64FontDataでBase64のテキストをJavaScript側に渡す。

iOSサンプル
class ViewController: UIViewController, WKScriptMessageHandler {

    var webView: WKWebView?

    override func viewDidLoad() {
        super.viewDidLoad()

        let contentController = WKUserContentController()
        contentController.add(self, name: "transferFontData")

        let config = WKWebViewConfiguration()
        config.userContentController = contentController

        let webView = WKWebView(frame: CGRect.zero, configuration: config)
        self.webView = webView

        view.addSubview(webView)

        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.leadingAnchor.constraint(equalTo:view.leadingAnchor).isActive = true
        webView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        // SafeAreaを使っているのでiOS10未満に対応するなら多少修正が必要
        webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        if let url = URL(string: "http://192.168.2.127:8020") {
            webView?.load(URLRequest(url: url))
        }
    }

    /// WebViewからのJavaScript呼び出しをHandleする
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        transferFontData()
    }

    /// フォントデータをJavaScript側に渡す
    func transferFontData() {
        if let path = Bundle.main.path(forResource: "NotoSansJP-Regular.woff2", ofType: "base64"),
            let base64 = try? String(contentsOfFile: path) {
            let script = "window.setBase64FontData('\(base64)');"
            webView?.evaluateJavaScript(script, completionHandler: nil)
        }
    }
}

Androidの実装

フォントファイルはassetsディレクトリに置く。

スクリーンショット 2018-07-09 20.47.59.png

Androidサンプル
public class MainActivity extends AppCompatActivity {

    WebView webView = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView = findViewById(R.id.mainWebView);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.addJavascriptInterface(this, "Android");
        webView.loadUrl("http://192.168.2.127:8020");
    }

    @JavascriptInterface
    public void transferFontData(final String[] args) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                try {
                    String base64 = readAssetString(getAssets(), "NotoSansJP-Regular.woff2.base64");
                    String script = "window.setBase64FontData('" + base64 + "');";

                    webView.evaluateJavascript(script, null);
                } catch (IOException e) {
                    throw new IllegalStateException(e);
                }
            }
        });
    }


    private static String readAssetString(AssetManager asset, String fileName) throws IOException {
        InputStream input = null;
        BufferedReader reader = null;
        StringBuilder builder = new StringBuilder();

        try {
            input = asset.open(fileName);
            reader = new BufferedReader(new InputStreamReader(input));

            String line;
            while ((line = reader.readLine()) != null) {
                builder.append(line);
            }
        } finally {
            if (input != null) input.close();
            if (reader != null) reader.close();
        }

        return builder.toString();
    }
}

Web(JavaScript)の実装

window.setBase64FontDataでネイティブからBase64のフォントデータを受け取り、StyleSheetのfont-faceに追加する。
本連携方法は、font-faceのURLにBase64でデータを直書きできる仕様により成り立っている。

フォントデータが読み込まれるまで待機する必要があるが、そこらへんのコードはちょっと怪しい。
(Android5未満はdocument.fontsが使えないかもしれない)

JavaScriptサンプル
class Launcher {

    start() {
        this.postToNative('transferFontData');
        this.waitFont();
    }

    // ネイティブアプリの処理を呼び出す
    postToNative(name, param) {
        let handler = undefined;
        if (window !== undefined &&  window.webkit !== undefined && window.webkit.messageHandlers !== undefined) {
            handler = window.webkit.messageHandlers;
        } else if (typeof Android !== "undefined") {
            handler = Android;
        }

        if (handler !== undefined && handler[name] !== undefined) {
            if (handler[name].postMessage !== undefined) {
                handler[name].postMessage(param);
            } else {
                handler[name](param);
            }
        }
    }

    // フォントデータが読み込まれるのを待つ
    waitFont() {
        let fontSet = document.fonts;
        if (fontSet !== undefined) {
            fontSet.load(`20px "Noto Sans JP"`); // 20pxは適当。サイズはたぶんなんでもいい?
            fontSet.ready.then((fontFaceSet) => {
                this.onFontLoaded();
            });
        } else {
            this.onFontLoaded();
        }
    }

    // フォントデータ読み込み完了時の処理
    onFontLoaded() {
        // 画面を表示
    }
}

// Base64のフォントデータをセットする(ネイティブから呼び出し)
window.setBase64FontData = (base64) => {
    let style = document.createElement('style');
    style.appendChild(document.createTextNode(`
        @font-face {
            font-family: 'Noto Sans JP';
            src: url('data:font/woff2;base64,${base64}');
            font-weight: normal;
        }
    `));
    document.head.appendChild(style);
};

new Launcher().start();

※JavaScriptのコードはES6なので、Babelを使って古いブラウザでも動くよう変換している。

あとはCSSでフォントにNoto Sans JPを指定すればフォントを使うことができる。

CSS
html, body {
   font-family: 'Noto Sans JP';
}