【Visual Studio, C#】マルチプラットフォームでのアプリ開発についての所感

今年の始めくらいまでは、アプリ開発はネイティブ環境でやるに限ると考えていたのですが、さすがにマルチプラットフォーム(マルチPF)での開発を知らないというのはちょっと・・・と思い、触り始めました。

対象として選んだIDEは、C#によるアプリ開発が可能な「Visual Studioです。

以前「Xamarin」という会社がXamarin Studioという名前のマルチPFの開発環境を出していたのですが、2016年にMicrosoftの子会社となったことから、Visual Studioに統合されたものです。

Visual Studioは、Micorsoftの.NET Framework環境を移植した「Mono」と呼ばれるオープンソースのフレームワーク上で動作するため、WindowsだけでなくMacやLinuxといった別のOSでも動作するのが強みです。

幸い、C#でのマルチPF開発とVisual Studioの利用については以前経験があったので取り掛かりのハードルは低かったのですが、あまり聞き慣れない単語が出てきたりネイティブではやらないような設定などが必要になったりと、日々苦労しながらやっています。

そこで、今回のエントリーでは、ネイティブとマルチPFでの開発両方に関するメリット・デメリットなどを中心として、Visual Studioでの開発をベースに感想や印象を書いていきたいと思います。

 

1. そもそも何が違う?

冒頭でいきなり「ネイティブ」とか言っていますが、そもそも以下のような違いがあります。

ネイティブ開発・・・AndroidならAndroid Studio + Java、iOSならXcode + Swiftを使った開発
マルチPF開発・・・一つのプログラミング言語と開発環境でのAndroid・iOS両アプリの開発

この2つの開発方法にはそれぞれメリット・デメリットの両方があります。
良く言われるものを以下に記載します。

 

【表1】ネイティブとマルチプラットフォームの各開発におけるメリット・デメリット

ネイティブ開発 マルチプラットフォーム(マルチPF)開発
メリット ・OS毎の各種APIがそのまま利用可能

・高いパフォーマンス

・脆弱性への細かい対策が可能

・コードは各環境で統一管理

・習得言語は原則として一つ

・開発環境も一つ

・パッケージもほぼ統一のものを利用可能(NuGet)

デメリット ・開発がそれぞれの環境毎に必要

・別々の言語習得が必要

・コードの流用がほぼ不可

・パッケージの種類・利用方法が環境毎に相違

・各環境でのアプリのライフサイクルといった動作仕様やクセの把握が必要

・ネイティブと比較すると低パフォーマンス

・最新バージョンOSへの対応の時間差

・OSのAPIをそのままの利用することが不可

 

上記の表1にまとめた通り、基本的な考え方として、「ネイティブ開発では稼働が倍かかるが高いパフォーマンスと自由度を誇る」、「マルチPF開発では多少のパフォーマンスと自由度を犠牲にして開発稼働やコード管理の煩雑さを低減できる」ということになります。

 

2. やってみた所感

しかし、私が開発してみた印象では、マルチPF開発時のパフォーマンス低下についてはほとんど気になりませんでした。

リアルタイム性の高いゲームやミッションクリティカルなサービスに付随するアプリでは、それこそ1ミリ秒単位の遅れが大問題につながることもありますが、そうではない限りはマルチPF開発の方が遥かに楽です。

 

一方で、言語も開発環境も一つで良いものの、開発対象となる各OSに関する知識は予想以上に必要だと感じました。

これの最大の理由として、どうしてもOS間の差異を吸収できない部分については、OS毎それぞれの処理を実装する必要があることが挙げられます。

例として、AdMobでアプリ内にバナー広告を出そうとする場合を考えると、広告表示のViewがAndroidではAdView・iOSではGADBannerViewとそもそもViewの種類が根本的に異なるため、当然その挙動や広告表示に至るまでに必要な手順に差異があります。

本記事の執筆時点ではこの差異を吸収するパッケージが存在しないため、DependencyServiceを用いたOS毎の場合分け処理を実装する必要があり、これに関してはAndroid向け・iOS向けの両方の実装が必要になります。

それでもコードをC#の1言語で記述できるだけ楽ではあるものの、Android・iOS双方の特性を把握していないと実装が難しいものになります。

 

尚、こういったOSの差異を吸収するべく、共通ライブラリとして動作する「.NET Standard」ベースのパッケージも増えてきていることから、将来的には各OSでの個別実装の必要がほぼなくなっていくのでは、と思っています(いつになるのかは不明ですが・・・)。

 

