20200803のiOSに関する記事は10件です。

Create remote にて、画面を押せない対処法。

 Githubのリポジトリ作成で、コケた箇所。

先程、Githubに初pushできた㊗︎。
ちょっと顛末をまとめます。

 結論

Create remote on Githubでcreateが押せなかった。

下は、create操作可能になってからのスクリーンショット。
解決策は、こちら。XcodeのCreate remote on Githubでcreateが押せない

補足をちょっとまとめます。

?pushまでの基本手順はこちら。XcodeとGitHubの連携方法 (※1)

スクリーンショット 2020-08-03 20.51.57.png

 補足をちょっとまとめます。

- Xcodeタブの場所

(前述のサイト引用)

Xcode > Preferences > Accountsから自分のGithubアカウントを選択すると、

ディスプレイ左上、?の隣の『Xcode』。

-Xcodeを選択している状態なら、『Xcode』
-Chromeを選択している状態なら、『Chrome』
-Apple Musicを選択している状態なら、『ミュージック』が表示されます。

- personal access tokenってなんジャ?

画面右下の「+」マークを押し、下記画像になり、Githubを選択したら、
githubAccountName と、personal access tokenが求められると思います。

スクリーンショット 2020-08-03 20.40.34.png

青い文字の「create personal access token」を選択。
そこから先は、こちらを参考にどうぞ。
Creating a personal access token

※1?

pushまでの基本手順はこちら。XcodeとGitHubの連携方法 (※1) に関して、

2.Git Repositoriesを作成する

Masterブランチは、下記画像をチェックしたらプロジェクト生成した直後に自動生成されるみたい。

initial Commitって言うらしい?

(プロジェクト作成時の画像↓)

追記:

後日GitHubを確認したところ、初期サンプルコードしかpushされてなくて、撃沈。GitHub
なるほど、だからinitial Commitって言うのか〜?

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }


}

XcodeとGitHubの連携方法の手順通りに、また今度やってみます。

おまけ

gitに関してあれやこれやは、こちら。
いまさらだけどGitを基本から分かりやすくまとめてみた

んー、詳しい事は分からないけど、まあその内覚えるかなぁ...ということで保留。

綺麗にまとめて下さっているので、いつか学び直したい。
とりあえず今は、Swift言語やりたい。

 参考サイト

XcodeとGitHubの連携方法
Xcodeでgit操作(ブランチを作ってみる)
いまさらだけどGitを基本から分かりやすくまとめてみた

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

道案内ボランティア向けのアプリ開発プロジェクト

1 はじめに

みなさんはじめまして。塚原卜伝と申します。

仕事ではC,C++,pythonを扱っています。

仕事の合間に外国人道案内ボランティア団体であるask me!での活動に参加しています。

ask me! HP : https://askme.ne.jp/
Twitter  : https://twitter.com/askmeJAPAN
Facebook  : https://www.facebook.com/weareaskme
Instagram  :https://www.instagram.com/we.are.askme/?hl=ja

2020年の3月にask me!での活動の効率化のためにAndroid&iOSアプリ(ask me! app)をリリースしました。

今回初めての個人開発で大変な所も多かったですが、楽しい所もありました。

今回は自分がどのように「ask me! app」を作っていったのかをまとめたいと思います。

2 ask me!の活動紹介

ask me! appはask me!での活動がベースになって作られたものなので、まずは活動内容の説明をしたいと思います。

活動中は困っていそうな外国人に積極的に話しかけていき、彼らの困っている内容を聞き出します(例:おすすめの寿司のお店はどこ?など)。

外国人の方を目的地までご案内いたしますが、我々はプロではないので、あくまでも自分たちの知っている範囲でorGoogle やスマートフォンで調べて分かる範囲でお伝えしています。

3 アプリを作成しようと思ったきっかけ

きっかけとしては2つあります。

一つ目は人数集計作業の簡素化のためです。

活動中に案内した方々の国籍や人数等は下記のシートに記入しています。

図1.png

一番最後に活動で関わった人達の合計人数を大陸別に集計するのですが、この作業を簡素化することを目指しました。

2つ目は、活動中に撮影した写真を参加者の間で共有するツールが必要だったためです。

今までは参加者同士でLINEのアカウント交換をしてLINE上で写真の共有を行っていましたが、LINEに頼らなくても写真を共有できるツールが必要でした。

以上の2点からask me!のオリジナルのアプリを開発することにしました!

4 UI

開発したアプリの主なUIを下記に示します。

図2.png

①ログイン画面

②活動する前に参加者や今日の目標等を登録する画面

③統計シート入力画面

④案内した合計人数を確認できる画面

⑤サイドバーメニュ

⑥写真共有機能画面

5 アプリの機能説明

①ログイン画面
ask me! appはask me!の活動参加者が使うことを想定していますので、一番最初にログイン画面を設けています。

②活動する前に参加者や本日の目標等を登録する画面
統計シートでは活動前に参加者の名前や本日の活動目標を記載していましたが、それをスマホアプリでも記入できるようにする画面です。

③統計シート入力画面
活動中に案内した外国人の国籍や人数を記入する画面です。

