Hituzi Ando's Blog

日々のアプリ開発についてや雑記など。

Blue Sketch ver.1.1.5がリリースされました!

こんにちは、個人アプリ開発者の@HituziANDOです。

僕が個人で開発しているiOSアプリ「Blue Sketch」の最新バージョンがリリースされました!

Blue Sketchとは

手書きで自由に、すばやく書けるシンプルなノートアプリです。青いキャンバスに白線のみ書けるという最小限の機能だからこそ、書くことに集中できます。ルーズリーフとボールペンをiPadApple Pencilに置き換えるとイメージしやすいです。

アップデート内容

今回の主なアップデートは以下の3点です。

  1. 読み取り専用モードが追加されました
  2. ドキュメントのコピーができるようになりました
  3. キャンバス画面で直接ドキュメントの新規作成ができるようになりました

1. 読み取り専用モードの追加

読み取り専用モードにすると、編集機能をロックすることができ、誤操作を防止することができます。iPhoneiPadを持っているとき、誤って画面を触ってしまい、知らないうちに不要な線を書き込んでしまうといったことを防げます。

読み取り専用モードにする手順は、以下の通りです。

  1. ドキュメントのタイトルをタップします

f:id:hituziando:20200402184513p:plain

  1. ポップアップが表示されます。編集モードをタップすると、読み取り専用モードに切り替えられます(もう一度タップすると、編集モードに戻ります)

f:id:hituziando:20200402184605p:plain

  1. 完了ボタンをタップします

2. ドキュメントのコピーが可能に

ドキュメントのコピーができるようになりました。キャンバス画面右下のコピーボタンをタップすることで、開いているドキュメントの複製を作成します。

f:id:hituziando:20200402185526p:plain

3. キャンバス画面で直接ドキュメントの新規作成が可能に

今まではフォルダ画面を開かないと新しいドキュメントを追加できませんでしたが、今回のアップデートで、キャンバス画面右下の新規作成ボタンをタップすることで、直接新しいドキュメントを作成できるようになりました。

さいごに

Blue SketchはApp Storeで無料で配信中です。気になった方はぜひインストールしてみてください!

iPhone / iPad

apps.apple.com

Mac

apps.apple.com

Mac版の最新バージョンは現在AppStore申請中です。

Swiftで同じ文字を繰り返す方法

こんばんは、アプリ開発者の@HituziANDOです。

同じ文字を繰り返す方法というと、まず第一にfor文で足し続ける方法を思いつきますが、Swiftでは以下のようにStringのイニシャライザを使ってスマートに書けます。

let str = String(repeating: "a", count: 4)
// => "aaaa"

あまり使う機会はないかもしれませんが、知っておくと地味に便利です。

【Android】LocationManagerを使って位置情報をなるべく取得する

こんにちは、アプリ開発者の@HituziANDOです。

この記事では、AndroidのLocationManagerを使って位置情報をなるべく取得する方法を紹介します。"なるべく"取得するとは、位置情報の精度よりも取得できることそのものに重きを置いています。というのも、先日、LocationManagerを使ったアプリを作っていたのですが、位置情報が全然取得できないという事象に見舞われました。原因はGPS測位のみ有効にしていて、屋内で測位していたため、取得に時間がかかった or 取得できなかったようです。

GPS測位とネットワーク測位

AndroidのLocationManagerにはGPS測位とネットワーク測位の2種類があります。GPS測位とは衛星を使った位置測位であるのに対し、ネットワーク測位とは基地局Wi-Fiアクセスポイントを使った位置測位です。それぞれの特徴を大まかにまとめました。

GPS測位 NW測位
精度 高い 低い
測定時間 遅い 速い
バッテリー消費 多い 少ない

上記より、粗くてもとにかく位置情報を取りたいという場合はNW測位を使い、精度が最重要という場合はGPS測位を使うと良さそうです。ということで、なるべく取得できるようにするためには、NW測位を採用すると良いです。

ところで、この2つの測位方法ですが、同時には片方しか使えないわけではなく、同時に両方使うことができます。以下の実装例は同時に両方を使う例です。

LocationManagerを使った実装

AndroidManifest.xml

AndroidManifest.xmlに以下のパーミッションを追加します。ACCESS_FINE_LOCATIONはGPS測位用、ACCESS_COARSE_LOCATIONはNW測位用です。

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