3. どちらで開発するのがオススメ?

ケースバイケースではありますが、開発形態はより稼働が少ない方向に向かうのが常なので、

・一般ユーザ向けサービスに主軸を置いたアプリ開発を目指すならマルチPF開発

・プロ向けのツールなど専門性の高い分野や緊急性・リアルタイム性が高く高度なセキュリティが求められるようなサービスで活用するアプリを目指すならネイティブ開発

という区分けが良いという印象です。

もちろん、アプリ内でどういう機能を提供するか?にもよりますし、既存のネイティブ開発アプリをマルチPF開発に移行検討する場合はどうか?などの課題はありますが、UIデザインやネットワーク機能などの内部処理が統一化できるのは大きな強みですので、一般的なアプリならばほぼマルチPF開発で十分だと感じました。

 

一方で、ひっかかる点が一つあるとすれば、言語がC#であるという点です。

C#は、Javaのように広く大小様々なシステムで活用されているというわけではなく、またSwiftやObjective-CのようにOS特化の言語でもありません。
またC/C++のように歴史があって教科書的な言語でもないため、教育用にもあまり向いていない印象です。

確かに、C#はJavaと同様オブジェクト指向言語ですし、ASP.NETによりインターネットやWebとの親和性が高く、記述の仕方によってはC言語のような形にもできるので柔軟性があると言えばそうなんですが、言語自体が活躍できるシーンがまだまだ多いとは言えないため、どうしてもマイナー感が拭えない感じがします。

 

いずれにせよ、言語自体の習得と各OSの特性の把握は必ず必要になるため、これから開発される方が勉強を含めて始めたり、開発経験豊富な方が開発効率化のために移行されたりなど、開発の場面では有用な手段であることは間違いないと考えています。

 

次回以降は、Visual StudioによるマルチPF開発について、具体例を含めたTips的な記事を書いていこうと思います。

【新アプリ】「はっちモニタ」(Android / iOS)

本日、Android / iOS向けに、新アプリ「はっちモニタ」をリリースしました。

↓ダウンロードはこちらから
Google Play で手に入れよう 

 

「はっちモニタ」は、スマホ・タブレット同士で、ご家庭のかわいいペットの見守りができるアプリです。

家庭内のネットワークにて、「モニタされる」を実行中の端末と同じ合言葉を設定することで、別の端末で「モニタする」ことができます。

 

 

【アプリの内容】(詳細は各種ストアの掲載情報をご参照ください)

  • 同じ合言葉を設定したスマホ・タブレット(Androd)同士で見守りができます。
  • Android・iOSで相互に見守りが可能です。
  • 通信内容は全て暗号化されるため、盗聴・改ざんの心配がなく、安心してご利用いただけます。
    (公共Wi-Fiやホットスポットなど、公共性の高いネットワークでの利用はできません)
  • IPv4・IPv6の両方に対応しています。
  • 「モニタされる」を実行中の端末には、いつでも「モニタする」で接続することができます。

 

このアプリは、自宅で作業中などに、別の部屋にいるペットの「はっち」の動向を見守るために開発しました。
自宅にある休眠スマホ・タブレットを使って「モニタされる」を実行しておき、必要な時に自分のスマホで「モニタする」のが、とても便利です。

ご自宅のペットのちょっとした見守りに、ぜひご活用ください!

Google Play で手に入れよう 

【Swift 3】IPv6でのsocket関数

以前のエントリーにて、sockaddrとsockaddr_inについてのまとめを記述しました。

sockaddr_inはIPv4で用いる構造体で、格納するIPアドレスは32ビット長の整数型を用います。

一方、IPv6では、IPアドレス長が128ビットであり、sockaddr_in構造体ではIPアドレス全体を保持できません。

そのため、IPv6では、「sockaddr_in6」という構造体を用いる必要があります。

● sockaddr_in6構造体

また、IPアドレスを表す sin6_addr の型「in6_addr」は、以下のような構造体です。

● in6_addr構造体

 

上記の通り、sockaddr_in6では、IPv6に合わせて、128ビット(=16バイト)の情報を格納する構造体を、sin6_addr変数として内部に含んでいます。

sockaddr_in6の変数サイズを計算すると、

  • UInt8 + UInt8 + UInt16 + UInt32 + in6_addr + UInt32 +  = 1 + 1 + 2 + 4 + 16 + 4 = 28バイト