④案内した合計人数を確認できる画面
活動中に案内した人数一覧を確認できる画面です。

⑤サイドバーメニュ
ask me!のHPやSNSおよび写真共有機能画面へのリンクを貼っています。

⑥写真共有機能画面
活動中に撮影した写真はこの画面で参加者と共有します。

6 技術選定

ask me! app作成にはFlutterを利用しました。理由は同じソースコードでiOSとAndroidの開発が同時にできるからです。

バックエンドにはFirebaseを利用しました。

7 技術習得方法

私はFlutter初心者だったため、まずは本で勉強しました。Flutterに関する本は少ないのですが、下記の本で勉強しました。

Android/iOSクロス開発フレームワーク:https://books.rakuten.co.jp/rk/8f657c363ada3ef9bd62b9abafc2fa28/

全部読んでいると非常に時間がかかるので、最初の2章までを読みました。2章までを読むだけでも、基本的な内容はわかると思います。

なお、Flutterで分からないことを知らべる際は英語の記事が多いので、英単語を入力して調べるといいとおもいます。

実現したい機能を英語で入力するとサンプルコードが出てくるのでそれを見て勉強していました。
(例:写真共有機能なら「Flutter photo share」で検索してみる、など)

8 まとめ

今回はask me! appの簡単な紹介をさせていただきました。

次回以降は各画面についてソースコード例と共にどのように実装したのかを記載したいと思います。

また、興味があれば読んでいただけると幸いです(次回は8月下旬に投稿予定です)。

アプリは下記から

Android: https://play.google.com/store/apps/details?id=com.askme.flutter_app4
iOS : https://apps.apple.com/us/app/ask-me/id1481671421?l=ja&ls=1

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

【iOSアプリ】ライセンス一覧を設定アプリに自動表記する方法【LicensePlist】

今回はアプリ内で利用したライブラリのライセンス一覧を、簡単に設定アプリに表記する方法を紹介します。
ライブラリを新しく追加しても更新されるのでとても便利です。

スクリーンショット 2020-08-03 8.39.34.png

LicensePlistというライブラリを活用する

ライセンス一覧を手軽に表記することができる、かなりオススメのライブラリです。
有名なアプリでも結構使われているみたいです、開発者の方いつもお世話になっています!

https://github.com/mono0926/LicensePlist

①インストール

Cocoapodsを使いました。

pod ‘LicensePlist’とPodfileに入れてinstallします。

②Run Scriptを編集

Xcodeの Build Phases のRun Script Phase にスクリーンショット 2020-08-03 9.42.21.png
下記を追加します。

if [ $CONFIGURATION = "Debug" ]; then
    ${PODS_ROOT}/LicensePlist/license-plist --output-path $PRODUCT_NAME/Settings.bundle
fi

③Settings.bundleを追加

新規ファイルでSettings.bundleを追加します。
スクリーンショット 2020-08-03 13.16.31.png

Settings.bundleの中に作られるen.lprojというフォルダは必要ないので削除して、Root.plistだけを残します。

④Root.plistを編集

Root.plistを好きなエディタで開き、下記の様に編集します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>PreferenceSpecifiers</key>
        <array>
            <dict>
                <key>Title</key>
                <string>Licenses</string>
                <key>Type</key>
                <string>PSChildPaneSpecifier</string>
                <key>File</key>
                <string>com.mono0926.LicensePlist</string>
            </dict>
        </array>
        <key>StringsTable</key>
        <string>Root</string>
    </dict>
</plist>

⑤ビルドする

ビルドが完了するとSettings.bundleの中に、ライブラリのライセンスを表示するためのフォルダが追加されます。

設定アプリを見ると、ライセンス一覧が確認できます。
※表示されない場合は再度ビルドして下さい

IMG_6F8E099B2506-1.jpeg

参考

https://github.com/mono0926/LicensePlist
https://medium.com/swift-column/license-plist-c0363a008c67
https://tomoyaonishi.hatenablog.jp/entry/2018/09/19/133447

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

【iOS】ライセンス一覧を設定アプリに自動表記する方法【LicensePlist】

今回はアプリ内で利用したライブラリのライセンス一覧を、簡単に設定アプリに表記する方法を紹介します。
ライブラリを新しく追加しても更新されるのでとても便利です。

スクリーンショット 2020-08-03 8.39.34.png

LicensePlistというライブラリを活用する

ライセンス一覧を手軽に表記することができる、かなりオススメのライブラリです。
有名なアプリでも結構使われているみたいです、開発者の方いつもお世話になっています!

https://github.com/mono0926/LicensePlist

①インストール

Cocoapodsを使いました。

pod ‘LicensePlist’とPodfileに入れてinstallします。

②Run Scriptを編集

Xcodeの Build Phases のRun Script Phase にスクリーンショット 2020-08-03 9.42.21.png
下記を追加します。

if [ $CONFIGURATION = "Debug" ]; then
    ${PODS_ROOT}/LicensePlist/license-plist --output-path $PRODUCT_NAME/Settings.bundle
fi

③Settings.bundleを追加

