retlat's blog

SwiftUI の NavigationView で複数回遷移する時がある

初めての iOS アプリを SwiftUI で書いてみたら、 NavigationLink の遷移が複数回発生したのでメモ
対処はしてないので、読み進めても何も解決策は書いてません

環境は

  • Xcode 12.4
  • Simulator (iOS 14.4)

挙動の再現するコードはこんな感じ
よくある NavigationBar のボタンで画面遷移するやつ
Timer を使用しているところは、実際に作ったものだとネットワーク経由のデータ取得をしてる

import SwiftUI

class ViewModel: ObservableObject {
  @Published var message = "aaa"

  init(_ number: Int) {
    Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
      self.message = "bbb"
      print("timer end \(number)")
    }
  }
}

struct MainView: View {
  @ObservedObject var vm: ViewModel

  var body: some View {
    NavigationView {
      Text(message)
        .toolbar {
          ToolbarItem(placement: .navigationBarTrailing) {
            NavigationLink(destination: SubView()) {
              Text("Show")
            }
          }
        }
    }
  }
}

struct SubView: View {
  @ObservedObject var vm: ViewModel

  var body: some View {
    Text("Sub view")
  }
}

これで Timer.scheduledTimer の block が実行される前に SubView に遷移すると、 block が実行されるタイミングで再度遷移する
ページのスタックが MainView -> SubView -> SubView になってしまうので、 NavigationBar の戻るをタップしても SubView が出る

なぜこの動きになるのかは調べたいけど、 Jetpack Compose の State Hoisting みたいに 状態管理を基点の View かその上にまで持ち上げる設計に直す方が優先な気がする

ちなみに SubView から ObservedObject を消すと発生しない
あとはこんな感じで toolbar から外すと遷移は 1 回だけになる

  struct MainView: View {
    @ObservedObject var vm: ViewModel
  
    var body: some View {
      NavigationView {
-       Text(message)
-         .toolbar {
-           ToolbarItem(placement: .navigationBarTrailing) {
-             NavigationLink(destination: SubView()) {
-               Text("Show")
-             }
-           }
-         }
+       VStack {
+         Text(message)
+         NavigationLink(destination: SubView()) {
+           Text("Show")
+         }
+       }
      }
    }
  }

Robolectric 4.4でJava 9必須のAPI Levelを対象とした時、Android Studio 4.0の同梱JDKで動かないのを回避する

AndroidでJSONを操作するところのUnit Testを書こうと思ったら、Robolectricが動かなくて困ったのでメモ

環境

  • Android Studio 4.0.1
  • Robolectric 4.4
  • targetSdkVersion 30

Robolectric 4.3.1からはリリースノートにある通り、API Level 29が対象の時にはJava 9が必須になっている
そしてAndroid Studio 4.0に同梱されているのはJava 8系なので動かない

解決の方法としては以下の2通りが考えられる

  1. Java 9以降のJDKをインストールする
  2. Robolectricの対象API Levelを下げる

個人的にはAndroid Studio以外にインストールするものがあるのは面倒だし、環境を作り直すときに確実に忘れるので 対象API Levelを下げる 方向で対処する

Robolectricで対象のAPI Levelを設定するには以下のような方法がある

  1. @Config annotationで指定する
  2. robolectric.propertiesで指定する
  3. RobolectricTestRunnerをextendする

ただどれも面倒だと思ってしまったので、今回は build.gradle.kts を編集する

公式サイトにあるようにRobolectricは targetSdkVersion に指定されているバージョンを対象にしている
このため、例えばbuild.gradle.ktsが以下のようになっているとAPI Level 29が使用される

android {
    defaultConfig {
        targetSdkVersion(29)
    }
}

であるならば、testの実行時だけtargetSdkVersionを書き換えれば、他のAPI Levelを使用するよう設定できる
どうするかというと

android {
    defaultConfig {
        targetSdkVersion(29)
    }
    testOptions {
        defaultConfig {
            targetSdkVersion(28)
        }
    }
}

