20200708のiOSに関する記事は5件です。

NavigationControllerで戻る画面を自在に書き換える

NavigationControllerを利用したページ移動をしていて、戻るページのスタックを自分の好きなように書き換えたくなった時があった。2ページ前に戻るとか、ある条件を満たした時だけ戻れるページの上限が変わるなど。

NavigationController.viewControllerの仕組み

navigationController?.viewControllers // [1pページ目, 2pページ目, 3pページ目, 今のページ]

NavigationControllerは上記のような感じで配列としてこれまで遷移したページを持っている。
基本的にはこれをpopしたりpushしたりする事で1画面遷移する。

ViewControllerが入った配列

ViewControllerが入った配列なので、この配列を好きなように操作してやれば戻るページを書き換えられる。

ただ、基本的にはスタックとして使う事を想定しているはずなので行儀は良くない気がする。

UIViewControllerクラス内で

let fugaView = self.storyboard?.instantiateViewController(withIdentifier: "FugaViewWrapController") as! FugaViewController
let hogeView = self.storyboard?.instantiateViewController(withIdentifier: "MainViewController") as! MainViewController            

self.navigationController?.viewControllers = [fugaView, hogeView, self]

こうしてやると、これまでどんな遷移をしていようが次 hogeView → fugaView の順番に戻るようになる。

こんな感じで好きなようにカスタマイズ出来る、と思う。多分。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Mobile Safari は再起動時に POST リクエストする場合がある

Rails アプリケーションで、iPhone の Safari からのリクエストでだけ ActionController::InvalidAuthenticityToken 例外が発生するので調査していたところ、Mobile Safari は再起動後、開いていたタブをロードする際に、POST リクエストで得たページであっても再度リクエストを送信しているらしいことがわかった。

※ iOS 13.5.1 の iPhone 11 で検証

検証方法

下記の Ruby スクリプトを作成して ruby server.rb -o 0.0.0.0 で起動

# gem install sinatra が必要
require "sinatra"

html = <<~HTML
<!doctype html>
<form action="/" method="post">
  <input type="text" name="email">
  <button type="submit">Submit</button>
</form>
HTML

get "/" do
  html
end

post "/" do
  p params
  html
end
  1. Mobile Safari で http://[上記スクリプト実行したPCのアドレス]:4567 にアクセス
  2. 適当に入力して submit
  3. Mobile Safari を終了
  4. Mobile Safari を起動 (確認などなしでページが表示される)

この手順でログを見ると、4 で 2 と同じリクエストが送られていることが確認できた。

x.x.x.x - - [08/Jul/2020:14:24:37 +0900] "GET / HTTP/1.1" 200 131 0.0072
{"email"=>"foo"}
x.x.x.x - - [08/Jul/2020:14:24:41 +0900] "POST / HTTP/1.1" 200 131 0.0022
{"email"=>"foo"}
x.x.x.x - - [08/Jul/2020:14:24:48 +0900] "POST / HTTP/1.1" 200 131 0.0007

Rails で InvalidAuthenticityToken 例外が起こる理由と対策

Rails で...

  1. Cookie をセッションストアとして使っていて、かつ有効期限が Session (デフォルト)
  2. protect_from_forgery with: :exception を指定している

場合に、上記のように 2 度 POST リクエストが送られると、2 度めのリクエストでは Cookie がクリアされているので session[:_csrf_token] が nil になり、params[:authenticity_token] の検証が失敗し、InvalidAuthenticityToken 例外が発生してしまう。

解決策としては下記のものが考えられる。

  1. POST でページを render せず、redirect してかならず GET させるようにする
  2. protect_from_forgery で null_session などにする
  3. Cookie の有効期限を伸ばす

3 の場合は config/initializers 下に適当なファイルを作って下記の内容を記述すればよい。

# key の部分はなんでもいいがデフォルトは "_#{アプリケーション名}_session"
# ここでは有効期限を 2 週間にしている
Rails.application.config.session_store :cookie_store, key: "_foo_session", expire_after: 2.weeks
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

アプリ�APIの負荷テストを自動生成したいと思った

概要

  • iOSアプリのバックエンドAPIに対して、負荷テストをしたい

    • アプリの使用開始時の挙動(APIコール内容)をシナリオ的に検証したい
      • API:Aにコールが集中している状況でAPI:Bをコールすると発生する問題がある スクリーンショット 2020-07-18 21.34.03.png
  • アプリの通信内容をキャプチャして、テストシナリオを自動で生成できないか?

    • 経験則からテスト対象APIを絞ることはできるが、意図しない組合せでAPIシステムが落ちることを防げない
    • アプリのテストケースはない前提
  • プロトタイプ的にかんたんに動くものを作ってみた