新規ファイルでSettings.bundleを追加します。
スクリーンショット 2020-08-03 13.16.31.png

Settings.bundleの中に作られるen.lprojというフォルダは必要ないので削除して、Root.plistだけを残します。

④Root.plistを編集

Root.plistを好きなエディタで開き、下記の様に編集します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>PreferenceSpecifiers</key>
        <array>
            <dict>
                <key>Title</key>
                <string>Licenses</string>
                <key>Type</key>
                <string>PSChildPaneSpecifier</string>
                <key>File</key>
                <string>com.mono0926.LicensePlist</string>
            </dict>
        </array>
        <key>StringsTable</key>
        <string>Root</string>
    </dict>
</plist>

⑤ビルドする

ビルドが完了するとSettings.bundleの中に、ライブラリのライセンスを表示するためのフォルダが追加されます。

設定アプリを見ると、ライセンス一覧が確認できます。
※表示されない場合は再度ビルドして下さい

IMG_6F8E099B2506-1.jpeg

参考

https://github.com/mono0926/LicensePlist
https://medium.com/swift-column/license-plist-c0363a008c67
https://tomoyaonishi.hatenablog.jp/entry/2018/09/19/133447

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

ChannelTalkのiOS、Androdアプリへの導入方法

MAMORIOにおけるチャットツールの利用とChannelTalkへの移行

落とし物防止タグのMAMORIOはIoTタグとアプリを連携させる複雑さの問題からユーザーからの問い合わせが多く、2018年頃からサポートの体験の質を向上させるためにIntercomというチャットツールを導入していた。

しかし、米国製で主に欧米や中南米諸国をマーケットとしているIntercomは基本的に英語話者向けで管理コンソールもIntercom社員とのやりとりも英語であり、また(便利そうな)自然言語処理を用いたチャットボット機能も一向に日本語対応する気配がなかったため、韓国製でBotやマーケティング機能のコストが安く、かつ日本語圏を優先してくれるChannelTalkへ移行したところオペレーターによるサポート負荷を半減させることに成功した

以下は弊社オペレーターが書いた記事: https://note.com/tanakosan0508/n/n99af82341ba0

導入方法

導入ガイド: https://developers.channel.io/docs

SDKを使用するには以下2つを行う必要がある。

  • 1. Plugin Keyの取得
    • スクリーンショット 2020-08-03 18.59.53.png
  • 2. push通知用証明書のアップロード
    • スクリーンショット 2020-08-03 19.36.16.png

また注意点としては、Androidでは以下のようにgradleに追加するだけでサクッとパッケージをインストールできるのだが、

dependencies{
    //Cannel Talk
    implementation 'com.zoyi.channel:plugin-android:7.1.3'
    implementation 'com.zoyi.channel:plugin-android-fcm:7.1.3'
}

iOSの場合、弊社のようにCarthageで管理を行っていると以下のようにChannelIOが依存している大量のライブラリを追加する必要がある。

image.png

利用方法

ChannelTalkの動作には以下のサイクルがある。

1. initalize

ChannelTalkがアプリの状態を監視できるようにする(やり忘れてもコンパイルは通りその後のコードも動くが、バックグラウンド時のプッシュ通知によるチャットの応答などが一切機能しなくなるので注意)。

iOS

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    ChannelIO.initialize(application)
    return true
  }

Andorid

public class MyApplication extends Application {
  @Override
  public void onCreate() {
    super.onCreate();

    ChannelIO.initialize(this);

    // TODO : Your code   
  }
}

2. 遠隔プッシュ通知登録

ChannelTalkのサーバーにデバイスをプッシュ通知の受け手として登録する
これをおこなわないとユーザーがバックグラウンドにしてしまったあとに返信を行ってもプッシュ通知が届かない。

iOS

func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  ChannelIO.initPushToken(deviceToken)
}

Android
くわしくはこちら

public class MyActivity extends AppCompatActivity {

    @Override
    public void onCreate() {
        super.onCreate();

        // TODO : Your code
        // ...
        // ...
        ChannelIO.handlePushNotification(this);
    }
}

3. bootとshow

bootはユーザーの情報をチャネルトークに渡してユーザー情報を更新し、セッションを貼る処理である。
bootはチャットコミュニケーションを行うためには必ず行わなければならず、viewの描画が終わったタイミングでbootを行ったあと、コールバックでshowコマンドによりランチャーを表示することが推奨されている。 (iOSの場合viewDidAppear)

iOS

let settings = ChannelPluginSettings()
settings.pluginKey = "YOUR_PLUGIN_KEY"
settings.memberId = "memberId"
let profile = Profile()
profile.set(name: "Jason")
ChannelIO.boot(with: settings, profile: profile) { (status, guest) in
    if status == .success {
         ChannelIO.show(animated: true)
    } else {
         //異常処理
    }
}

なお、Profileは非必須でログイン前の匿名ユーザーともチャットを行うことができる。

let settings = ChannelPluginSettings()
settings.pluginKey = "YOUR_PLUGIN_KEY"
ChannelIO.boot(with: settings, profile: profile) { (status, guest) in
  print(guest) //=> nil
}