どちらのパーミッションもProtection level: dangerousなため、ユーザ認可が必要です。

MainActivityクラス

ここでは、MainActivityクラスで位置測位するものとします。(この画面が表示されているときだけ位置測位します)

private const val REQUEST_CODE_PERMISSIONS = 1

class MainActivity : AppCompatActivity() {

    private val locationManager: LocationManagerUtil by lazy {
        val manager = LocationManagerUtil(this)
        manager.listener = locationListener
        manager
    }

    private val locationListener: LocationManagerUtil.Listener = object : LocationManagerUtil.Listener {
        // 位置情報に更新があると呼ばれる
        override fun onLocationUpdated(location: Location) {
            Log.d("""
                provider: ${location.provider} 
                lat: ${location.latitude} 
                lon: ${location.longitude} 
                acc: ${location.accuracy}
                """)
        }
    }

    private var isPermissionDenied = false
        set(value) {
            field = value

            if (!value) {
                // ユーザに権限が与えられているならば位置測位開始
                locationManager.start()
            }
            else {
                locationManager.stop()
            }
        }

    override fun onResume() {
        super.onResume()

        isPermissionDenied = !checkSelfPermissions()
    }

    override fun onPause() {
        super.onPause()

        // 位置測位の終了
        locationManager.stop()
    }

    private fun checkSelfPermissions(): Boolean {
        val deniedPermissions = arrayListOf<String>()

        // 位置情報の権限が与えられているか
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            deniedPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
        }

        if (deniedPermissions.size > 0) {
            // 権限が無い場合はユーザに認可してもらえるようにリクエスト(ダイアログが表示される)
            ActivityCompat.requestPermissions(this, deniedPermissions.toTypedArray(), REQUEST_CODE_PERMISSIONS)
            return false
        }

        return true
    }

    // requestPermissionsの結果が返ってくる
    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (grantResults.isNotEmpty()) {
                val deniedPermissions = arrayListOf<String>()

                grantResults.forEachIndexed { index, result ->
                    if (result != PackageManager.PERMISSION_GRANTED) {
                        deniedPermissions.add(permissions[index])
                    }
                }

                isPermissionDenied = deniedPermissions.isNotEmpty()

                if (isPermissionDenied) {
                    // TODO: ユーザに権限が与えられなかったときの処理
                }
            }

            return
        }

        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }
}

ポイントは位置情報の権限をチェックして、ユーザに認可されているならば位置測位を開始するようにしています。もし、権限が与えられていない場合は、権限を要求し、その結果でもって位置測位を開始するか判断しています。

LocationManagerUtilクラスは後述するLocationManagerのラッパークラスです。

LocationManagerUtilクラス

// 位置情報更新の最小間隔
private const val MIN_INTERVAL = 10 * 1000L // [ms]
// 位置情報更新の最小距離
private const val MIN_DISTANCE = 3.0F   // [meter]

class LocationManagerUtil(context: Context) {

    interface Listener {
        fun onLocationUpdated(location: Location)
    }

    var listener: Listener? = null
    var currentLocation: Location? = null
        private set
    private var isStarted = false

    private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager

    private val locationListener: LocationListener = object : LocationListener {

        // GPS測位またはNW測位で位置情報が更新されると呼ばれる
        override fun onLocationChanged(location: Location?) {
            // TODO: 更新時間や精度などからGPS測位結果とNW測位結果のどちらかより良い方を採用する処理

            currentLocation = location

            location?.apply { listener?.onLocationUpdated(this) }
        }

        override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
        }

        override fun onProviderEnabled(provider: String?) {
        }

        override fun onProviderDisabled(provider: String?) {
        }
    }

    @SuppressLint("MissingPermission")
    fun start() {
        if (!isStarted) {
            isStarted = true

            // GPS測位とNW測位を開始する
            locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, MIN_INTERVAL, MIN_DISTANCE, locationListener)
            locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, MIN_INTERVAL, MIN_DISTANCE, locationListener)
        }
    }

    fun stop() {
        if (isStarted) {
            isStarted = false
            locationManager.removeUpdates(locationListener)
        }
    }
}

GPS測位とNW測位を開始しています。いずれも位置情報が更新されるとLocationListenerのonLocationChangedメソッドが呼ばれます。MIN_INTERVALとMIN_DISTANCEは位置情報が更新される頻度を調整するパラメータです。