注意事項

  • 負荷テストは自身が管理するシステム以外に実行しないでください

登場人物

  • アプリ アプリ

    • APIコールをするやつ
  • charles for ios charles for ios

    • アプリの通信内容をキャプチャするアプリ
  • API APIサーバ

    • アプリのバックエンド: REST APIサーバ
  • locust locust

    • 負荷試験ツール
  • スクリーンショット 2020-07-15 12.06.29.png僕: システムエンジニア

    • 許されることなら何もしたくない

やりたいこと

概要

  • 負荷テストシナリオを生成する
    スクリーンショット 2020-07-18 21.34.30.png

  • 負荷テストシナリオを実行する
    スクリーンショット 2020-07-18 21.36.32.png

詳細

負荷テストシナリオを生成する

  • charles for iosでアプリの通信内容(chlsj)をキャプチャし、エクスポートする
    -> charles session json(chlsj)

  • charles session json(chlsj)からシナリオテンプレート(CSV)を生成する

変換スクリプト
$python chlsj_2_scenario_csv.py {シナリオ名}.chlsj
   -> {シナリオ名}_template.csvが生成される
chlsj_2_scenario_csv.py
  import json
  import sys
  import csv
  import dateutil.parser
  import os

  initial_time = None


  def session_json_2_scenario_csv(data):
      request = data['request']

      request_body = ''
      if ('body' in request) and ('text' in request['body']):
        request_body = request['body']['text']

      # 呼び出し開始msecを計算
      run_at = dateutil.parser.parse(data['times']['requestBegin'])
      global initial_time
      initial_time = initial_time or run_at
      run_msec_at = int((run_at-initial_time).total_seconds()*1000)

      # headerを{"name":"Authorizatoin","value":"~~~"}から{"Authorizatoin": "~~~"}方式に変換
      request_header={}
      for kv in data['request']['header']['headers']:
        request_header[kv['name']] = kv['value']

      # 不要なヘッダーを削除
      del request_header['Host']

      return {
        "start_msec_at": run_msec_at,
        "method": data['method'],
        "request": '?'.join( filter(None,[data['path'],data['query']])),
        "request_header":json.dumps(request_header),
        "request_body":request_body,
        "response_to_variable": None
      }

  def chlsj_2_scenario(chlsj_path):
    scenario = []

    with open(chlsj_path) as in_file:
      for request in json.load(in_file):
        scenario.append( session_json_2_scenario_csv(request) )

    out_file_path = os.path.basename(chlsj_path).split('.', 1)[0] + "_template.csv"
    with open(out_file_path, 'w') as csvfile:
      writer = csv.DictWriter(csvfile, fieldnames=scenario[0].keys(),quotechar="'",quoting=csv.QUOTE_NONNUMERIC)
      writer.writeheader()
      writer.writerows(scenario)

  if __name__ == '__main__':
    chlsj_2_scenario(sys.argv[1])
  • シナリオテンプレ(CSV)からシナリオ(CSV)を作る
    • 必要に応じてシナリオ_テンプレの項目を変更し、「シナリオ」を作成する
start_msec_at method request request_header request_body response_to_replace
テスト開始から何秒でコールするか
※プロトタイプでは未実装
HTTP メソッド
GET/POST/PATCH
REST path+クエリ HTTP header HTTP request body レスポンス内容を以降のリクエストで使用したい場合、
{変数名: '値の場所'}のjsonで変数をセットする
    • 「POST /api/user_devices」をたたき、response bodyの「oauth_access_token.access_token」を「auth_token」として以降のリクエストで使用する
      • header/bodyに"auth_token"の文字列が含まれる場合、「oauth_access_token.access_token」の内容にリプレースされる
start_msec_at method request request_header request_body response_to_replace
0 POST' /api/user_devices' {"X-LANGUAGE": "ja_JP", "X-TIME-ZONE": "Asia/Tokyo", "Authorization": "Bearer ~~~", "Accept": "/"} {"auth_token": "oauth_access_token.access_token"}'

負荷テストシナリオを実行する

  • シナリオからlocustテストを実行する
locust起動
## 初回のみlocustをインストールする
$pip install locust
$script_csv={シナリオ}.csv locust -f scenario.py -H https://{サーバアドレス}
scenario.py
from locust import HttpUser,task, between

import os
import csv
import json

def is_json(string):
    try:
        json_object = json.loads(string)
    except ValueError as e:
        return False
    return True