Android

ChannelPluginSettings settings = new ChannelPluginSettings(YOUR_PLUGIN_KEY);
settings.setUserId(uniqueUserId);

Profile profile = Profile.create()
      .setName(userName)
      .setEmail(userEmail)
      .setProperty("HomePage", "www.zoyi.co");

// If you register the listener, it receives the boot status.
// Booting without user information
  ChannelIO.boot(ChannelPluginSettings settings, OnBootListener listener);

4. hide

iOSではチャネルトークのランチャーと返信のバナーはWindowの最前面に表示されるので、特定のページのみでチャットを表示し他のページで表示したくない時は明示的にhideし隠さなければならない。

iOS

override func viewWillDisappear(_ animated: Bool) {
   ChannelIO.hide(animated: true)
}

5. shutdown

ChannelTalkは一度bootした後コネクションを貼ってメッセージを待ち続けるため、ユーザーがそのアカウントでのアプリの使用をやめる際は明示的にshowdownコマンドを実行しなければならない。

iOS

func logout() {
   ChannelIO.shutdown();
}

メリット

1. 接客チャットの料金が訪問者数5000人超で月9000円

ChannelTalkも接続したユーザー数による従量課金だが、上限が5001人で終わる。

image.png

2. 日本語の接客botを作成できる

ChannelTalkを導入する最大のメリットは、選択肢式の接客ボットを日本語で複数種類手軽に作成できることである。
MAMORIOで日本語のbotを導入した結果、オペレーターへ直接話しかけるユーザーの割合が半減しサポートチームの負荷を激減させることに成功した。

スクリーンショット 2020-08-03 20.43.41.png

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

【iOS13】UIActivityViewControllerの不具合とそのワークアラウンド

共有を完了したときに起こる不具合

テキストやファイルなどを共有するときに使用するUIActivityViewControllerですが、iOS13の環境では共有が終了したタイミングでUIActivityViewControllerを表示した親のUIViewControllerまでdismissしてしまう不具合があるようです。

参考: iOS 13 UIActivityViewController automatically present previous VC after image saving

発生条件は曖昧?

上記のstack overflowの記事では特に言及がないようですが、僕が遭遇した不具合の発生条件は以下でした。

発生条件
1. iPadでのみ起こる
2. キャンセル時にのみ発生(共有シートの外をタップしたり、ファイルに保存 > キャンセルを選択するなど)

不具合
1. 親のUIViewControllerのdismissメソッドが勝手に呼ばれる
2. 実際にはdismissしない(dismissメソッドをオーバーライドして別の処理を追加していたため不具合が顕在化した)

ワークアラウンド

上記のstack overflowの記事の回答をひとつを参考にしました。

透明のUIViewControllerを生成して、その上にUIActivityViewControllerを表示します。そして、UIActivityViewControllerの完了ハンドラ内で透明のUIViewControllerをdismissします。

完了ハンドラ内でdismissするだけでほとんどのケースは問題なかったのですが、なぜか「マークアップ > PDFを削除」のフローの場合だけ、dismissを呼んでも残り続けたので、さらにdismissの完了ハンドラ内でdismissを呼んでいます。