上記の実装により、GPS測位できない状況下でもNW測位で救うことができる可能性があります。ただし、GPS測位もNW測位もできる場合、onLocationChangedメソッドが2重で呼ばれるため、どちらかより良い結果を採用するなど、ひと手間加えた方が良いと思います。

さいごに

AndroidのLocationManagerを使って位置情報を取得する方法を紹介しました。ところで、Android公式ドキュメントでは、FusedLocationProviderClientを使う方法が紹介されています。最近はこちらを使った方が良いのかもしれません。

Swiftで点線を描く

f:id:hituziando:20200229103644p:plain

こんにちは、アプリ開発者の@HituziANDOです。

この記事ではSwiftで点線を描く方法を紹介します。Swiftで点線を描くのは簡単です。

まずはコードから↓。下記のコードはUIImageViewを継承したクラスのdraw(_:)メソッドを抜粋したものです。

public override func draw(_ rect: CGRect) {
    UIGraphicsBeginImageContext(self.frame.size)

    guard let context = UIGraphicsGetCurrentContext() else { return }

    // ブレンドモードの設定
    context.setBlendMode(.normal)

    // 線の色の設定
    UIColor.black.setStroke()

    // 点線の設定
    context.setLineDash(phase: 0, lengths: [5.0, 5.0])

    // 線幅の設定
    context.setLineWidth(1.0)

    // 始点
    context.move(to: CGPoint(x: 10.0, y: 10.0))

    // 終点
    context.addLine(to: CGPoint(x: 100.0, y: 100.0))

    // 線を引く
    context.strokePath()

    // UIImageに書き出す
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    // UIImageViewのimageにセット
    self.image = image
}

点線を描く上でのポイントは、setLineDash(phase:, lengths:)メソッドです。このメソッドのlengthsに点線のパターンを指定します。指定の仕方は、[実線の長さ, 空白の長さ]で指定します。

例えば、[5.0, 5.0]で指定した場合は下図のように描画され、

f:id:hituziando:20200229103644p:plain
lengths=[5.0, 5.0]

[20.0, 10.0]で指定した場合は下図のように描画されます。

f:id:hituziando:20200229110404p:plain
lengths=[20.0, 10.0]

なお、phaseは0のままで良いと思います。