def xpath_get(mydict, path):
    elem = mydict
    try:
        for x in path.strip(".").split("."):
            elem = elem.get(x)
    except:
        pass

    return elem
class ScenarioLoadTest(HttpUser):
    wait_time = between(0.500, 1)

    @task
    def test(self):
        api_list_csv_path = os.environ['script_csv']

        replaces = {}
        with open(api_list_csv_path) as f:
            replace_map={}
            for row in csv.DictReader(f,quotechar="'",quoting=csv.QUOTE_NONNUMERIC):
                _request      = row['request']
                _header       = row['request_header']
                _request_body = row['request_body'].encode('utf8')

                # 変数のレンダー(文字リプレース)
                for key in replace_map:
                    _request      = _request.replace(key,replace_map[key])
                    _header       = _header.replace(key,replace_map[key])

                request = _request
                header = json.loads(_header)
                request_body = _request_body

                response = None
                if row['method']=='GET':
                    self.client.get(request,headers=header)
                elif row['method']=='POST':
                    raw_response = self.client.post(request,headers=header,data=request_body)
                    response = raw_response.json()
                elif row['method']=='PATCH':
                    self.client.patch(request,headers=header,data=request_body)
                else:
                    print('called else ' + str(row))

                # 変数の保存
                if is_json(row['response_to_replace']):
                    replace_map = json.loads(row['response_to_replace'])
                    for key in replace_map:
                        replace_map[key] = xpath_get(response,replace_map[key])
  • 負荷テストできた! 9ba8374c-8e76-4d8b-1e40-fbd8e582be61.jpg

今後やりたいこと

  • aws device farmでアプリの表示テストを動かしたい
    • そのついでにスクリプトを自動生成できない?
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Danger用にxcpretty-json-formatterで テストを実行して ld: symbol(s) not found for architecture x86_64 になった時に対応したこと

前段

  • BitriseのWorkflow内でDangerを回している
  • danger-xcode_summaryを利用
  • xcpretty-json-formatterも利用
  • BitriseのXcodeTest for iOSは利用せずスクリプトでテスト実行
  • FirebaseはCocoaPodsで入れる
  • Xcode11.3.1, CocoaPods1.5.3, FirebaseCore6.6.6

現象

xcpretty-json-formatterの出力を指定した以下のスクリプトでテストビルドを実行

set -o pipefail && env "NSUnbufferedIO=YES" xcodebuild -workspace $WORKSPACE -scheme $SCHEME \ 
clean build test -destination "platform=iOS Simulator,name=iPhone Xs,OS=12.4" \
GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES | \ 
XCPRETTY_JSON_FILE_OUTPUT=xcodebuild.json xcpretty -f `xcpretty-json-formatter`

CocoaPodsにてFirebase/Coreを入れたタイミングで上記スクリプトにて表題のエラーが発生スクリーンショット 2020-07-08 11.00.40.png

対応内容

Build Settings > Other Linker Flags-fprofile-arcsを追加

スクリプトは以下に変更

set -o pipefail && env "NSUnbufferedIO=YES" xcodebuild -workspace $WORKSPACE -scheme $SCHEME \ 
clean build test -destination "platform=iOS Simulator,name=iPhone Xs,OS=12.4" \ 
"GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES" "GCC_GENERATE_TEST_COVERAGE_FILES=YES" \
"OTHER_LDFLAGS=\$(OTHER_LDFLAGS) -fprofile-arcs" | \ 
XCPRETTY_JSON_FILE_OUTPUT=xcodebuild.json xcpretty -f `xcpretty-json-formatter`

番外編

Dangerとは関係ないのですがUITestでもCocoaPodsにてFirebase/Coreを入れたタイミングでエラーが発生したので合わせて記載します。

UITest実行時に 「バンドル“アプリ名UITests”は、壊れているか必要なリソースがないため読み込めませんでした。 …」 が吐かれていたのですが以下の対応で通るようになりました

Build Settings > Runpath Search Paths$(FRAMEWORK_SEARCH_PATHS)を追加

参考リンク

助けて頂きました。ありがとうございます?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UIKitに依存するSwift PackageをVSCodeで開発する

XcodeがクソすぎてVSCodeが好きすぎてSwiftのコードを書くのにもVSCodeを使いたい!
と思って調べてみたところ、どうやらSwift PackageのプロジェクトならVSCodeでもそれなりにコード補完できるようになる、ということがわかりました。

こちらにあるのがApple公式のsourcekit-lsp(Swiftの構文補完のためのLanguage Server)リポジトリで、中を見るとVSCode用の拡張機能のコードもあるみたい。