func onGeneratedPDF(at url: URL) {
    let transparentVC = UIViewController()
    transparentVC.modalPresentationStyle = .overFullScreen

    let acv = UIActivityViewController(activityItems: [url], applicationActivities: nil)
    acv.completionWithItemsHandler = { [weak transparentVC] _, _, _, _ in
        transparentVC?.dismiss(animated: false, completion: { [weak self] in
            if let transparentVC = transparentVC,
                transparentVC.presentingViewController == self {
                // 「マークアップ > PDFを削除」のフローの場合、ここに来てもVCが残っているので、もう一度呼ぶ
                transparentVC.dismiss(animated: false, completon: nil)
            }
         })
    }

    present(transparentVC, animated: false, completion: {
        let v = transparentVC.view!
        acv.popoverPresentationController?.sourceView = v
        acv.popoverPresentationController?.sourceRect =
            .init(origin: v.bounds.size.toPoint().half, size: .square(0))
        transparentVC.present(acv, animated: true, completion: nil)
    })
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity / Xcode】Unity で生成したプロジェクトが Xcode でのアーカイブ作成時に失敗する

Unity で作成したプロジェクトを iOS 向けにビルドする必要があったのですが、Xcode でのアーカイブ作成に失敗したので、その対処法を書きます。

環境

  • Xcode 11.6
  • Unity 2019.4.5f1

症状

iPhone を繋いでの実機デバッグはできるのですが、アーカイブを作成しようとするとエラーを吐きビルドに失敗します。
エラー内容としては、Undefined symbol: _UnityPluginLoad のような文言がひたすら並んでいます。
image

解決策

Unity 側で iOS の最低バージョンを 11.0 に変えるで解決しました。

Project Settings > Player > Other Settings 内の Target minimum iOS Version10.0 から 11.0 に変更。

image

再度 Unity でビルドし、生成された .xcodeproj ファイルを開き、アーカイブを実行したところ無事成功しました。

似たような症状を探していたところ、以下のフォーラムを見つけました。
Linker Errors on Vuforia 8.1 Unity 2018.3.0f2 | Vuforia Developer Portal

「iOS 9、iOS 10 と 32ビットのサポートは無くなるため、最低リリースを iOS 11 にアップデートしないといけない」というような記述を見つけたため、試したところうまくいきました。

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

iOSの In App Purchase アプリ内課金 消耗型 購入処理

iOSアプリ内課金の消耗型アイテムの実装方法に関する話です。

アプリ内課金実装時にやりがちな失敗例を解説した後に正しい処理の流れを解説します。

間違った購入処理の流れ

※購入処理を書くうえで以下の実装処理は完全なる間違いです。絶対に以下のような流れで実装してはいけません。大変なことになります。

1'. 商品情報の取得
2'. 商品を購入トランザクションに追加
3'. ユーザー側での処理(アカウント情報確認や購入の確認)
4'. Appleのサーバーでの購入処理の完了
5'. 商品情報を購入トランザクションから削除
6'. レシート検証
7'. ユーザーへの報酬(コイン追加等)

間違った実装で生じる問題

もしも6'のレシート検証の時点でイレギュラーな事態が発生して処理が終わってしまった場合、報酬を受け取ることができず、ユーザーのお金だけが消えてしまうという事態になります。

レシート検証は多くの場合クライアント側ではなくWebサーバーを用意してその中で行い、その結果を持ってユーザーに報酬を与えます。
Appleでの購入処理が成功したあと、
レシート検証の時点で通信が切断してしまった場合、もしくはアプリが落ちてしまった場合
ユーザーは報酬を受け取ることができず、ユーザーのお金だけが消滅します。

やってはいけないこと

間違った流れの中で最もやってはいけなかったのが
5',6'の流れです。

5'. 商品情報を購入トランザクションから削除
6'. レシート検証

問題なのはレシート検証が成功する前に購入トランザクションから商品情報を削除してしまったことです。
つまりSKPaymentQueueのfinishTransactionを呼んでしまうことです。
コードにすると以下のようになります

AkanPurchaseManager.swift
// ※この実装には間違いが含まれています
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for t in transactions {
            switch t.transactionState {
                case .purchased:
                    // トランザクション処理終了 ここが間違い
                    SKPaymentQueue.default().finishTransaction(t)
                    // レシート検証 ここが間違い
                    self.validateReceipt() { [unowned self] in
                        self.delegate.success()
                    }
                case .failed:
                    SKPaymentQueue.default().finishTransaction(t)                default:
                    break
            }
        }
    }
// ※この実装には間違いが含まれています

正しい購入処理の流れ

上記の問題へ対応するための正しい実装処理の流れは以下のようになります。

  1. 購入トランザクションの監視の開始
  2. 未処理のトランザクションのチェック
  3. 未送信のレシート情報を保存しているかチェック
  4. 商品情報の取得
  5. 商品を購入トランザクションに追加
  6. ユーザー側での処理(アカウント情報確認や購入の確認)
  7. Appleのサーバーでの購入処理の完了
  8. レシート情報を暗号化して保存(KeyChainAccess等)
  9. レシート検証
  10. 商品情報を購入トランザクションから削除
  11. ユーザーへの報酬(コイン追加等)
  12. レシート検証で成功したレシート情報を削除

やるべきこと

問題に対応するために、中断してしまった場合の処理を再開させるという実装をします。
そのポイントとなるのが、正しい流れの中の1,2,3と8,9,10の処理になります。

まず8,9,10に関して

8, レシート情報を暗号化して保存(KeyChainAccess等)
9, レシート検証
10, 商品情報を購入トランザクションから削除

レシート検証がサーバー側で成功するまでfinishTransactionを呼び出さなければSKPaymentQueueの内容を
次回のアプリ起動時もしくはログイン時まで持ち越すことができる
ため、途中の処理を復元することができます。

更なる注意点

しかし弊社デバッグチームが100回ほどの購入処理テストを行ったところ、30回に1回ほどそれでも復元できないという事例が出ていました。

それに対応するために8の処理でレシート情報を保存して次回のログイン時に失敗したレシート情報を確認してサーバー側で検証するという処理を入れています。

これによって弊社デバッグチームの鬼のような回数の検証にも耐え抜き、購入を途中中断からの復元を100%成功させるということができました。

コード例

コードは以下になります。

OKPurchaseManager.swift
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for t in transactions {
            switch t.transactionState {
                case .purchased:
                   self.saveReceipt() // 未送信レシート保存
                    // レシート検証 
                    self.validateReceipt() { [unowned self] in
                        // トランザクション処理終了 
                        SKPaymentQueue.default().finishTransaction(t)
                        self.deleteReceipt() // 送信済成功レシートの削除
                        self.delegate.success()
                    }
                case .failed:
                    SKPaymentQueue.default().finishTransaction(t)                default:
                    break
            }
        }
    }

続いて1,2,3の処理

  1. 購入トランザクションの監視の開始
  2. 未処理のトランザクションのチェック
  3. 未送信のレシート情報を保存しているかチェック