その他の設定はコードのコメントの通りです。ブレンドモードについては公式リファレンス(https://developer.apple.com/documentation/coregraphics/cgblendmode)を一度読んでみて、色々設定を変えながら試してみると良いです。

さいごに

僕が個人開発している手書きノートアプリ「Blue Sketch」でも点線を描くことができます(本記事で使った画像もこのアプリで描いたものです)。

Blue Sketchは、手書きで自由に、すばやく書けるシンプルなノートアプリです。青いキャンバスに白線のみ書けるという最小限の機能だからこそ、書くことに集中できます。

Blue SketchはApp Storeで無料で配信中です。気になった方はぜひインストールしてみてください!

iPhone / iPad

apps.apple.com

Mac

apps.apple.com

App Storeに載せるスクショをカッコよくしてみた

こんにちは、アプリ開発者の@HituziANDOです。

僕が個人で開発しているアプリの一つ「Blue Sketch」のApp Storeに載せるスクショを気合を入れて作り直した話です。

Blue Sketchとは

手書きで自由に、すばやく書けるシンプルなノートアプリです。青いキャンバスに白線のみ書けるという最小限の機能だからこそ、書くことに集中できます。ルーズリーフとボールペンをiPadApple Pencilに置き換えるとイメージしやすいです。

新しいスクショ

さて、新しいスクショは以下の通りです。これを5等分割して掲載しています。

f:id:hituziando:20200222095531j:plain
New

f:id:hituziando:20200222100125j:plain
New

複数のスクショにまたがった感じでイメージを配置する手法はMicrosoft To Doを参考にしています。Microsoft To Doは、もはやスクショではなく完全にイラストですね。昔、スクショと実際のアプリ画面の内容が違うという理由でリジェクトされたことがあるのですが、最近は緩くなったのでしょうか。

ちなみに、以前のBlue Sketchのスクショはこんな感じでした。

f:id:hituziando:20200222101833j:plain
Old

これは酷い(笑)。言い訳をすると、元々Blue Sketchは、昨年のQiitaのAdvent Calendarのネタ用に作ったアプリで、早くリリースすることを第一目標にし、細かいところは手を抜いていました。本来なら目的を果たしたので、これで終了と考えていたのですが、思いの外、インストール数が伸びている(1ヶ月半で300DL近い!)ため、このまま捨て置くのは勿体ないと思い、気合を入れて作り直したという背景です。

ところで、App Store Connectのアナリティクスを見ていると、検索結果一覧からこのBlue Sketchのページを実際に閲覧している人は、わずか6%しかいないことが分かりました。ASO対策としても、カッコいいスクショにしたことで、少しでもインストールへつながってくれればと思います。

なお、スクショはSketchを使って作成しています。

さいごに

Blue SketchはApp Storeで無料で配信中です。気になった方はぜひインストールしてみてください!

iPhone / iPad

apps.apple.com

Mac

apps.apple.com

Railsアプリで使っているライブラリのバージョンを一覧表示する

こんにちは、アプリ開発をしている@HituziANDOです。

受託開発で仕事を受けたりしていると、提出するドキュメント(設計書など)に、ソフトウェアバージョンなどの記載を求められることがあります。

今日もRailsで作ったアプリケーションで使用しているライブラリ一覧を記載する必要がありました。毎回やり方を調べている気がするので、コマンドをメモしておきます。

bundle installでインストールしたgem

コマンド
bundle exec gem list
出力例
actioncable (5.2.3)
actionmailer (5.2.3)
actionpack (5.2.3)
actionview (5.2.3)
activejob (5.2.3)
activemodel (5.2.3)
activemodel-serializers-xml (1.0.2)
activerecord (5.2.3)
...

yarnでインストールしたパッケージ

コマンド
yarn list --depth=0

(依存関係の深さは0にしていますが目的に合わせて変更)

出力例
yarn list v1.21.1
├─ @babel/code-frame@7.5.5
├─ @babel/core@7.6.4
├─ @babel/generator@7.6.4
├─ @babel/helper-annotate-as-pure@7.0.0
├─ @babel/helper-builder-binary-assignment-operator-visitor@7.1.0
├─ @babel/helper-call-delegate@7.4.4
├─ @babel/helper-create-class-features-plugin@7.6.0
├─ @babel/helper-define-map@7.5.5
...

あとは出力されたリストを適宜加工してドキュメントに貼り付ければ完了です。

【iOS】半強制アップデートの仕組みをカジュアルに実装する

この記事では、アプリを起動したとき、新バージョンがリリースされていることをユーザに伝え、アップデートを促す仕組みの実装方法を紹介します。

強制アップデートはゲームアプリなどでよく見る仕組みですね。強制力の強いものだと、アップデートするまで、アプリを利用できなくするものもありますが、必ずしもアップデートできる通信環境に居るとは限らないため(地下鉄に乗っているときなど辛い)、アップデートを促すまでに留めた半強制くらいが僕は好みです。

アプリのバージョンに乖離が生じると、最新機能を提供できないのはもちろんのこと、既存機能を改修しづらくなってきます。例えば、DBのマイグレーションを何世代かに渡って行っているとき、バージョンが飛んでいると思わぬバグを踏んだりします。

アップデートは放っておいてもされていくものと思っていましたが、計測してみると意外にも乖離していることが分かりました。僕が開発しているiOSアプリのBlue Sketchの例を見ても、バージョンのばらつきが見受けられます(離脱ユーザも含まれているため、正確な数字ではないですが)。

ver. インストールされている割合
1.0.4 3%
1.0.5 9%
1.0.6 51%
1.0.7 5%
1.0.8 32%

Blue Sketchのインストールバージョンの分布

この差を埋めるため、以下のようなAppStoreクラスを実装しBlue Sketchにも導入しています。

import Foundation
import Alamofire

typealias LookUpResult = [String: Any]

enum AppStoreError: Error {
    case networkError
    case invalidResponseData
}

class AppStore {

    private static let lastCheckVersionDateKey = "\(Bundle.main.bundleIdentifier!).lastCheckVersionDateKey"

    static func checkVersion(completion: @escaping (_ isOlder: Bool) -> Void) {
        let lastDate = UserDefaults.standard.integer(forKey: lastCheckVersionDateKey)
        let now = currentDate

        // 日付が変わるまでスキップ
        guard lastDate < now else { return }

        UserDefaults.standard.set(now, forKey: lastCheckVersionDateKey)

        lookUp { (result: Result<LookUpResult, AppStoreError>) in
            do {
                let lookUpResult = try result.get()

                if let storeVersion = lookUpResult["version"] as? String {
                    let storeVerInt = versionToInt(storeVersion)
                    let currentVerInt = versionToInt(Bundle.clyr.version)
                    completion(storeVerInt > currentVerInt)
                }
            }
            catch {
                completion(false)
            }
        }
    }

    static func versionToInt(_ ver: String) -> Int {
        let arr = ver.split(separator: ".").map { Int($0) ?? 0 }

        switch arr.count {
            case 3:
                return arr[0] * 1000 * 1000 + arr[1] * 1000 + arr[2]
            case 2:
                return arr[0] * 1000 * 1000 + arr[1] * 1000
            case 1:
                return arr[0] * 1000 * 1000
            default:
                assertionFailure("Illegal version string.")
                return 0
        }
    }

    static func open() {
        if let url = URL(string: storeURLString), UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url)
        }
    }
}