となり、sockaddr・sockaddr_inの16バイトとは異なります。

そのため、IPv6においては、以前のエントリーで記述した「sockaddr・sockaddr_inの相互変換にUnsafeBitcastを使う」ことが不可能です。

以上のことから、sockaddr(主にUnsafePointer<sockaddr>)を利用するsocket関数において、データ受信時などに通信相手の情報を sockaddr に格納後sockaddr_inに変換して利用する、という手段が使えないのではないか?という疑問が出てきます。

これについては、IPv6でもIPv4と同様に、以下のような処理で実現可能です。

 

● IPv6でのsocket関数実装例

 

上記のコード例のように、sockaddr_in6 を 「sockaddr_in6のサイズのsockaddr」とみなして処理を行うイメージです。

これで、socket関数の内部では「sockaddr型の変数用として、sockaddr_in6のサイズ分のメモリ領域を確保する」という処理を行い、規定サイズが小さいsockaddrの領域にもオーバーフローせずにIPv6のアドレス情報を格納できます。

 

IPv6でのソケット通信では、上記以外でも、

  • UDPブロードキャストがなくマルチキャストとして処理するため、setsocketopt()の処理方法が異なる
  • sockaddr_in6にネットワークインターフェースの番号を指定する必要がある

など、IPv4とは異なる処理が必要となります。これらの詳細については、今後のエントリーでまとめていきたいと思います。

【Swift 3】非同期処理・DispatchQueueについてのメモ

アプリ開発の上では、様々な場面で非同期処理を行う必要が出てきます。

非同期処理を行うには、Swift 3ではDispatchQueueクラスを利用します。
以下、処理の流れに関する簡単なメモを記載します。
(以下のコード例は全てXcode8.3.2にて動作確認)

DispatchQueueは、一つ以上のタスクを管理するクラスで、独立したスレッドにて、登録されたタスクを実行していくものです。
タスクは、大きく分けて同期処理(.sync())または非同期処理(.async(), .asyncAfter())のどちらかで登録します。

■(1).sync()と.async()の違い

.sync()や.async()は、それぞれが実行されたスレッドに対して作用します。
そのため、.sync()をメインスレッドで呼び出した場合、メインスレッドが処理の完了を待つことになります。

また、DispathcQueue自体に、Serial、Concurrent、mainの3種類が存在します。
それぞれ以下のように分類されています(公式ドキュメントも参照)。

  • Serial:登録したタスクを、同時並行ではなく、登録した順に実行
  • Concurrent:登録したタスクを、同時並行で実行
  • main:メインのUIスレッドでの実行。主にタスク内でUI操作を行う時などで使用

加えて、Concurrentの種類として、アプリケーション単位でシステムにより自動的に生成されるものが5種類あり、それぞれクラスメソッド.global()で取得可能です。

■(2)Serial、Concurrent、main

・Serial

Serialでは、生成したDispatchQueueに登録されたタスクが、順番に実行されていきます。
上記の例では2つのタスクを登録しており、それぞれが登録された順に実行されているのがわかります。
非同期処理をしたい場合で、リソースの問題などによりタスクの実行順を決めておきたい場合は、Serialが適しています。

・Concurrent

ConcurrentでDispatchQueueを生成するには、コンストラクタの引数 attributes を DispatchQueue.Attributes.concurrent に設定します。
Concurrentでは、登録されたタスクを同時に実行します。

・main

DispathcQueueのタスク内でDispatchQueue.mainでの処理を実行することで、メインスレッド外からメインスレッドでの処理実行が可能です。
上記のコード例ではわかりづらいですが、例えば複数の画像の読み込み中に、読み込みが完了した画像からUIImageViewに表示させる場合など、メインスレッドでなければ処理できないものを実行させます。

尚、DispatchQueue.mainはクラス変数で、メインスレッドを参照しています。

■(3)DispatchQueue.global()

システムにより予め生成されている5つのDispatchQueueを参照します。

5つのDispatchQueueは、DispatchQueue.mainと同様にクラス変数として定義されており、スレッドのQoS(Quality of Service)のレベル別に設けてあります。
.global()は、引数によって指定したQoSのものを返すようになっています。