アプリ起動時、もしくはログイン時に行う購入復元処理は以下になります。

OKPurchaseManager.swift
    func resumePurchase() {
        if SKPaymentQueue.default().transactions.count > 0 {
            for t in SKPaymentQueue.default().transactions {
                switch t.transactionState {
                    case .purchasing, .deferred: // 購入処理中
                        SKPaymentQueue.default().add(t.payment)
                        SKPaymentQueue.default().add(self)
                        break
                    case .purchased: // 購入済み
                        self.validateReceipt() { [unowned self] in
                            SKPaymentQueue.default().finishTransaction(t)
                            self.delegate.resumeSuccess()
                        }
                    case .failed:
                        SKPaymentQueue.default().finishTransaction(t)
                    default:
                        break
                }
            }
        } else {
            // トランザクションには何も処理が無い状態だが、未送信のレシート情報が保存してあるかをチェック
            if let receipt = self.getSavedReceipt {
                self.validateReceipt(receipt) { [unowned self] in
                    self.delegate.resumeSuccess()
                }
            }
        }
    }

終わりです

最後までお読みくださりありがとうございました。

Brewus,Inc.
株式会社ブリューアス
https://brewus.co.jp

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

まいにち体調管理アプリのデータを Google スプレッドシートに保存しよう

はじめに

本記事は、まいにち体調管理アプリの JSON データ出力機能を使って、
Google スプレッドシートに体調データを保存する手順を記載している。
Google スプレッドシートへの保存は、Google App Script を利用している。

Google スプレッドシートと App Script

Google スプレッドシートを使うには、Google アカウントが必要である。
アカウントが無い場合は予め取得しておこう。
Gmail を利用しているなら、そのアカウントが Google アカウントである。

Goolge スプレッドシートの作成

まずは、Google Drive にアクセスする。
左上の新規ボタンをクリックして、「Google スプレッドシート」を選択する。
スプレッドシート画面が表示されるので、「ツール」の「スクリプトエディタ」をクリックする。
スプレッドシート
なお、スプレッドシート名は「まいにち体調管理」に変更しておく。1

Google App Script プログラムの作成

Google App Script の画面に切り替わるので、スクリプトの名前を変更する。
ここでは便宜上、「HealthCare」と変更した。
Google App Script

HealthCare.gs のプログラムは、Gist のソースコードをそのままコピーして貼り付けるだけ。

HealthCare.gs
function doPost(e) {
  var status = 0
  let jsonString = e.postData.getDataAsString()
  let json = JSON.parse(jsonString)
  if (json.date) {
    let year = json.date.year
    let month = ('00' + json.date.month).slice(-2)
    let day = ('00' + json.date.day).slice(-2)
    let sheetName = year + month

    let spreadsheet = SpreadsheetApp.getActiveSpreadsheet()
    var sheet = spreadsheet.getSheetByName(sheetName)
    if (!sheet) {
      spreadsheet.insertSheet(sheetName)
      sheet = spreadsheet.getActiveSheet()
      sheet.appendRow([
        "日付","時間帯","体温",
        "","鼻水","","息切れ","喉の痛み","眼の痛み","筋肉痛","頭痛",
        "下痢","嘔吐","倦怠感","におい","","解熱剤の服用"
      ])
    }
    let dateString = year + '/' + month + '/' + day
    for (const condition of json.conditions) {
      sheet.appendRow([ 
        dateString, condition.time_zones, condition.body_temperature,
        condition.cough, condition.runny_nose, condition.phlegm, condition.breathless,
        condition.sore_throat, condition.eye_pain, condition.muscle_pain, condition.headache,
        condition.diarrhea, condition.vomiting, condition.malaise, condition.smell, condition.taste,
        condition.antipyretic
      ])
    }
  }
  else {
    status = 500
  }
  const result = {
    "status": status
  }
  const response = JSON.stringify(result)
  return ContentService.createTextOutput(response).setMimeType(ContentService.MimeType.JSON)
}

プロジェクト名も「まいにち体調管理」に変更した。2
手順通りに進んでいれば、以下のような画面になる。
GAS完成

このスクリプトは、自動的に年月のシートを作成するので、月が変わってもスプレッドシート側で操作をする必要は無い。

Google App Script を公開

メニューの「公開」をクリックして「ウェブアプリケーションとして導入」を選択する。
gas_002.png
公開用の設定画面が表示されるので、次の画面のように設定する。
gas_003.png
Who has access to the app の項目を Anyone, even anonymous に設定したので、URL を知っているユーザは誰でもアクセス可能になるので注意が必要である。3
必要な設定が完了したら、「更新」ボタンをクリックする。
gas_004.png
「Current web app URL」の内容をコピーしておく。

まいにち体調管理アプリの設定

コピーした URL をアプリに設定する。
サーバ情報
URL の script.google.comホスト名 に設定する。
/macros/s/... 以降を API のパス名 に設定する。
SSL通信オン にする。
アプリに貼り付ける際は、メモアプリなどを使ってホスト名とパス名に分離してから作業すると、比較的楽にできると思う。

アップロードの動作確認