これでUnit TestとInstrumented Testの両方に作用する
Unit Testだけに絞るなら以下のようにネストすれば良い

android {
    defaultConfig {
        targetSdkVersion(29)
    }
    testOptions {
        unitTests {
            defaultConfig {
                targetSdkVersion(28)
            }
        }
    }
}

macOSにNode.jsをtar.gzでインストールして、Gatekeeperを通す

macOS CatalinaにNode.jsをインストールするとき、趣味でHomebrewを使わず、tar.gzをダウンロードしてインストールしている
tar.gzのものはApple Notary Serviceを通していないので、Gatekeeperの検証に引っかかって node コマンドのプロセスに SIGKILL が送信されてしまう
検証の通し方は簡単なんだけど、いつも忘れてしまうのでメモ

Finderでnodeの実行ファイルがあるディレクトリを開いて

$ open /path/to/node
  1. 実行ファイルを右クリック
  2. メニューの 開く を選択
  3. ダイアログが出てくるので 開く をクリック

Vue.jsとReactのマウント動作比べ

仕事で使うのがVue.jsばかりだったのでReactとどう違うのか見てみる
とりあえずマウントした時の動作を

環境はVue.jsが 2.6.11 、Reactは 16.13.1

土台はこんな感じのHTMLで

<body>
  <div id="app">
    <span>Loading...</span>
  </div>
</body>

まずはVue.jsから
ソースはこんな感じで

new Vue({
  el: '#app',
  render(h) {
    return h('div', 'Hello, World!');
  }
});

実行した結果はこうなってて、 div#app が消えてる

<body>
  <div>Hello, World!</div>
</body>

次にReactで、ソースはこう

ReactDOM.render(
  React.createElement('div', null, 'Hello, World!'),
  document.querySelector('#app')
);

結果は div#app > span は消えるけど、 div#app は残ってる

<body>
  <div id="app"><div>Hello, World!</div></div>
</body>

VueReactどちらもドキュメントを読んだら書いてあるので、ちゃんと読むのは大事

Dockerのポートバインドで使用するホストIPを指定する

普段はDockerを使った開発環境で、ホストとコンテナのポートをバインドして他の端末から繋いでいる
公衆無線LANの環境で作業する必要が出てきて、これ他の接続してるユーザーから見えてダメじゃんと思ったのでIPを縛るように変えてみる

とりあえず普段使う感じで確認
0.0.0.0 にバインドされているのがわかる

$ docker run --rm -d -p 80:80 nginx:alpine
$ docker ps --format "table {{.Image}}\t{{.Ports}}"
IMAGE               PORTS
nginx:alpine        0.0.0.0:80->80/tcp

リファレンスに書いてある通りホストのIPを指定する
これだとホストの外からはアクセスできない

$ docker run --rm -d -p 80:80 nginx:alpine
$ docker ps --format "table {{.Image}}\t{{.Ports}}"
IMAGE               PORTS
nginx:alpine        127.0.0.1:80->80/tcp

Docker Composeでも同じようにホストのIPを指定できるので、場所に応じて適切に使い分けたい

Array.prototype.mapとshallow copy

Arrayのdeep copyはどう作るのかなと思ってArray.prototype.map()を触っていたら、パッと思ったのと挙動が違うのでメモ
環境はSafari 13.0.5

まずArray.prototype.map()はMDNには以下のように記載されている

map() メソッドは、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成します。

ということで雑にcallbackFnで引数をそのまま返してみる

const a = [{a: 'aaa'}];
const b = a.map(v => v);

b[0].a = 'bbb';

console.log(a, b);
// Array (1)
//   0 {a: "bbb"}
// Array (1)
//   0 {a: "bbb"}

あれ? 新しいArrayが生成されるのに変数aの値も変わってる?
いや、Arrayとしては新しく生成されたけど、要素単位ではobjectだからshallow copyされてる?
ということでプリミティブ値のArrayで実験

const a = ['aaa'];
const b = a.map(v => v);