5つのDispatchQueueは、それぞれ目的とする用途が異なっており、概ね以下のようになっています。
公式ドキュメントも参照のこと)

  • User-interactive(.userInteractive)
    主に、処理速度に特化した高パフォーマンスで瞬時に完了する処理が対象。そのため、端末電池を多く消費し、メインスレッドを始めとした他スレッドのパフォーマンスが低下する場合がある。
  • User-initiated(.userInitiated)
    User-interactiveほどではないものの、主に高いパフォーマンスで行う処理に使用。数秒程度で完了する処理を対象としており、端末電池の消費量は多い。
  • Utility(.utility)
    データのダウンロードや変換処理など、完了までにある程度の時間を有して良い処理で使用。数分程度で完了する処理を対象とし、パフォーマンスと電池消費のバランスをとっている。
  • Background(.background)
    バックアップ処理など、数時間単位で完了する処理が対象。電池消費量の抑制を重視しているため、パフォーマンスは低い。
  • Default(.default)
    User-initiatedとUtiliryの中間にあたる。引数なしで.global()を実行した際にもこれが返される。

また、上記5つに加え、.global()の引数としてUnspefiedも存在しています。

  • Unspacified(.unspecified)
    QoSを指定せず、状況・環境に適したものを返すように要求する。

ここまで見るとUnspecifiedが柔軟で良さそうですが、実行したい処理に適したQoSのスレッドが得られない可能性があるため、個々の状況に応じたQoSの選択を行うことが必要だと考えます。

【豆メモ】Android:画面回転時のActivity再生成を無効にする設定

  • Androdiアプリでは、デフォルトでは画面回転時にActivityが一旦破棄され、直後に再生成される仕様
  • 処理としては、画面回転時にActivityのonDestroy()までが実行され、直後にonCreate()が実行される(Activityのライフサイクルに関する以前の記事も参照のこと)
  • 回避策は、マニフェストファイルにて、再生成されたくないActivityの<activity>タグに、「android:configChanges」属性を追加し、この属性値として自分で処理を実装したい項目を記述する

【公式ドキュメントの記述】
https://developer.android.com/guide/topics/resources/runtime-changes.html

(以下、上記URLの該当部分引用)

アプリケーションに特定の構成の変更の際にリソースを更新する必要がなく、パフォーマンスの制限によりアクティビティの再起動を回避する必要がある場合は、構成の変更をアクティビティ自身が処理することを宣言します。そうすることで、システムによってアクティビティが再起動されなくなります。

(中略)

アクティビティで構成の変更を処理することを宣言するには、マニフェスト ファイルの該当する <activity> 要素を編集し、処理する構成を表す値を使用して android:configChanges 属性を追加します。 使用可能な値は、android:configChanges 属性のドキュメントに一覧が記載されています(一般的には、画面の向きを変更した場合の再起動を回避するには "orientation" の値を、キーボードの可用性を変更した場合の再起動を回避するには "keyboardHidden" の値を使用します)。 パイプ記号の | 文字を使用して区切ることで、属性内に複数の構成値を宣言できます。

【参考】
https://stackoverflow.com/a/10530867/7877380

キーボードを表示することがなければ orientation|screenSize のみの設定でも問題ないが、EditText等キーボードを表示する場合を考え、上記のように設定するのが無難。

(参考)android:configChangesの値一覧
https://developer.android.com/guide/topics/manifest/activity-element.html#config

【Swift3】sockaddrとsockaddr_in

ソケットを使って通信する場合、宛先アドレスの指定など様々な場面でsockaddr・sockaddr_inを使う必要があります。

sockaddrとsockaddr_inは共に構造体で、それぞれ以下のように定義されています。

■sockaddr構造体

■sockaddr_in構造体

また、各構造体の値として宣言されている型は、それぞれ以下のように別名として設定されています。

これより、sockaddrおよびsockaddr_inは、各種整数型のデータを要素として持つ構造体であることがわかります。

また、各構造体のサイズは、

  • sockaddr: 1 + 1 + 14 = 16 bytes
  • sockaddr_in: 1 + 1 + 2 + 4 + 8 = 16 bytes

となり、実は同じサイズです。

そのため、sockaddrとsockaddr_inは相互変換が可能です。
実際に、C言語は各構造体のポインタとしてキャストでき、またSwift2までは、withUnsafePointer()とUnsafePointer()を使っての型変換が可能でした。

Swift3では、Swift2までのようなUnsafePointer()を使った型変換ができなくなっていますが、そのかわりunsafeBitCast()関数でキャストが可能です。

■sockaddr・sockaddr_inの相互変換