なので、まずはそちらのREADMEに従ってVSCodeのsourcekit-lsp拡張機能をインストールします。上記のsourcekit-lspのリポジトリをクローンしてきて、

$ cd Editors/vscode
$ npm run createDevPackage
$ code --install-extension out/sourcekit-lsp-vscode-dev.vsix

とするだけです!

※ Xcodeのインストールが必要です。
sourcekit-lspコマンドが実行できるようになっている必要があります。最近のXcodeなら入ってる…?もしくは上記リポジトリからビルドする。
※ npm コマンドを使うために、Node.jsのインストールが必要です。
※ code コマンドを使うために、VSCodeでCmd+Shift+PからInstall code command in PATHを実行する必要があります。

ではさっそくプロジェクトを作ります。

swift package init

プロジェクトができたら、さっそくコードを書いてみましょう。Sources/my-swift-ios(作ったディレクトリにより異なる)の中にMyViewController.swiftというファイルを作って次のようなコードを書きます。

import UIKit

…おや、import UIKitのところに赤い線が引かれて no such module UIKit と言われていますね。

そしたらVSCodeの設定画面を開き(Cmd+,)、右上の「設定(JSON)を開く」アイコンを押してsettings.jsonを開きます。
そして、その中に以下のような指定を加えます。

{ 
    "sourcekit-lsp.serverArguments": [
        "-Xswiftc",
        "-sdk",
        "-Xswiftc",
        "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk",
        "-Xswiftc",
        "-target",
        "-Xswiftc",
        "x86_64-apple-ios13.5-simulator",
    ]
}

iPhoneSimulator13.5とかios13.5-simulatorとかいう部分はXcodeのバージョンにより正しい指定が異なるので、実際に/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKsの場所をFinderで開いて番号を確認してください。

設定ができたら、VSCodeを再起動(もしくはCmd+Shift+P→Reload Window)します。

改めて先程のswiftファイルを開いてみましょう。赤い線が消えていると思います(消えていない場合、上記の-Xswiftcで渡しているパスが正しいかどうか確認してください)。

output.gif

コード補完できてる〜!!!!

ちなみに、先程指定したsourcekit-lsp.serverArgumentsの設定は、swift buildをするときにも同様に必要になります。
なので、.vscode/tasks.jsonの中にこんな感じで指定すると良いでしょう。

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "type": "shell",
            "command": "swift",
            "args": [
                "build",
                "-Xswiftc",
                "-sdk",
                "-Xswiftc",
                "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk",
                "-Xswiftc",
                "-target",
                "-Xswiftc",
                "x86_64-apple-ios13.5-simulator",
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

これでVSCodeのビルド(Cmd+Shift+B)をするだけでビルドできるようになります。

テスト

ここまで書いて気付いたのですが、テスト(swift test)が動かない…orz

.vscode/tasks.jsonに以下のテスト実行の追記をしてみましたが…

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "type": "shell",
            "command": "swift",
            "args": [
                "build",
                "-Xswiftc",
                "-sdk",
                "-Xswiftc",
                "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk",
                "-Xswiftc",
                "-target",
                "-Xswiftc",
                "x86_64-apple-ios13.5-simulator",
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        },
        {
            "label": "test",
            "type": "shell",
            "command": "swift",
            "args": [
                "test",
                "-Xswiftc",
                "-sdk",
                "-Xswiftc",
                "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk",
                "-Xswiftc",
                "-target",
                "-Xswiftc",
                "x86_64-apple-ios13.5-simulator",
            ],
            "group": {
                "kind": "test",
                "isDefault": true
            }
        }
    ]
}

error: module 'XCTest' was created for incompatible target と言われて実行できない。

2020/07/09追記:
ですが、xcodebuildコマンドを使うことでiPhone Simulator上でテストが可能であるということがわかりました!

.vscode/tasks.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "type": "shell",
            "command": "swift",
            "args": [
                "build",
                "-Xswiftc",
                "-sdk",
                "-Xswiftc",
                "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk",
                "-Xswiftc",
                "-target",
                "-Xswiftc",
                "x86_64-apple-ios13.5-simulator",
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        },
        {
            "label": "test",
            "type": "shell",
            "command": "xcodebuild",
            "args": [
                "-scheme",
                "my-swift-ios",
                "test",
                "-destination",
                "name=iPhone 8"
            ],
            "group": {
                "kind": "test",
                "isDefault": true
            }
        }
    ]
}

これでiOSのコードがVSCodeでもそれなりに開発できる…かも!?!?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む