アプリの体調管理画面の日にちをタップして、右上のアイコンをタップする。
全データを JSON 形式で送信をタップ。
ss003.png

作成した Google スプレッドシートに送信した年月日のデータが記録されていれば成功。
ss004.png

Google スプレッドシートにデータが記録されるので、あとは自由に加工すれば良い。

おわりに

まいにち体調管理アプリのデータを Google App Script を使って Google スプレッドシートに保存する方法を記した。
ここで紹介した Google App Script は、ちょっと修正するだけで他の用途にも応用できると思う。
Google App Script を初めて利用したけど、データ記録系のアプリのバックエンドとしても利用できそうだなと感じた。

また、まいにち体調管理アプリの JSON データを自作サーバで受信したい場合は、GitHubにサンプルコードがあるので参照してね。node.js と express を使ったコードだよ。


  1. 好きなスプレッドシート名にしても問題ないよ。 

  2. 好きなプロジェクト名にしても問題ないよ。 

  3. only myself を指定するとトークン関係の説明が必要となり、面倒なので簡単な方法を紹介した。 

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

【Flutterの状態管理】テーマ切替&多言語化 〜provider,BLoC,reduxの3パターンで実現してみる③

いよいよ本シリーズの最終篇になります。
本篇は、アプリのテーマ(Theme)切替、多言語化を通じてFlutterの状態管理のreduxパターンを説明します。

三、reduxでテーマ切替&多言語化

flutter_redux: ^0.6.0

Blocと同じのようにreduxにも3つのポイントがある。それは 状態StateアクションAction変化させる者Reducer

reduxは封建制とある程度似ている。天子が、土地を諸侯に分与し、諸侯はまたそれぞれ領内の管理を大夫に委託する。各々が責任を取って行動する。reduxにおいて、全ての状態はstoreによって集中管理し、天子AppStateから階層的に分配していく。

Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.52.34.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.52.56.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.19.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.25.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.41.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.47.png

1.redux状態、アクション、変化させる者の準備

天子

appState_redux.dart
import 'themeState_redux.dart';
import 'localState_redux.dart';

class AppState {
  final ReduxThemeState themeState;//テーマ状態
  final ReduxLocaleState localeState;//言語状態
  AppState({this.themeState, this.localeState});

  factory AppState.initial()=> AppState(
      themeState: ReduxThemeState.initial(),
      localeState: ReduxLocaleState.initial()
  );
}

//集中的に状態を分配
AppState appReducer(AppState prev, dynamic action)=>
    AppState(
      themeState:themeDataReducer(prev.themeState, action),
      localeState: localReducer(prev.localeState, action),);

諸侯① theme

themeState_redux.dart
import 'package:flutter/material.dart';
import 'package:redux/redux.dart';

//テーマ状態の切り替え
class ReduxThemeState extends ChangeNotifier {
  ThemeData themeData; 
  int colorIndex; 

  ReduxThemeState(this.themeData,this.colorIndex);

  factory ReduxThemeState.initial()=> ReduxThemeState(ThemeData(primaryColor: Colors.green),1);
}

//テーマアクションの切り替え
class ActionSwitchTheme {
  final ThemeData themeData;
  final int colorIndex;
  ActionSwitchTheme(this.themeData,this.colorIndex);
}

//変化させる者の切り替え
var themeDataReducer = TypedReducer<ReduxThemeState, ActionSwitchTheme>((state, action) =>
    ReduxThemeState(action.themeData,action.colorIndex));

諸侯② local

localState_redux.dart
import 'package:flutter/material.dart';
import 'package:redux/redux.dart';

//言語状態の切り替え
class ReduxLocaleState{
  Locale locale;
  ReduxLocaleState(this.locale);
  factory ReduxLocaleState. initial()=> ReduxLocaleState(Locale('ja', 'JP'));
}

//言語アクションの切り替え
class ActionSwitchLocal {
  final Locale locale;
  ActionSwitchLocal(this.locale);
  factory ActionSwitchLocal.ja()=> ActionSwitchLocal(Locale('ja', 'JP'));
  factory ActionSwitchLocal.en()=> ActionSwitchLocal(Locale('en', 'US'));
}

//変化させる者の切り替え
var localReducer = TypedReducer<ReduxLocaleState, ActionSwitchLocal>((state,  action) => 
    ReduxLocaleState(action.locale,));

2.reduxプロパティーの使用

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; //providerパターンのpubspec.yaml配置を参照
import 'home_page.dart';
import 'provider/I18nDelegate.dart'; //providerパターン紹介時のファイル使用
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import 'redux/appState_redux.dart';

void main() {
  runApp(Wrapper(child: MyApp()));
}

class Wrapper extends StatelessWidget {
  final Widget child;
  Wrapper({this.child});