sockaddrでは、sockaddr_inのsin_port(=UInt16)にあたる部分がInt8☓2つになっています。
これは単にビット配列の問題だけなので、イメージとしては、Int8をUInt8(bitPattern:)でUInt8化し、UInt16の上位バイトにあたるものを8ビット左シフトしてから論理和をとり、UInt16へと変換する形です。

尚、sockaddrは汎用的なソケットアドレスを、sockaddr_inはインターネットにおけるソケットアドレス(IPv4)を指しています。
そのため、sockaddr_inではポート番号(sin_port)とIPアドレス(sin_addr)を個別に存在させる形となっています。

以上のことを踏まえると、sockaddrからsockaddr_inへの変換は、unsafeBitCastを使わない場合、以下のようになります。

■sockaddrからsockaddr_inへの変換例

unsafeBitCast変換でも上記のような変換を行っていることになります。

注意が必要なのは、sockaddrやsockaddr_inではビッグエンディアンとしてデータを扱う、という点です。
これは、ネットワーク上で数値をやり取りする場合、原則としてビッグエンディアンで行うという取り決めから来ています(RFC1700(インターネットプロトコルでの数値型の取り扱い) Page 2に記載)。

この取り決めにより、例えばrecvfrom()やsendto()といった関数の引数として渡すsockaddrの中身は、ビッグエンディアンのデータである必要があります。

そのため、上記の変換では、sin_portとsin_addrをビッグエンディアンとして格納しています。

一方、unsafeBitCastを使わないsockaddr_inからsockaddrへの変換では、以下のような処理を行います。

■sockaddr_inからsockaddrへの変換例

 

ソケットを使うネットワーク通信の場合、通信先のIPアドレスやポート番号を格納したsockaddr_inからsockaddrを生成して各種ソケット通信関数に渡す、というのが基本的な流れです。


上記までの例では、sockaddrまたはsockaddr_inが予め定義されている状態での変換を取り扱いました。

しかし実際には、sockaddr_inをsockaddrから変換して生成するのではなく、ポート番号やIPアドレスからsockaddr_inを直接生成することになります。

そこで、ポート番号とIPアドレスからsockadr_inを生成する例を、以下に記載します。

■ポート番号やIPアドレスからsockaddr_inを生成するコード例

 

【Swift3】色々なデータ→バイト配列(UInt8配列)化のメモ

ネットワーク上でのデータのやりとりなど、様々な場面で各種データをバイト配列に変換する必要が出てきます。

本稿では、各種データをバイト配列化する方法についてメモします。

以下のコードは、全てXcode8.3.2にて動作確認済みです。

■(1) 数値データをバイト配列化

数値データは変数のサイズが予め決まっているため、Data型のコンストラクタでDataへの変換が容易に可能。
ここで言う数値データは、Int32、UInt64などのプリミティブ型を指す。

Androidアプリなど、他のOSやアプリとやり取りする際には、対象のエンディアンに注意。
Swiftではデフォルトがリトルエンディアンなのに対し、プロトコルヘッダなどネットワーク上でやり取りするデータやAndroid端末ではデフォルトがビッグエンディアンであるため、そのまま数値データをDataでやり取りするのではなく、各数値データ型の.bigEndian プロパティを活用するなどの対応を行うこと。

尚、後述する通り、Dataと[UInt8]は、ほぼ相互変換が可能。

■(2) String, Data をバイト配列化

String型は、文字列をそのまま保持しているのではなく、文字列が格納されている場所を保持するポインタであるため、数値データと同様の方法では正常にバイト配列化できない点に注意。

Data と [UInt8]は、キャストほど直接的ではないものの、コンストラクタを通して相互変換可能。

■(3) それ以外のインスタンスなどをバイト配列化

バイト配列化して変換して・・・と考えるよりも、JSON文字列への変換と、JSON文字列からのインスタンス化を実装した方が扱いやすい。
そもそもバイト化すると各プログラミング言語への翻訳が必要となり、マルチデバイスで展開する場合は非常に扱いづらい。
文字列化すれば、ネットワーク上で他のOSやアプリとのデータやり取りも簡易になるため便利。

■(4) ジェネリクス + 可変引数で実装する場合の注意点

各変数を一括でバイト配列化したい場合、方法の一つとしてジェネリクスと可変引数での対応が考えられる。

同じ型をバイト配列化する場合、ジェネリクス型を指定することで、容易に可能。