b[0] = 'bbb';

console.log(a, b);
// Array (1)
//   0 "aaa"
// Array (1)
//   0 "bbb"

変数aの方には影響が出なかったので、やっぱり要素がobjectだとそこだけはshallow copyみたい
ということは型の混ざるArrayだと?

const a = ['aaa', {a: 'aaa'}];
const b = a.map(v => v);

b[0] = 'bbb';
b[1].a = 'ccc';

console.log(a, b);
// Array (2)
//   0 "aaa"
//   1 {a: "ccc"}
// Array (2)
//   0 "bbb"
//   1 {a: "ccc"}

思った通りに混ざった
代入の仕様そのままだけど思い込みで違和感たっぷり

Laravel Mixのwebpack設定を出力する

Laravel Mixを使うとBabelのトランスパイルやSASSのコンパイルが簡単にできるけど、webpackが何してるかさっぱりなので設定を出力してみる
setup/webpack.mix.jsにAPIが書いてあったので以下の通りにすると出力される

const mix = require('laravel-mix');
const util = require('util');

mix.override(webpackConfig => {
  console.log(
    util.inspect(webpackConfig, false, null, true)
  );
});

IISでCache-Controlヘッダー出力

Vue.js + Vue Routerを普段使わないIISで配信する必要に迫られて、web.configでCache-Controlヘッダーの出力設定をしたのでメモ
Vue Routerはhashモードなのでrewriteはいらなかった

web.configのリファレンスはどこをどう探したらいいのかさっぱり分からない
ディレクトリごとの設定をまとめてできないのが不便

<configuration>
  <system.webServer>
    <defaultDocument enabled="true">
      <files>
        <clear />
        <add value="index.html" />
      </files>
    </defaultDocument>
    <httpProtocol>
      <customHeader>
        <add name="Cache-Control" value="no-cache" />
      </customHeader>
    </httpProtocol>
  </system.webServer>
  <location path="js">
    <system.webServer>
      <httpProtocol>
        <customHeader>
          <remove name="Cache-Control" />
          <add name="Cache-Control" value="max-age=86400" />
        </customHeader>
      </httpProtocol>
    </system.webServer>
  </location>
  <location path="css">
    <system.webServer>
      <httpProtocol>
        <customHeader>
          <remove name="Cache-Control" />
          <add name="Cache-Control" value="max-age=86400" />
        </customHeader>
      </httpProtocol>
    </system.webServer>
  </location>
  <location path="images">
    <system.webServer>
      <httpProtocol>
        <customHeader>
          <remove name="Cache-Control" />
          <add name="Cache-Control" value="max-age=86400" />
        </customHeader>
      </httpProtocol>
    </system.webServer>
  </location>
</configuration>

Nginxで静的ファイルをリバースプロキシキャッシュ

Nginxのリバースプロキシで他のサーバにあるファイルをキャッシュする

まずhttpブロックでキャッシュファイルを保存するパスとキャッシュのパラメータを設定する

http {
    proxy_cache_path /var/nginx/cache keys_zone=static_assets:1m max_size=1g;
}

keys_zoneが1MBで8,000個のデータに関する情報を保存できるので, 1ファイル100KBと仮定すると800MB位になる
そんなに大きくないのでmax_sizeを全て格納できそうな1GBにする

次にserverブロックにリバースプロキシの設定を記述していく

upstream resource_server {
    server resource.example.com;
}
server {
    location /(css|js)/ {
        proxy_pass http://resource_server;
        proxy_cache static_assets;
        proxy_cache_valid 200 10m;
        add_header X-Cache-Hit $upstream_cache_status;
    }
}

proxy_cache_pathの keys_zone で指定したzone名を proxy_cache に設定し, proxy_passに記述したサーバからのレスポンスを格納する
proxy_cache_validではレスポンスのステータスコードごとにキャッシュ時間の制御ができる
ここでは200が返って来たら10分間キャッシュするように設定している
キャッシュが使われたか等の情報は $upstream_cache_status 変数が持っているのでヘッダーとして出力すると確認できる
ドキュメントには値の意味が見当たらなかったが, Nginxのブログには書いてあった