  @override
  Widget build(BuildContext context) {
    //StoreProviderによってラップする、storeプロパティーでAppStateを配置
    return StoreProvider(
        store: Store<AppState>(appReducer, initialState: AppState.initial()),
        child: MyApp());
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreBuilder<AppState>(builder: (context,store) => MaterialApp(
                  title: "状態管理Demo",
                  localizationsDelegates: [
                    GlobalMaterialLocalizations.delegate,
                    GlobalWidgetsLocalizations.delegate,
                    I18nDelegate.delegate //言語デリゲート シリーズ初編のproviderパターンのファイルを利用
                  ],
                  locale: store.state.localeState.locale,
                  supportedLocales: [store.state.localeState.locale],
                  debugShowCheckedModeBanner: false,
                  home: HomePage(),
                ));
  }
}

状態の切り替えは store.dispatch(~action) を使う

home_page.dart
import 'package:flutter/material.dart';
import 'provider/i18n.dart'; //providerパターン紹介時のファイル使用
import 'package:flutter_redux/flutter_redux.dart';
import 'redux/appState_redux.dart';
import 'redux/themeState_redux.dart';
import 'redux/localState_redux.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    var screenWidth = MediaQuery.of(context).size.width;
    var statusBarH = MediaQuery.of(context).padding.top;
    var naviBarH = kToolbarHeight;

    //StoreBuilder<AppState>(builder: (context,store) を通じて状態ゲット
    return StoreBuilder<AppState>(
      builder: (context, store) => Scaffold(
        appBar: AppBar(
          backgroundColor: store.state.themeState.themeData.primaryColor,
          title: Text(
            I18N.of(context).title,
            style: TextStyle(color: Colors.white),
          ),
        ),
        drawer: Drawer(
          child: Column(
            children: <Widget>[
              Container(
                width: screenWidth,
                height: statusBarH + naviBarH,
                color: store.state.themeState.themeData.primaryColor,
              ),
              SizedBox(height: 10.0),
              Row(
                children: <Widget>[
                  Padding(padding: EdgeInsets.all(10)),
                  Wrap(
                    children: <Widget>[
                      RaisedButton(
                        color: Colors.green,
                        onPressed: () {
                          //イベントトリガー、重要
                          store.dispatch(ActionSwitchTheme(
                              ThemeData(primaryColor: Colors.green), 1));
                        },
                        child: Text(
                          I18N.of(context).greenBtn,
                          style: TextStyle(color: Colors.white),
                        ),
                        shape: CircleBorder(),
                      ),
                      RaisedButton(
                        color: Colors.red,
                        onPressed: () {
                          store.dispatch(ActionSwitchTheme(
                              ThemeData(primaryColor: Colors.red), 2));
                        },
                        child: Text(
                          I18N.of(context).redBtn,
                          style: TextStyle(color: Colors.white),
                        ),
                        shape: CircleBorder(),
                      ),
                      RaisedButton(
                        color: Colors.blue,
                        onPressed: () {
                          store.dispatch(ActionSwitchTheme(
                              ThemeData(primaryColor: Colors.blue), 3));
                        },
                        child: Text(
                          I18N.of(context).blueBtn,
                          style: TextStyle(color: Colors.white),
                        ),
                        shape: CircleBorder(),
                      ),
                    ],
                  ),
                ],
              ),
              Divider(),
              SizedBox(
                height: 10,
              ),
              Row(
                children: <Widget>[
                  Padding(padding: EdgeInsets.all(10)),
                  SizedBox(
                    width: 15,
                  ),
                  RaisedButton(
                    color: store.state.themeState.themeData.primaryColor,
                    onPressed: () {
                      store.dispatch(ActionSwitchLocal.ja());
                    },
                    child: Text(
                      "日本語",
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                  SizedBox(
                    width: 15.0,
                  ),
                  RaisedButton(
                    color: store.state.themeState.themeData.primaryColor,
                    onPressed: () {
                      store.dispatch(ActionSwitchLocal.en());
                    },
                    child: Text(
                      "English",
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
        body: ListView(
          children: <Widget>[
            Center(
              child: Column(
                children: <Widget>[
                  SizedBox(height: 10.0),
                  Container(
                    width: 200,
                    height: 300,
                    color: store.state.themeState.themeData.primaryColor,
                  ),
                  SizedBox(height: 10.0),
                  Text(
                    I18N.of(context).analects,
                    style: TextStyle(
                        color: store.state.themeState.themeData.primaryColor,
                        fontSize: 18.0,
                        fontWeight: FontWeight.bold),
                  ),
                  SizedBox(height: 30.0),
                  FloatingActionButton(
                      onPressed: () {},
                      backgroundColor:
                          store.state.themeState.themeData.primaryColor,
                      child: Icon(Icons.check)),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}


まとめ

いかがでしたか。本篇をもってflutterの状態管理シリーズを完了とします。総じて言えば、3パターンともに構造的に、実現方式的に似ているようなところがあります。providerはflutter公式が推奨するパターンとして使っても無難でしょう。Streamに対する理解を深めれば、Blocの使用は少し上級感が感じられます。reduxはコード量が少なく、構造もはっきりしており、個人的には好きです。
どのパターンを採用するのかは正解がなく、プロジェクトのニーズに合わせて決めておけば良いでしょう。また、状態を変更する必要のあるwidgetがそれほど無ければ、基礎のsetState()というapiを使っては分かりやすいでしょう。

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