一方、例えばInt32とUInt16など、異なる型のデータを一括でバイト配列化したい場合、可変引数としてAny型を指定してしまうと、Any型の仕様として32バイトのデータとして取り扱われるため、変数のサイズが正しく取得できず、正常なバイト配列化が不可能(AnyObjectでも同様)。

この解決方法の一例として、各型のサイズを返すプロトコルを定義し、extensionでプロトコルの継承と定義を行った上で、当該プロトコルの型を可変引数にとる関数での実装を記載。

ただし、使用するデータ型の種類が増えると、extension実装が必要な型が増えて煩雑になるため、データのJSON文字列化や利用する型の統一化(Int32としてしかバイト配列化しない、など)といった対応が良いと考える。

【Swift3】OptionSetの使い方メモ

Swift3におけるOptionSetの使い方メモです。

OptionSetは、利用する値を論理和(OR)としても使いたい場合に非常に便利な型で、主にフラグ管理を行う場合に利用します。

以下のコードは、例として赤・緑・青それぞれの色のランプのOn/Offをイメージしています。
※Xcode 8.3.1のPlaygroundで動作確認

以下、特に説明が必要な部分のみ記述します。

■(1) 定義済みの値一つを指定

通常の変数指定と同様。
OptionSet型変数の「==」比較では単純に内容値の比較のみとなるため、「対象の値を内包するかどうか?」を調べたい場合は.contains()を使用。

■(2) 複数の値を指定する場合

初期化は、定義済み変数を要素とした配列の記述で指定。
指定した複数の値は、自動的に内容値の論理和に設定。

■(3) 空の指定と値の追加

初期化方法は配列と同様だが、要素の追加と削除が配列とは異なり、要素の追加には.insert()、削除には.remove()の各メソッドを使用。
上記のコード例では空の値を指定するため [] で初期化したが、(1)のように定義済みの値一つを指定した状態で.insert()しても複数の値を指定したことと同様になる。

尚、.isEmpty は、正確には「空になった」ではなく、「rawValueが0になった」かどうかを返す点に注意。

■(4) 内部値を指定して初期化

内部値であるrawValueを直接指定して初期化した場合、指定したrawValueに定義済み変数で指定したものがある場合には、その定義済み変数はすでに設定済みの状態となる。

【Android, iOS】「佐世保のバス時刻」ver.1.1.0をリリース

AndroidならびにiOSアプリとして提供している「佐世保のバス時刻」につきまして、ver.1.1.0をリリースしました。

ver.1.1.0-Android版 ver.1.1.0-iOS版
(左:Android版、右:iOS版)

↓ダウンロードはこちらから
Google Play で手に入れよう 

 

主な変更点は、下記の通りです。

  • 「平日/土/日/祝」の文字を文字アイコンに変更
  • 一部アイコン画像の変更

 

ぜひ、ご利用ください!

【iOSアプリ】「佐世保のバス時刻」をリリースしました!

Android版に続き、iOSアプリでも「佐世保のバス時刻」をリリースしました!

こちらもぜひご利用ください!

Simulator Screen Shot 2016.06.11 8.19.46Simulator Screen Shot 2016.06.11 8.21.0502_Simulator Screen Shot 2016.06.11 8.20.02

↓ダウンロードはこちらから

【動作環境】

  • iOS9.0以上
  • iPadでも利用可能ですが、画面はiPhone向けに作成しています

=====以下、ストア掲載内容=====

佐世保市内を運行するバスの時刻表を表示するアプリです。

通勤・通学時間などに合わせた、スケジューリングによる時刻表示機能も付いています。
バスでお出かけの際や通勤・通学時などに、ぜひご利用ください!

【特徴】
・佐世保市営バス・西肥バス両方の時刻表表示に対応
・時刻表を表示したいバス停に対し、複数の行き先を選択指定することが可能
・通勤・通学など、決まった時間帯・行き先に対する時刻表の表示スケジュール設定が可能

【注意事項】
・ 本アプリは、表示する時刻表データを、佐世保市交通局および西肥自動車株式会社(以下、バスサービス提供会社)が各公式Webサイト上で一般公開している バスの時刻表情報を参照して作成していますが、個人で開発しているものであるため、仕様・動作や表示される情報など本アプリの全てに関して、バスサービス 提供会社とは一切関係がございません。
・時刻表データの不備や誤記などがございましたら、開発者(info@chobitech.com)までご連絡ください。
・本アプリをご利用の上で発生したあらゆる損害に関して、開発者は一切の責任を負いませんので、予めご了承ください。

====ストア掲載文章ここまで=====