private extension AppStore {

    static var iTunesID: String {
        "<YOUR_ITUNES_ID>"
    }

    static var storeURLString: String {
        "https://apps.apple.com/jp/app/XXXXXXX/id" + iTunesID
    }

    static var lookUpURLString: String {
        "https://itunes.apple.com/lookup?id=" + iTunesID
    }

    static var currentDate: Int {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = .current
        formatter.dateFormat = "yyyyMMdd"
        return Int(formatter.string(from: Date()))!
    }

    static func lookUp(completion: @escaping (Result<LookUpResult, AppStoreError>) -> Void) {
        AF.request(lookUpURLString).responseJSON(queue: .main, options: .allowFragments) { (response: AFDataResponse<Any>) in
            let result: Result<LookUpResult, AppStoreError>

            if let error = response.error {
                result = .failure(.networkError)
            }
            else {
                if let value = response.value as? [String: Any],
                   let results = value["results"] as? [LookUpResult],
                   let obj = results.first {
                    result = .success(obj)
                }
                else {
                    result = .failure(.invalidResponseData)
                }
            }

            completion(result)
        }
    }
}

App Storeでリリースされているバージョンの確認には、iTunes Search APIを利用しています。このAPIを使うと、App Storeで配信されているアプリの情報を無料で取得することができます。また、通信ライブラリはAlamofire v5を使用しています。

iTunes Search APIを使うことで、アプリが実際にApp Storeから配信されるようになった段階で、取得できるアプリ情報のバージョンが自動的に最新のものになるため、自前で配信バージョンを管理するサーバを立てる必要がありません。最新バージョンの更新し忘れとかも起きません。

<YOUR_ITUNES_ID>の部分にはバージョン情報を取得したいアプリのiTunesIDに置き換えてください。iTunesIDはブラウザでApp Store上のアプリのページを開いたときのURLに表示されています。同様にstoreURLStringも置き換えてください。

f:id:hituziando:20200209154257p:plain
iTunesIDを調べる

上記の実装では1日1回バージョンの更新チェックをするようになっています。バージョン比較は、major.minor.patch形式のバージョン文字列をドットで分割しInt型に変換(versionToInt(_:)メソッドを見てください)したもので比較しています。上記のやり方ではminorとpatchは0~999の1000ステップしか値を取れませんが十分でしょう。

AppStoreクラスを使うときはViewControllerで以下のようなメソッドを作っておき、viewDidAppearUIApplication.willEnterForegroundNotificationをオブザーブしたメソッドなどで呼び出します。

private extension ViewController {

    func checkVersion() {
        AppStore.checkVersion { (isOlder: Bool) in
            guard isOlder else { return }

            let alertController = UIAlertController(title: "新しいバージョンがあります!", message: "アップデートしてください。", preferredStyle: .alert)
            alertController.addAction(UIAlertAction(title: "アップデート", style: .default) { action in
                AppStore.open()
            })
            alertController.addAction(UIAlertAction(title: "キャンセル", style: .cancel))
            self.present(alertController, animated: true)
        }
    }
}

以上の実装で、新しいバージョンがリリースされたことをユーザに伝えることができるようになりました。半強制アップデートの仕組みを導入したことでアップデートが促進されるか楽しみです。