AndroidからJDBC DriverでPostgreSQLに接続

ちょっと作りたいものがあるので, AndroidからDocker Desktop for Macで動かしているPostgreSQLにJDBC Driverで接続できるか実験

環境

  • Android Studio 3.5.2
  • Android Emulator 29.2.1
  • Nexus 5
  • PostgreSQL JDBC Driver 42.2.8
  • postgres:11.5 Docker image

実験用ソース

Android Studioの Start a new Android Studio Project から, Empty ActivityでMinimum API level 23なプロジェクトを作る
これに必要な分をちょっとだけいれたものを使う
まずはapp/build.gradleのdependenciesにドライバを追加する

dependencies {
    // 略
    implementation 'org.postgresql:postgresql:42.2.8'
    // 略
}

次に適当な接続してSQLを実行するコードを, MainActivity.ktに追加する

override fun onResume() {
    super.onResume()

    thread {
        val c = DriverManager.getConnection("jdbc:postgresql://10.0.2.2/postgres?user=postgres&password=password")
        val s = c.createStatement()
        val r = s.executeQuery("CREATE TABLE IF NOT EXISTS sample (id SERIAL PRIMARY KEY, number INTEGER)")
        r.close()
        s.close()
        c.close()
    }
}

ネットワークアクセスするのでパーミッションの記述をAndroidManifest.xmlに追加する

+ <uses-permission android:name="android.permission.INTERNET" />

Emulator と実機の比較

最近の端末を持っていないのでエミュレータを使うとして, 実機と同じように動作するかテストする
Android 6.0.1がインストールされたNexusとAPI 23のエミュレータで実行したところ, 同じように以下のExceptionが出力された
カスタマイズして出荷されている可能性があるので一概に言えないが, とりあえずエミュレータが実機と同じように動作すると判断する

E/AndroidRuntime: FATAL EXCEPTION: Thread-147
    Process: com.example.sample, PID: 2812
    java.sql.SQLException: No suitable driver
        at java.sql.DriverManager.getConnection(DriverManager.java:186)
        at java.sql.DriverManager.getConnection(DriverManager.java:144)
        at com.example.sample.MainActivity$onResume$1.invoke(MainActivity.kt:19)
        at com.example.sample.MainActivity$onResume$1.invoke(MainActivity.kt:8)
        at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)

実験

いろいろなバージョンで試してみる
とりあえず上の実験でAPI 23はダメだったので, それ以降を順次試す
API 24, 25はクラスが見つからないのでダメ

java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/Duration;
 Caused by: java.lang.ClassNotFoundException: Didn't find class "java.time.Duration" on path: DexPathList[[zip file "/data/app/com.example.sample-1/base.apk"],nativeLibraryDirectories=[/data/app/com.example.sample-1/lib/x86_64, /system/lib64, /vendor/lib64]]

API 26から29はExceptionがthrowされているものの, SQLを実行した結果なのでOK

org.postgresql.util.PSQLException: No results were returned by the query.

追加実験 (追記: 2019/12/30)

API 24のExceptionはJava 8およびAPI 26で追加された java.time.Duration を使っているからダメなだけで, DriverをJava 7用にしたら問題なかったのではと思い立ったので実験
app/build.gradleのdependenciesを以下のように書き換える

  dependencies {
      // 略
-     implementation 'org.postgresql:postgresql:42.2.8'
+     implementation 'org.postgresql:postgresql:42.2.8.jre7'
      // 略
  }

他は変更せずに動かしてみるとAPI 26以降と同じ org.postgresql.util.PSQLException が投げられたので接続OK

結論

Android 7.0 NougatからJDBC DriverでPostgreSQLに接続できる
なおAndroid 7.0, 7.1 はJava 7用を使う必要があるが, Android 8.0 OreoからはJava 8用で構わない
ただし端末による