1
3

オフラインのスマホ(iOS)でPythonを動かす

Last updated at Posted at 2024-02-23

概要

個人情報をサーバーに送らずにデータ分析したいといった要望はままあります。
また、分析するコードをPythonで開発後に、Swiftに移植するのはかなり手間です。
ソリューションとしてオフラインでPythonを実行できないか以前に調べた内容を紹介します。

これはPyodideをWebkitから実行する方法です。
将来的にはwasmer/pythonで直接実行できることでしょう。
ちなみに上記ディスカッションのotmbは私の個人アカウントです。

環境構築

適当にPyodideの最新版を引っ張ってきます。

$ curl -LO https://github.com/pyodide/pyodide/releases/download/0.26.0a2/pyodide-core-0.26.0a2.tar.bz2
$ tar xvjf pyodide-core-0.26.0a2.tar.bz2

解凍したpyodideフォルダをXcodeプロジェクト内に配置した上でReferenceになるように取り込みます。

  • 実際の配置
PyodideExample
├── Assets.xcassets
│   ├── AccentColor.colorset
│   │   └── Contents.json
│   ├── AppIcon.appiconset
│   │   └── Contents.json
│   └── Contents.json
├── ContentView.swift
├── Preview Content
│   └── Preview Assets.xcassets
│       └── Contents.json
├── PyodideExampleApp.swift
├── index.html
└── pyodide
    ├── package.json
    ├── pyodide-lock.json
    ├── pyodide.asm.js
    ├── pyodide.asm.wasm
    ├── pyodide.js
    ├── pyodide.mjs
    └── python_stdlib.zip
  • 青いフォルダ(Reference)になるように取り込む

コード

ContentView.swift
import SwiftUI
import UIKit
import WebKit

struct ContentView: View {
  var body: some View {
    VStack {
      WebView()
    }
    .padding()
  }
}

struct WebView: UIViewControllerRepresentable {
  typealias UIViewControllerType = WebViewController
  
  func makeUIViewController(context: Context) -> WebViewController {
    return .init()
  }
  
  func updateUIViewController(_ uiViewController: WebViewController, context: Context) {}
}

class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler {
  var webView: WKWebView!
  
  override func loadView() {
    let config = WKWebViewConfiguration()
    config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
    let controller = WKUserContentController()
    controller.add(self, name: "logging")
    config.userContentController = controller
    webView = WKWebView(frame: .zero, configuration: config)
    webView.uiDelegate = self
    webView.isInspectable = true
    
    view = webView
  }
  
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    print(message.name, message.body)
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    let htmlFileName = "index"
    do {
      guard let filePath = Bundle.main.path(forResource: htmlFileName, ofType: "html")
      else {
        throw NSError(domain: "Error: The file path is invalid.", code: -1, userInfo: nil)
      }
      let htmlString = try String(contentsOfFile: filePath, encoding: .utf8)
      webView.loadHTMLString(htmlString, baseURL: URL(fileURLWithPath: filePath))
    } catch let error {
      print(error.localizedDescription)
    }
  }
}
index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>Hello WKWebView</title>
      <script type="text/javascript">
        function jsLog (msg){
          window.webkit.messageHandlers.logging.postMessage(msg);
        }
        const console = {
          log: jsLog,
          info: jsLog,
          warn: jsLog,
          error: jsLog,
        }
        
        async function fetch(filename) {
          const promise = new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open('GET', filename);
            xhr.responseType = 'arraybuffer';
            xhr.addEventListener('load', (e) => resolve(xhr.response));
            xhr.send();
          });
          
          const response = new Response(await promise.then(), {headers: new Headers([['Content-Type', 'application/wasm']])});
          
          return response;
        }
      </script>
      <script src="pyodide/pyodide.js"></script>
  </head>
  <body>
    Pyodide test page <br>
    Open your browser console to see Pyodide output

    <script type="text/javascript">
      async function main(){
        let pyodide = await loadPyodide({
          stdin: jsLog,
          stdout: jsLog,
          stderr: jsLog,
        });
        console.log(pyodide.runPython(`
              import sys
              sys.version
          `));
          pyodide.runPython("print(1 + 2)");
      }
      main();
    </script>

  </body>
</html>

実行結果

logging 3.12.1 (main, Feb 15 2024, 01:00:57) [Clang 18.0.0git (https://github.com/llvm/llvm-project 0a3a0ea5914cb4633f4f4c14f
logging 3

コードの説明

PyodideはJSのfetchを利用してファイルを取得します。
ところがWKWebViewはローカルファイルの取得にXMLHttpRequestは機能しますがfetchは機能しません。
なのでfetchを上書きします。

async function fetch(filename) {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', filename);
    xhr.responseType = 'arraybuffer';
    xhr.addEventListener('load', (e) => resolve(xhr.response));
    xhr.send();
  });
  
  const response = new Response(await promise.then(), {headers: new Headers([['Content-Type', 'application/wasm']])});
  
  return response;
}

以上。簡単なハックでオフラインで動作します。

おまけ

機械学習ライブラリを使う場合は、フルサイズのPyodideに置き換えると良い。

$ curl -LO https://github.com/pyodide/pyodide/releases/download/0.26.0a2/pyodide-0.26.0a2.tar.bz2
$ tar xvjf pyodide-0.26.0a2.tar.bz2
index.html
<script type="text/javascript">
      async function main(){
      ...
          await pyodide.loadPackage(["numpy", "scikit-learn"]);
          await pyodide.runPython(`
              import numpy as np
              a = np.array([1.0, 2.0, 3.0])
              b = np.array([a, (10, 20, 30)])
              print(b)
          `);
      }
      main();
</script>

`);

実行結果

指定したライブラリの依存関係含めて読み込みます。

logging Loading numpy, scikit-learn, scipy, openblas, joblib, threadpoolctl
logging Loaded joblib, numpy, openblas, scikit-learn, scipy, threadpoolctl
logging [[ 1.  2.  3.]
logging  [10. 20. 30.]]
1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3