PsyTouSan’s LAB

アプリ開発に関することから、くだらないことまで。

白霧島と黒霧島を飲み比べてみる

今日は黒霧島白霧島を飲み比べます。最近は、少しだけ芋焼酎にはまっているので、そのノリで書いていきます。
霧島シリーズはこれら二つだけでなく、赤霧島とか茜霧島とか様々なシリーズがありますが、まだ飲んでいないので、人生の楽しみの一つとして温めておきます。

f:id:PsyTouSan:20220317173442j:plain:w500
黒霧島と白霧島のワンカップ

ワンカップサイズを買いました。黒霧島のワンカップはそこらじゅうで見かけますが、白霧島のワンカップはあまり見かけません。偶然見かけたコンビニでゲットしました。

動機

黒霧島と白霧島はどちらも一回飲んだことがあります。しかし、両者の違いが分かりにくいなと感じたのが正直なところでした。一回目に飲んだ時は、最初に黒霧島を頂いた後に、しばらくの期間を空けて白霧島を頂きました。なので、今回は黒霧島を飲んだ後に、間髪入れずに白霧島を飲んで両者の違いを探ってみたいと思います。

黒霧島

まずは、一番オーソドックスな黒霧島から飲んでみたいと思います。キャッチコピーはトロっとキリっと黒霧島です。
記事を書いている途中で気づきましたが黒麹で造られているそうです。サツマイモは黄金千貫だそうです。
トロっとは甘み、キリっとはキレの良さを表しているみたいです。なお、アルコール度数は25度です。

f:id:PsyTouSan:20220317173510j:plainf:id:PsyTouSan:20220317173501j:plain

黒霧島と肴です。黒霧島に氷の入ったグラスが凄く似合っています。なにも味のない焼酎とは違って、オンザロックやお湯割りでも、しっかりと味があって美味しいです。

白霧島

続いて白霧島を飲んでみます。こちらのキャッチコピーはどしっとほわんと白霧島です。こちらは、白麹で造られているそうです。しかし、使用しているサツマイモの品種は黒霧島と変わりありません。度数も25度になっています。

f:id:PsyTouSan:20220317212530j:plain:w500
お湯割り

こちらも肴を用意しました。霧島なので、白っぽい肴を用意しました。オンザロックとお湯割りです。
こうして両者を飲み比べてみると、思ったより違いがあります黒霧島よりも、ガツンと来ます。さらに、白霧島の方が口の中に残りやすいです。まさにほわんとしています。

感じた違い

期間を空けて飲んだ場合と、間を空けずに飲み比べた場合では、全然インパクトが違いました。黒霧島の方が、白霧島よりも明らかにキリっとしています。要するに、飲み込んだ後にお酒の風味が潔く去っていく感じです。対して、白霧島は飲み込んでもしばらくの間はお酒の風味が口の中に残留します。味は、白霧島の方が爽快です。ただ、香りに関しては両者ともあまり違いはありませんでした。

黒霧島(「キリっと」は分かりやすい)

キャッチコピーにあるキリっとに関しては、しっかりと感じることができます。白霧島に比べても明らかに、キレがいいです。しかし、トロっとに関しては分かりにくかったです。白霧島との比較をすると、こちらの方が少しだけトロっとしているような気もします。

白霧島(概ねキャッチコピー通り)

こちらのキャッチコピーにはほわんとと入っていますが、これは分かりやすいです。口に入れた時に、口の中で何かが膨張するような感じです。しかも、前述したとおり黒霧島よりもキレが少ないので、余計にほわんと感じると思います。さらに、飲んだ時にガツンと来ます。これが恐らくどしっとということでしょう。

個人的な感想

飲み比べてみると、意外と違いがあることがわかりました。個人的には、黒霧島の方が好みです。白霧島はインパクトと爽快感があり、これはこれで美味しいです。ただ、やっぱ黒霧島です。霧島シリーズの中で、トップクラスの人気を誇るだけあり、万人受けしやすい芋焼酎になっていると思います。
ここからは完全に余談ですが、焼酎とお菓子が想像以上に相性が良くてびっくりしました。焼酎のロックでは、度数も高い上に割とガツンと来る(それこそ白霧島とか)ので、そのインパクトをチョコレートやビスケットなどでそれなりに緩和できるので、料理を食べ終えた後の晩酌には最高かもしれません。

日本酒にアイスを組み合わせるとどうなるか。

この記事は、どうにかしてアイスと日本酒を組み合わせて、美味しく頂く方法はないのかと考えた者の奮闘記です。最後までご覧いただけると幸いです。

動機(合うのか、合わないのか)

これに関しては完全になんとなくです。一般的には、日本酒は天ぷら、刺身、焼き鳥や鍋料理と共に頂くものです。ただ、お菓子とかデザートと組み合わせも悪くないのでは?そう思ったので、今回試してみました。あと、バニラアイスに燗の日本酒をぶっかけると、結構見栄えが良くなりそうなので「合うのか、合わないのか」という点でも気になります。

調べてみると気になるサイトが…

しかし、自分が実践する前に、アイスに酒という組み合わせに先人がいるのではないかと思ったので、検索ワードに「日本酒 アイス」と入れて検索してみました。すると、割と上位の方に以下の記事が出てきました。

magazine.asahi-shuzo.co.jp

日本酒の銘柄として非常に有名な「久保田」を醸造している、朝日酒造さんのサイトです。このサイトを少し覗いてみると、以下のようなことが書かれていました。

  • 芳醇でコクのある日本酒には、「アイスクリーム」や「アイスミルク」と表記されているような、ミルクの風味豊かな濃厚タイプのバニラアイスが良く合います。(引用)

芳醇でコクのある日本酒が、バニラアイスと合いやすいらしいので、これを参考にします。そこで今回使用する日本酒は、芳醇なお酒を中心に試していきたいと思います。ただ、それだけでなく価格が安いお酒や、淡麗辛口のお酒など、いろいろ試してみようと思います。

使用するアイス

というわけで、バニラアイスを用意します。アイスにも多少こだわって頂きたいと思いますので、少し奮発してハーゲンダッツを使用します。ただ私自身、ハーゲンダッツを食べたことがないので日本酒と一緒に食べる前に、そのままの状態のハーゲンダッツを食べてみました。やはり、やや高級なアイスということでかなり濃厚なバニラ味でした。というか、ねっとり感がほかのバニラアイスよりも強かったです。普通に美味しいアイスです。

試した日本酒

アイスと組み合わせる日本酒を紹介します。

  1. 八海山特別本醸造
  2. 魚沼 淡麗辛口
  3. 月桂冠 山田錦純米パック
  4. 久保田 碧寿
  5. 一ノ蔵 円融

この5つで試してみます。個人的には、八海山特別本醸造が2番か3番目に好きなお酒です。安すぎず高すぎず、ちょうどいい価格です。そして、その価格以上に美味しいです。ただ最近は、月桂冠の純米紙パックのお酒を飲んでいます。安価なお酒ですが美味しいです。しかし、この記事で一番注目すべきは、久保田碧寿。先ほど紹介した朝日酒造さんの記事でも紹介されていた日本酒です。このお酒は純米大吟醸としては珍しく、でも美味しく頂くことができる銘柄なので、アイスと組み合わせて頂く事はもちろん、食中酒として頂く事も楽しみにしています。また、バニラアイスに相性がいいとされている芳醇なお酒は、山廃仕込みのものに多いらしいので、山廃仕込みは「久保田 碧寿」と「一ノ蔵 円融」の2つを用意しました。

八海山特別本醸造

今回試す5つの日本酒の中では、唯一の非純米酒(特別本醸造)です。好きなお酒なので、アイスと一緒に食べる前に、少しだけ味わいました。
少しだけ味わった後に、いよいよアイスにかけて頂きます。

f:id:PsyTouSan:20220316210522j:plainf:id:PsyTouSan:20220316210459j:plain
八海山特別本醸造
感想

アイスとの組み合わせは初めてですが、意外と美味しいです。アルコール感がだいぶ緩和されているので、極めて飲みやすく感じました。ただ八海山本来の風味は、衰えてしまいます。雰囲気としては、焼酎のコーラ割に近いような気がしま。度数の高いお酒を、甘い何かで割って飲みやすくする的な感じです。でも、全然悪くないです。

月桂冠 山田錦純米パック

比較的安価な日本酒の中でも、個人的に美味しいと感じているやつです。なんだかんだ気に入っています。アイスと日本酒のツーショットを撮ってみましたが、和洋折衷で意外にも似合っていますね。ブルーベリーがいい感じのアクセントになっていて綺麗ですが、ミントとか乗せてみるとさらに綺麗に映るかもしれません。

f:id:PsyTouSan:20220301190754j:plainf:id:PsyTouSan:20220301190724j:plain

右の写真は、上燗(45度)にした酒をぶっかけた様子です。酒を温めると、アイスにぶっかけた時に少し溶けるので、アイスと酒が混ざり易くなります。

感想

八海山特別本醸造より相性は良いと思います。癖がなくシンプルです。お酒自体は、比較的芳醇なものなのでバニラアイスとの組み合わせは良い方です。

魚沼 淡麗辛口

白瀧酒造さんが醸しているお酒です。白瀧酒造といえば、上善如水でも有名な酒蔵です。
お酒にかけるとこんな感じです。試しに一口だけ飲んでみましたが、普通の美味しいお酒です。少し八海山に近いような気がしますが、魚沼の方が甘くない(辛口)です。

f:id:PsyTouSan:20220316210457j:plainf:id:PsyTouSan:20220316210456j:plain
魚沼

さっきから特に代り映えありません。

感想

少し、八海山特別本醸造と似ている気がしますが、こちらの場合は八海山の売りの一つである麹香があまり感じられませんでした。しかし、肴がアイスの場合むしろ麹の香りが立たない方が良いかもしれません。なので、八海山特別本醸造と比較した場合は、魚沼の方が優勢かと思われます。

久保田 碧寿

主人公です。碧寿は山廃仕込みのお酒となっています。初めて頂くお酒なので、まずは一杯頂きました。もちろんぬる燗で頂きます。ぬる燗なので香りは結構立ちます。しかし、癖が無くてスッキリしており、酸味も強めなのでちょっと贅沢な食中酒として楽しめます。とはいっても、今回はハーゲンダッツの食中酒です。

f:id:PsyTouSan:20220316210455j:plainf:id:PsyTouSan:20220316210439j:plain
久保田 碧寿
感想

これまでの組み合わせの中では、群を抜いて美味しいです。麹香や淡麗によるさっぱり感などがないため、アイスとの組み合わせは非常に良いと思います。しかし、碧寿の場合は、そういった複雑さがなくてシンプルに味わうことができました。これまでの組み合わせだと、「月桂冠 純米パック」が一番近いと思われます。

一ノ蔵 円融

こちらも、碧寿同様に山廃仕込みのお酒となります。碧寿とどのくらい違いが出るのか楽しみです。これも初めて頂くお酒なので、まずは一杯。
思ったよりも酸味が強かったです。酸味が強いということはつまり芳醇である証なので、アイスに会う可能性が高いということです。

f:id:PsyTouSan:20220316210436j:plainf:id:PsyTouSan:20220316210434j:plain
一ノ蔵 円融
感想

これも非常に美味です。もしかすると、碧寿超えの可能性もあるくらい相性がいいです。お酒単体では、酸味が強いので、味の濃い料理との相性が良いと思います。ですが、酸味の強いお酒に、バニラと合わせて頂いてみると、お酒の酸味が良い感じに緩和されるので、癖のない味わいになって飲みやすくなります。

結論(普通に美味しい)

アイスとの組み合わせは全然ありだと思います。しかし個人的におすすめしたいのは、久保田 碧寿一ノ蔵 円融です。ただ、この2種類のみならず、山廃仕込みのお酒であれば美味しく頂けると思います。もちろん、山廃仕込みのお酒は、この記事で紹介した2種類だけではないので、今後も機会があれば記事にしてまとめてみたいと思います。

卒業旅行に広島へ…(獺祭本社、厳島神社、しまなみ海道etc...)

高専の卒業旅行として広島へ行ってまいりました。その内容を記事にざっくりとまとめます。
旅行は3泊4日で、広島県を中心に観光しました。

高崎駅から羽田空港

群馬県高崎駅から羽田空港第2ターミナル駅までは、9時29分発の上野東京ラインに乗って、赤羽駅まで到着した後、京浜東北線で在来線やモノレールをうまいこと乗り継いで到着しました。ただ、羽田空港からは飛行機となります。

f:id:PsyTouSan:20220310173757j:plainf:id:PsyTouSan:20220310173755j:plain
赤羽駅羽田空港第2ターミナル

私が小さい頃に乗ったことがあるみたいですが、そんな小さい頃の記憶など残っていないので、実質飛行機となります。
羽田空港内は、めちゃめちゃ広かったです。あと、高専に通っていた私からしたら、道行く女子は全員可愛いと思いました。

搭乗手続き(シンプルに不安)

自分で飛行機に乗ったことがない人間なので、搭乗手続きに手こずるか多少心配でしたが、直感でうまくいきました。航空券はネット予約で済ませたので、あらかじめ取得してあった予約番号を、券売機みたいなやつに入力すると、QRコードが描かれた搭乗券がウィーンって出てきます。

保安検査場(面倒)

保安検査場を見た途端に、面倒な雰囲気が漂っているのが感じ取れました。金属探知機ゲートやら、X線付きコンベヤーやら、モニターを眺める監視員の姿など、テレビや動画などのメディアでもよく見る光景です。財布、イヤホン、鍵やパソコン、さらにはベルトといった、金属含んでいる物を全部かごに入れる必要があるので割と面倒でした。しかし、面倒でも仕方ありません。機内に爆弾でも持ち込まれたり、機内で燃料を撒かれたり、銃器を見せてハイジャックされたら、たまったもんじゃありません。旅行が楽しくなくなるどころか、生きて帰れなくなります
ちなみに、金属探知機は3往復もしました。割とうるさめの音量でピッピピッピ鳴ります。

離陸(楽しい)

滑走路に入ってしばらくすると、エンジンがウォーミングアップし始めます。そして、前進を開始して数秒後に物凄い加速をして、十分な速度に到達すると離陸します。また、離陸後しばらくの間は機内が多少上下しますが、これもスリルがあって楽しいです。
飛行機は地上から10km以上の高度を、時速600km/hくらいの速度で飛び続けます。

着陸

羽田空港から広島空港までは、大体1時間15分で到着します。割とすぐに到着しました。関東地方から、1時間ちょっとで中国地方へ飛んでいくので、ちょっと不思議な感覚ですね。宿泊予定のホテルは広島駅近くにあるので、広島駅まではバスで移動します。バスは、広島空港と広島駅の往復券を購入しました。2700円くらいだった気がします。往復券は、一週間以内に使わないと、期限切れになってしまいます。

一日目

到着した時間が午後4時半前くらいだったので、あまり大きな移動をすることができません。なので、近場で観光できる場所に行きたいと思います。

広島駅(カープファンが大勢)

駅に到着する也、試合観戦が終わった大勢のカープファンがぞろぞろと駅構内に入ってまいりました。皆、すがすがしい顔をしていたし、ほぼ同じタイミングで「広島カープ サヨナラ勝ち」みたいなニュースが流れてきたので、凱旋ですね!
さすが広島だなと言った感じです。また、駅構内はかなり広く、商業施設も充実しており土産物を買うにも都合が良かったです。

広島城

最近まで存在そのものを知りませんでしたが、駅からだと歩いて20分位に位置しています。あまり大きな移動ができないので、友人と2人でとりあえず行ってみることに。

f:id:PsyTouSan:20220311084123j:plainf:id:PsyTouSan:20220311084125j:plain
広島城

近くで見るとこんな感じです。

f:id:PsyTouSan:20220311084119j:plain
広島城天守閣跡

敷地がかなり広かったです。敷地内には、建物が存在したであろう跡がちらほら見えました。「歴史的建造物なのに、なんで取り壊したんだろう」と思いましたが、ここは原爆ドームが位置する広島県です。天守となっているのも頷けます。

さらに、近くに池田勇人氏の像が建っていました。

f:id:PsyTouSan:20220313202928j:plainf:id:PsyTouSan:20220313202929j:plain
内閣総理大臣 池田勇人氏の像

頭にカラスが乗っているのはたまたまです。

一日目はこれで終了です。

続きを読む

【Android Studio】メモアプリの作成 Firebaseを用いてログイン機能を実装

メール・パスワードによるアカウント認証機能を持つメモアプリを作ります。また、作成しメモはクラウドに保存されるため、他の端末からログインすれば同じメモを見ることができますし、編集もできます。メモアプリにわざわざ認証機能を追加する必要があるのかと考えればそれまでですが、勉強の一環として実践しようと思います。

デモンストレーション

こんな感じのやつを作ります。

f:id:PsyTouSan:20220228204345g:plainf:id:PsyTouSan:20220228204134g:plain
左:ログインとログアウト 右:メモの追加と削除

こんな感じのメモアプリを作成します。ログイン、ログアウトの他にも、アカウント作成やメモの作成といった、基本的な機能はすべて備えたメモアプリの作成です。

Firebaseについて

Firebaseを利用する際は、プロジェクトのセットアップを行い、ここで様々な手順を踏んでようやく利用できるようになります。ただ、この作業自体は、セットアップ時に親切な指示を色々としてくれるので、意外と簡単です。
とりあえず、「firebase」と検索をかけて、トップに出てきたGoogleのサイトにアクセスすると、以下の画像のような画面になると思います。この画面で、「使ってみる」をクリックすると、プロジェクトのセットアップや、プロジェクトの管理を行うFirebaseコンソールに遷移します。

f:id:PsyTouSan:20220222234746p:plain
ここの「使ってみる」をクリック

これがFirebaseコンソールです。ここのプロジェクト作成をクリック。

f:id:PsyTouSan:20220223000830j:plain
Firebaseコンソール

あとは、指示に従ってプロジェクトの作成を完了してください。

Firebase Authentication

今回は、メールアドレスとパスワードによる認証方法を使用するので、sign-in-methodタブから、メール/パスワードをクリックします。

f:id:PsyTouSan:20220223085748p:plain
メール/パスワードをクリック

クリックして、メール/パスワードを有効にして保存してください。

f:id:PsyTouSan:20220223090024p:plain
有効にしたら、保存をクリック

Firebase Authenticationの設定はこれで完了です。続いて、Realtime Databaseの設定をしていきます。

Firebase Realtime Database

Realtime Databaseの画面に入ると、最初には以下の画面が表示されているのでデータベースを作成をクリックします。

f:id:PsyTouSan:20220225124913p:plain:w450
「データベースを作成」をクリック

すると、データベースのロケーションを設定します。ここでは米国(us-central1)を選択します。

f:id:PsyTouSan:20220223092624p:plain:w500
ロケーションの指定

次に、セキュリティルールの設定を行います。とりあえずロックモードでいいです。

f:id:PsyTouSan:20220223093103p:plain:w500
ロックモードで開始

完了したら、右下の「有効にする」をクリックしてデータベースの構築を完了してください。詳しいことは、この記事では述べませんので、これ以上に詳しい手法について知りたい場合は、ご自身で調べてみてください。この記事よりよっぽど分かりやすい記事がたくさんアップされています。
とりあえず、アプリ開発の方に進んでいきます。

アプリの仕様

最初に今回開発するアプリの大まかな仕様について決めていきたいと思います。

画面遷移はActivityResultLauncherを主軸とする

シンプルな画面遷移を行う場合、多くはstartActivityメソッドを使うケースが多いですが、個人的にはActivityResultLauncherの方が好きです。アクティビティ間(フラグメント間)で何かしらの値を渡したい場合、例えば、アクティビティAでユーザが入力した文字列を、アクティビティBへ渡したい場合に活用することができます。さらに、最近になってstartActivityForResult()メソッドが非推奨となった影響もあり、このアプリではActivityResultLauncherをメインで使うことにします。

アクティビティの役割

作成するアクティビティの役割をまとめます。それが以下の通りです。




Javaファイル名

XMLファイル名

役割

MemoListActivity

activity_memo_list

メモのリスト表示

CreateMemoActivity

activity_create_memo

メモの作成

LoginActivity

activity_login

既存アカウントにログイン

CreateAccountActivity

activity_create_account

新たなアカウントの作成

MemoData

無し

メモ一つが持つデータ型

CustomListAdapter

無し

独自のリストアダプタ

アクティビティとは言っていますが、MemoDataとCustomListAdapterはアクティビティではなく、ただのJavaファイルです。

アクションバーの非表示

今回は、アクションバーを非表示にしたいと思います。なので、themes.xmlファイルを開いて以下のように記述してください。

<resources xmlns:tools=...>
    <!-- Base application theme. -->

    //以下の文
    <style name="Theme.MemoApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">

        //省略

    </style>
</resources>

文の最後に「NoActionBar」と追記しているだけです。これで、アプリ画面上部に表示されるアクションバーが表示されなくなります。

レイアウトの作成

まずはレイアウトを作成します。右下のFloatingActionボタンを配置して、それをクリックすることで新たなメモを作成できるようにします。また、Stringデータは面倒だし分かりにくくなると思うので使いません。

activity_memo_list

メモのリスト表示を行うアクティビティのレイアウトファイル(activity_memo_list.xml)です。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MemoListActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/tool_bar"
            android:layout_width="match_parent"
            android:layout_height="?android:actionBarSize" />

    </com.google.android.material.appbar.AppBarLayout>

    <ListView
        android:id="@+id/memo_list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/app_bar" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/add_new_memo"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginRight="15dp"
        android:layout_marginBottom="15dp"
        android:src="@drawable/add_24"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

特に難しい部分はありません。非表示にしたツールバーを、9行目から19行目の部分で記述しています。言い換えれば、xmlファイルでこの記述をしない場合はツールバーが表示されなくなります。

activity_create_memo

メモをリストに追加するアクティビティのレイアウトファイルです。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CreateMemoActivity">

    <EditText
        android:id="@+id/memo_title"
        android:layout_width="0dp"
        android:layout_height="60dp"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="40dp"
        android:layout_marginRight="20dp"
        android:hint="メモのタイトルを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/memo_contents"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="40dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:hint="コンテンツを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/memo_title" />

    <Button
        android:id="@+id/add_new_memo"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:layout_marginRight="30dp"
        android:backgroundTint="#adadad"
        android:text="追加"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/memo_contents" />

</androidx.constraintlayout.widget.ConstraintLayout>

custom_memo_card.xml

メモリストに表示されるリストのカード一枚分のレイアウトです。これを応用すれば、自分の思うようなウィジェットを、リスト状に表示することができます。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:paddingBottom="6dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/memo_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Date"
            android:textSize="15sp"
            android:layout_marginLeft="5dp" />

        <TextView
            android:id="@+id/memo_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="5dp"
            android:text="Sample Title"
            android:textSize="27sp"
            android:maxLines="1"
            android:ellipsize="end"
            android:textAppearance="?android:attr/textAppearance"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/memo_contents"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="sample contents "
            android:textSize="18sp"
            android:maxLines="2"
            android:ellipsize="end"
            android:layout_marginLeft="5dp"
            android:layout_marginBottom="6dp"/>

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

今回の開発で初めて知った、少し便利な関数を紹介します

30行目(android:maxLines)

TextViewに表示できる最大行数を設定することができます。指定した行数を超える文字を表示しようとしても、指定した行数を超えることがなくなります。

31行目(android:ellipsize)

今回は"end"の属性を与えています。この場合は、行数を超える文章を表示させようとすると、最後に「...」が追加されます。ユーザ目線では、「この後も文章が続いているんだな」と思わせることができます。

activity_login.xml

ログインを行うためのアクティビティのレイアウトです。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LoginActivity">

    <EditText
        android:id="@+id/email"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textEmailAddress"
        android:layout_marginTop="40dp"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:hint="メールアドレスを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textPassword"
        android:layout_marginTop="20dp"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:hint="パスワードを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/email" />

    <Button
        android:id="@+id/loginButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="ログイン"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password" />

    <TextView
        android:id="@+id/createAccount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="※アカウントを新たに作成する"
        android:layout_marginTop="16dp"
        android:textSize="14sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.15"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/loginButton" />

</androidx.constraintlayout.widget.ConstraintLayout>

activity_create_account.xml

アカウント作成を行うためのアクティビティのレイアウトです。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CreateAccountActivity">


    <EditText
        android:id="@+id/email"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textEmailAddress"
        android:layout_marginTop="40dp"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:hint="メールアドレスを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textPassword"
        android:layout_marginTop="20dp"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:hint="パスワードを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/email" />

    <EditText
        android:id="@+id/rePassword"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textPassword"
        android:layout_marginTop="20dp"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:hint="もう一度パスワードを入力"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password" />

    <Button
        android:id="@+id/createAccount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="アカウントを作成する"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/rePassword" />


</androidx.constraintlayout.widget.ConstraintLayout>

inputType属性を与えることで、テキストボックスに様々な特徴を持たせることができます。例えば、"textPassword"を与えると、入力した文字が中黒で表示されるようになります。

Javaソースコード

続いて、Javaソースコードを紹介します。上記に示したアクティビティの順番通りに紹介します。また、Javaのコードは、できるだけコードが短くなるように基本的にはラムダ式で記述しています。

MemoListActivity.java

//パッケージの宣言部分なので省略
package...

//ただのインポート文の羅列なので省略
import...

public class MemoListActivity extends AppCompatActivity {

    private DatabaseReference reference;
    private FirebaseUser user;
    private String uid;

    //CustomListAdapterについては後述します
    private CustomListAdapter customListAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_memo_list);

        setSupportActionBar(findViewById(R.id.tool_bar));
    }

    @Override
    protected void onResume() {
        super.onResume();

        //ログイン状態の確認
        user = FirebaseAuth.getInstance().getCurrentUser();
        if (user == null){

            //ログインしていない場合、ログイン画面へ遷移
            Intent intent = new Intent(MemoListActivity.this, LoginActivity.class);
            resultLauncher.launch(intent);
        } else {
            appSetup();
        }
    }

    private void appSetup(){
        Toolbar toolbar = findViewById(R.id.tool_bar);
        ListView memoListView = findViewById(R.id.memo_list);
        ArrayList<MemoData> memoListItem = new ArrayList<>();

        toolbar.setTitle("Memo List");

        user = FirebaseAuth.getInstance().getCurrentUser();
        assert user != null;
        uid = user.getUid();

        FirebaseDatabase database = FirebaseDatabase.getInstance();
        reference = database.getReference("users").child(uid);

        customListAdapter = new CustomListAdapter(this, R.layout.custom_memo_card, memoListItem);
        memoListView.setAdapter(customListAdapter);

        databaseListener();


        FloatingActionButton addMemo = findViewById(R.id.add_new_memo);

        addMemo.setOnClickListener(v -> {
            Intent intent = new Intent(MemoListActivity.this, CreateMemoActivity.class);
            resultLauncher.launch(intent);
        });

        memoListView.setOnItemLongClickListener((parent, view, position, id) -> {
            MemoData memoData =(MemoData) memoListView.getItemAtPosition(position);
            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.setTitle("メモの削除")
                    .setMessage("選択したメモを削除しますか?")
                    .setPositiveButton("削除",(dialog, which) -> {
                            reference.child("Memo").child(memoData.get_memoTitle()).removeValue((error, ref) -> {
                        databaseListener();
                        Toast.makeText(MemoListActivity.this, "メモを削除しました", Toast.LENGTH_SHORT).show();
                            });
                    })
                    .setNegativeButton("やめる",(dialog, which) -> {})
                    .show();
            return false;
        });
    }

    private void logout(){
        AuthUI.getInstance()
                .signOut(this)
                .addOnCompleteListener(task -> {
                    Intent intent = new Intent(MemoListActivity.this, LoginActivity.class);
                    Toast.makeText(this, "ログアウトしました", Toast.LENGTH_SHORT).show();
                    resultLauncher.launch(intent);
                });
    }

    //画面遷移を行うためのインターフェース
    ActivityResultLauncher<Intent> resultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
            result -> {
        if (result.getResultCode() == LoginActivity.RESULT_CODE_LOGIN){
            Toast.makeText(this, "ログインしました", Toast.LENGTH_SHORT).show();
        }
        if (result.getResultCode() == CreateMemoActivity.RESULT_CODE_CREATE_MEMO){
            Intent intent = result.getData();
            assert intent != null;
            String[] memo = intent.getStringArrayExtra("memo");
            MemoData memoData = new MemoData(uid, memo[0], memo[1], memo[2]);

            //Realtime Databaseにデータを保存する
            reference.child("Memo").child(memo[1]).setValue(memoData).addOnCompleteListener(task -> {
                if (task.isSuccessful()){
                    databaseListener();
                    Toast.makeText(MemoListActivity.this,"メモを追加しました",Toast.LENGTH_SHORT).show();
                }else {
                    Toast.makeText(MemoListActivity.this,"メモの追加に失敗しました",Toast.LENGTH_SHORT).show();
                }
            });
        }
    });

    //ツールバーにオプションメニューを追加する
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        super.onCreateOptionsMenu(menu);
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.memo_list_tool_bar, menu);
        return true;
    }

    //ツールバーのオプションをクリックしたときの処理
    @Override
    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
            if (item.getItemId() == R.id.logout){
                AlertDialog.Builder builder = new AlertDialog.Builder(this);
                builder.setTitle("ログアウト確認")
                        .setMessage("ログアウトしますか?")
                        .setPositiveButton("はい", (dialog, which) -> {
                            logout();
                        })
                        .setNegativeButton("やめる", (dialog, which) -> {

                        })
                        .show();
            }
        return super.onOptionsItemSelected(item);
    }

    //データベースの更新を検出する
    private void databaseListener(){
        customListAdapter.clear();
        user = FirebaseAuth.getInstance().getCurrentUser();
        assert user != null;
        uid = user.getUid();

        FirebaseDatabase database = FirebaseDatabase.getInstance();
        reference = database.getReference("users").child(uid);
        reference.child("Memo").addChildEventListener(new ChildEventListener() {
            //データベースに追加された要素があれば実行される
            @Override
            public void onChildAdded(@NonNull DataSnapshot snapshot, @Nullable String previousChildName) {
                MemoData memoData = snapshot.getValue(MemoData.class);
                customListAdapter.add(memoData);
                customListAdapter.notifyDataSetChanged();
            }

            @Override
            public void onChildChanged(@NonNull DataSnapshot snapshot, @Nullable String previousChildName) {

            }

            //データベースに削除された要素があれば実行される
            @Override
            public void onChildRemoved(@NonNull DataSnapshot snapshot) {
                MemoData memoData = snapshot.getValue(MemoData.class);
                customListAdapter.add(memoData);
                customListAdapter.notifyDataSetChanged();
            }

            @Override
            public void onChildMoved(@NonNull DataSnapshot snapshot, @Nullable String previousChildName) {

            }

            @Override
            public void onCancelled(@NonNull DatabaseError error) {

            }
        });
    }
}

メインアクティビティ的な立ち位置なので、かなり長くなってしまいました。部分的にコメントを書きましたが、一応少しだけ説明を加えます。

21行目(setSupportActionBar)

activity_memo_list.xmlファイルで追加したツールバーに、オプションメニューを表示させるためのメソッドです。

54行目(customListAdapter)

後述するクラスをインスタンス化しています。このクラスは、リスト表示をになるアダプタを継承したクラスです。そのクラスを用いることでアダプタをカスタマイズしています。

107行目(setValue(object))

Realtime databaseに値をセットします。Realtime databaseを通じて、アカウントごとにメモを保存します。すなわち、ログアウトをしたとしてもメモの内容が消えることはなく、ログインすることで再びメモを表示することができます。また、アカウントごとに保存なので、機種変更やアプリの再インストールなどでデータが消えることもなく、アカウントにログインさえすれば同じ内容のメモを表示することができます。ちなみに、Firebaseコンソールでは以下の画像のように表示されています。

f:id:PsyTouSan:20220227093013j:plain
Firebaseコンソール側の画面
121行目(inflater.inflate)

ツールバーのオプションメニューに表示する内容をメニューファイルを渡しています。このメニューファイルは次の通りです。

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item android:id="@+id/logout"
        android:icon="@drawable/logout_24"
        android:title="Logout"
        app:showAsAction="ifRoom"/>
</menu>

メニューファイルの作成は、「Project」ファイル内の「res」⇒「menu」を右クリックして、「New」⇒「Menu Resource File」から作成できます。
このファイルの5行目のdrawableは、vectorファイルです。vectorファイルの作成は、Android Studioの画面の左上の「File」から「New」⇒「Vector Asset」から作成できます。また、showAsAction属性には"ifRoom"としています。これは、ツールバーにアイコンを表示する余裕がある場合は、そのアイコンを表示し、できない場合はオーバーフローメニューの中に格納するように表示するための属性です。

CreateMemoActivity.java

新たなメモを作成するアクティビティです。MemoListActivityの右下に表示されたボタンから遷移します。

package...

import...

public class CreateMemoActivity extends AppCompatActivity {

    public static final int RESULT_CODE_CREATE_MEMO = 3000;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_create_memo);

        EditText memoTitle = findViewById(R.id.memo_title);
        EditText memoContents = findViewById(R.id.memo_contents);
        Button addButton = findViewById(R.id.add_new_memo);

        addButton.setOnClickListener(v -> {
            //メモタイトルに何も入力されていない場合は、必須項目であることをユーザに知らせる。
            if (!memoTitle.getText().toString().isEmpty()){

                //コンテンツに何も入力されていない場合は「詳細なし」で登録する。
                if (memoContents.getText().toString().isEmpty()) {
                    memoContents.setText("詳細なし");
                }
                String[] memo = {getCurrentDate(), memoTitle.getText().toString(), memoContents.getText().toString()};
                Intent intent = getIntent();

                intent.putExtra("memo", memo);
                setResult(RESULT_CODE_CREATE_MEMO, intent);
                finish();

            } else {
                memoTitle.setError("必須入力の項目です");
            }
        });
    }

    //現在日時を取得するメソッド
    private String getCurrentDate(){
        @SuppressLint
                ("SimpleDateFormat") SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm");
        Date d = new Date();
        return dateFormat.format(d);
    }
}

こんな感じです。メモの追加は、Addボタンをクリックしたときに追加され、クリック時にタイトルが未記入の場合はメモを追加できないようになっています。ただし、メモの内容が未記入の場合は「詳細なし」として格納されます。またメモの追加時に、その時点での日付を取得し、メモの作成日時としています。

LoginActivity.java

既存のアカウントにログインするためのアクティビティです。また、アカウント作成をするための画面にも遷移することができます。

package...

import...

public class LoginActivity extends AppCompatActivity {

    public static final int RESULT_CODE_LOGIN = 1000;
    private FirebaseAuth mAuth;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        activitySetup();
    }

    private void activitySetup(){
        EditText email = findViewById(R.id.email);
        EditText passWord = findViewById(R.id.password);
        Button login = findViewById(R.id.loginButton);
        TextView createAccount = findViewById(R.id.createAccount);

        login.setOnClickListener(v -> {

            //入力欄が空欄でないか確認
            if (!email.toString().isEmpty()) {

                //入力欄が空欄でないか確認
                if (!passWord.toString().isEmpty()) {
                    //ログイン処理の開始
                    mAuth = FirebaseAuth.getInstance();
                    mAuth.signInWithEmailAndPassword(email.getText().toString(), passWord.getText().toString())
                            .addOnCompleteListener(this, task -> {
                                if (task.isSuccessful()) {
                                    Intent intent = getIntent();
                                    setResult(RESULT_CODE_LOGIN, intent);
                                    finish();
                                }else {
                                    Toast.makeText(LoginActivity.this, "ログインに失敗しました", Toast.LENGTH_SHORT).show();
                                }
                            });

                }else {
                    passWord.setError("必須項目です");
                }
            }else {
                email.setError("必須項目です");
            }
        });

        createAccount.setOnClickListener(v -> {
            Intent intent = new Intent(LoginActivity.this, CreateAccountActivity.class);
            resultLauncher.launch(intent);
        });
    }

    ActivityResultLauncher<Intent> resultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
            result -> {
        if (result.getResultCode() == CreateAccountActivity.RESULT_CODE_CREATE_ACCOUNT){
            Intent intent = new Intent(LoginActivity.this, MemoListActivity.class);
            setResult(RESULT_CODE_LOGIN, intent);
            finish();
        }
            });
}
33行目(signInWithEmailAndPassword())

ログイン処理の開始は、33行目のsignInWithEmailAndPasswordメソッドによって行われています。このメソッドの第一引数にメールアドレス第二引数にアカウントのパスワードを渡すことで、ログイン処理が実行されます。ログイン処理が完了した場合は、addOnCompleteListener内の処理が実行されます。そして、task.isSuccessfulメソッドによって、完了したログイン処理が成功したか失敗したかをboolで返してくれます。

CreateAccountActivity

既存のアカウントがない場合は、新たにアカウントを作成する必要があります。ログイン画面から遷移することができます。

package...

import...

public class CreateAccountActivity extends AppCompatActivity {

    public static final int RESULT_CODE_CREATE_ACCOUNT = 2000;

    FirebaseAuth mAuth;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_create_account);

        activitySetup();
    }

    private void activitySetup(){
        EditText email = findViewById(R.id.email);
        EditText password = findViewById(R.id.password);
        EditText rePassword = findViewById(R.id.rePassword);
        Button create = findViewById(R.id.createAccount);

        mAuth = FirebaseAuth.getInstance();

        create.setOnClickListener(v -> {
            if (password.getText().toString().equals(rePassword.getText().toString())){

                //ここからアカウント作成の処理が開始される
                mAuth.createUserWithEmailAndPassword(email.getText().toString(), password.getText().toString())
                        .addOnCompleteListener(task -> {
                            if (task.isSuccessful()){
                                Toast.makeText(CreateAccountActivity.this, "アカウントを作成しました!",Toast.LENGTH_LONG).show();
                                setResult(RESULT_CODE_CREATE_ACCOUNT, getIntent());
                                finish();
                            } else {
                                Toast.makeText(CreateAccountActivity.this, "アカウント作成に失敗しました",Toast.LENGTH_LONG).show();
                            }
                        });
            }
        });

    }
}

ログインの場合と似ていますが、これでアカウントが作成することができます。

MemoData

これまでに紹介したソースコードの中にも、ちょこちょこ顔を出していたクラスです。一つのメモに格納するデータを意味しています。

package...

public class MemoData {

    public String _uid,_memoTitle,_memoContents,_date;

    public MemoData(String uid, String date, String memoTitle, String memoContents){
        this._uid = uid;
        this._date = date;
        this._memoTitle = memoTitle;
        this._memoContents = memoContents;
    }

    public MemoData(){}

    public String get_uid() {
        return _uid;
    }

    public String get_date() {
        return _date;
    }

    public String get_memoTitle() {
        return _memoTitle;
    }

    public String get_memoContents() {
        return _memoContents;
    }
    
}

見ての通り、ただのアクセサーメソッドの集まりです。コンストラクタの記述は忘れないようにしてください。
一つのメモに格納させるデータは、ユーザID、日付、メモタイトル、メモの内容の4つです。

CustomListAdapter

ようやく最後です。ArrayAdapterを継承したクラスを作成します。このクラスもこれまでのコード内に何回も顔を出しています。

package...

import...

public class CustomListAdapter extends ArrayAdapter<MemoData> {

    private final int Resource;
    private final List<MemoData>  _MemoData;

    public CustomListAdapter(@NonNull Context context, int resource, List<MemoData> memoData) {
        super(context, resource, memoData);
        Resource = resource;
        _MemoData = memoData;
    }

    @Override
    public int getCount() {
        return super.getCount();
    }

    @Nullable
    @Override
    public MemoData getItem(int position) {
        return super.getItem(position);
    }

    //リストカードに日付、メモタイトル、メモ内容を表示させる
    @NonNull
    @Override
    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {

        final ViewHolder holder = new ViewHolder();
        View v;
        if (convertView != null){
            v = convertView;
        } else{
            v = LayoutInflater.from(getContext()).inflate(Resource, null);
            holder.memoDate = v.findViewById(R.id.memo_date);
            holder.memoTitle = v.findViewById(R.id.memo_title);
            holder.memoContents = v.findViewById(R.id.memo_contents);
        }

        MemoData data = _MemoData.get(position);

        holder.memoDate = v.findViewById(R.id.memo_date);
        holder.memoDate.setText(data.get_date());

        holder.memoTitle = v.findViewById(R.id.memo_title);
        holder.memoTitle.setText(data.get_memoTitle());

        holder.memoContents = v.findViewById(R.id.memo_contents);
        holder.memoContents.setText(data.get_memoContents());

        return v;
    }

    static class ViewHolder{
        TextView memoDate,memoTitle,memoContents;
    }
}

ArrayAdapterを継承することで、独自のリストアダプタを形成することができます。10行目のクラスは、ArrayAdapterを継承している場合は必ず記述しなければなりません。

まとめ

以上で終わりです。長めの記事になってしまいました。ただ、Firebaseを活用すれば、簡単に高クオリティのアプリ開発が可能になりますから、今後も勉強していきたいと思います。今回は、ユーザ認証(Authentication)と、クラウドデータベース(Realtime Database)を利用しましたが、Firebaseはこれ以外にもかなり多くのサービスを展開しているので、それぞれ何ができるのか気になります。今後のアプリ開発次第ではお世話になるかもしれません。

なぜ原神に課金をしたくなるのか。単なる射幸心なのか?

最近、原神にはまってしまい課金してガチャを引こうか悩んでいる。なので、原神を始めとするソシャゲ課金をする事は愚かなのかを考えることにした。その考えの記録としてここに刻む。


ここで、一つ定義しておきたいことがあります。ここで言うソシャゲとは課金要素を含むゲームとします。具体的な、「ソシャゲ」の分類とか意味はここでは問わないものとします。
また、誤解のないように記しておきますが、決して課金をすることが悪いことと言うつもりは一切ありません。
加えて、日常的に重課金をしているほど余裕のある方に向けて書いたものでもありません。
また、これまでの課金総額は3000円程度であり大金を貢いだ経験は一切ないので、重課金から抜け出せず悩んでいる方のアドバイスにはならないと思います。
あくまでも、ガチャを引きたくて課金をしたいと悩んでいる学生の独り言としてお考え下さい。


さて、本題に入ろうと思う。
私は、これまでにガチャを引くために課金をしようと考えたことがない。課金をしたことがないといっても、いわゆるお得パック的なものは何度か購入したことがある。ただ、欲しいキャラがいて、そのキャラクターのために課金しようと考えたことがないということである。さて、どんなゲームに課金しようと考えているのかと言えば、タイトルからもわかる通り「原神」である。知っている人も多いだろう。今年の夏ごろに原神を始めて、なんだかんだプレイしてきたが、キャラクターデザインも優秀だし、モーションもかっこいい。それに、キャラクター一人一人にしっかりとしたストーリーが設けられており、親近感を感じることができる。そして、自分好みのキャラクターが登場したことで、割と大きめの額を課金しようと考えるようになった。しかし冷静になって考えれば、課金をしなくても十分に楽しめるコンテンツである。原神はオープンワールドであり、フィールドも広大である上に、そこら中に宝箱が眠っている。さらに、定期的にイベントが行われたり、イベント限定のストーリーも楽しむことができる。なので、別に無課金であろうと微課金であろうと、十分に楽しめるコンテンツに仕上がっているのは確かだ。それなのに、なぜ課金をしたくなるのだろうか。その理由について考察してみようと思う。

課金のメリット

課金をすることでゲームを有利に進めることができるのは言うまでもないが、ここでは、課金をすることの意義について考察する。
課金をせずに普通にプレイする場合、原石(いわゆる石)をまぁまぁの量しか貰うことができず、それは決して多い量ではないだろう。そして、この原石というのは、ゲームをするうえで非常に重要な役割を果たす。その最たるものがガチャの存在だと思う。この記事で述べる「課金」をすることによって、リアルマネーを代償に原石を手に入れることができる。

好きなキャラでプレイできる

正直、この理由が一番大きい。自分が格好いい、あるいは可愛いと感じたキャラクターは、ぜひ手に入れたいと感じるのが正直なところだ。しかも原神の場合、手に入れたキャラクターは画面内で歩いたり、走ったり、飛んだり、泳いだり、壁を上ったりする。さらに、どのキャラクターも個性的なスキルや行動、ボイスまでしっかりとついている。

強くなれる

ガチャを引くと強くなれる。例えば、スキルや大技が非常に強力なキャラクターであれば、それは是非手に入れて、敵を楽に倒したいと思うかもしれない。
敵を楽に倒せて、何か良いことがあるのかと問いたくなるかもしれない。しかし、このゲームにはエンドコンテンツに相当する要素がある。原神をプレイしている方なら分かるかもしれないが、そのエンドコンテンツ相当の要素とは、「深境螺旋」のことである。この深境螺旋に登場する敵はどいつもこいつも強敵で、生半可な育成では決して突破できるものではない。そこへ、強力なパーティを組めるキャラクターがいたらどうだろう。あるいは、既に所持しているキャラクターをもう一度引き当てて、強力な凸効果を得ることができたらどうだろう。簡単、とは言わないまでも、そこそこ楽に攻略できるようになると思う。

プレイの幅が広がる

プレイの幅が広がるというのは、そのゲーム性を非常に豊かにしているものと思える。例えば、一発で見たことがないほど桁外れなダメージを出してみたり、一瞬にして周囲の敵に大ダメージを与えて殲滅したり、アタッカーバーバラのように世間的にはサポーターとかヒーラーと位置付けられているキャラクターを無理やりアタッカーとして運用したりするのは、ロマンがあるし、そのキャラクターに対する愛情を感じることができて、すごく面白いと思う。
でも、これをするにはやはりガチャを回してキャラクターを手に入れるところから始める必要がある。そもそも、キャラクターを持っていなければ、そういったプレイやパーティ編成は組めないのだ。

課金のデメリット

次に、課金のデメリットについて考える。

飽きた先に残るは"虚無"

私が、課金に対して消極的な姿勢を示す最大の要因がこれ。のゲームに課金したところで、ガチャは引けるが物体として残るものは何もなく、ただの虚無である。もし、ゲームに飽きた場合、これまでの課金が単純に無駄になると考えると、どうしても尻込みしてしまう。

お金にはもっと有意義な使い道がある

少なくとも、Youtuberで原神の動画を投稿している方ではない限り、わざわざ多少のランダム要素をふくむガチャにお金をつぎ込む必要性は感じられないということである。とはいえ、むやみに宝くじやパチンコで散財する人よりはましだとは思うが。要するに、ランダム性のあるガチャを回すより、お金を払えば確実に手に入る娯楽(旅行、スポーツ、カラオケなど)、のほうにフォーカスすべきではないかと考えると、これまた尻込みしてしまう。極めつけには、結構の額を課金した後に冷静になって振り返り、その金額で何ができるだろうと考えてしまうと、なかなかの地獄である。

何が課金欲を搔き立てるのか

射幸心

ガチャである以上、この射幸心という感情を語らない訳にはいかない。これは、「次はもしかしたらうまくいくかもしれない」という、苦労をせずに思いもしなかった利益を期待することである。これは、ソシャゲのガチャだけでなく、宝くじ、パチンコや競馬といったギャンブルでも応用されている。ただ、原神やその他ソシャゲには、親切に「天井」という概念が用意されている。なので、ある程度の金額を費やせば、欲しいキャラクターが確実に手に入るのだ。なので、先に述べたようなギャンブルに比べたら、よっぽど親切である。そもそも、なぜ人間は不確定な要素に対して、利益を得ようとする期待を抱いてしまうのかという疑問がある。現状、ゲームを楽しくプレイできているのであれば、わざわざ不確定な要素にエネルギーを注ぐことはせずに、もっと他の部分にエネルギーを注いだほうが良いように思える。しかし、射幸心を煽られて課金をする人がいるのも事実である。はっきりとした目的がない場合は、射幸心を煽られようともお金を消費するのは良くないだろう。基本的に後で後悔するパターンが多い。しかし、単なる損得でゲームの課金要素の良し悪しを考察するのもよくない。

キャラクターの魅力

課金をする際に一番重要視するのは、やはりキャラクターの魅力だと思う。キャラの性格、ビジュアル、モーションやスキル、大技などにキャラクターごとの個性的な魅力がある。もし、自分に刺さるビジュアルのキャラクターだったり、キャラクターの個性が自分好みであったりすれば、欲しいと強く思うのは当然と言われれば当然だ。さらに、ゲームである以上、キャラクターごとに適した運用方法がある場合が多い。原神であれば、元素爆発効果中に受けるダメージが増加する代償として攻撃性能を上げて緊張感を楽しんだり、無類の回復力でパーティを手厚くサポートするゾンビ戦法で戦ったり、圧倒的な攻撃範囲と爽快感で無数の敵を葬る戦い方などである。

期間限定という仕様

私は、単純な射幸心と欲求だけが、課金欲を促進するものではないと思っている。それが、期間限定ガチャである。しかも原神の場合、期間限定ガチャの機会を逃すと、次に復刻ガチャとして登場するがいつなのかわからない。なので、期間限定という仕様が創り出す時間制限からくる焦りが原因の一つとも考えられる。

絶対的に"悪い"課金とは

メリット、デメリットだけを並べてもなかなか良い結論は出ない。なので、これだけは許せないとされる"悪い課金方法"について考察する。次に示した内容のものは、最終的に悪い結果を生みかねない課金の仕方について考えたものである。

目標の無い課金

例えば、たいして目標が立っていないのに課金をするのはよくないと思う。単純に散財するだけだし、仮に散財して強力なキャラクターを手に入れたところで、その幸福感というのは一瞬に過ぎないと思う。なので、何か目標を持った状態で課金をするべきだと私は考える。例えば、ピックアップのキャラクターを手に入れるまで、とか天井一回分引くまで、など。

撤退ラインの甘い課金

課金を始めた後に諦めるのはなかなか苦渋の決断となるかもしれない。しかし撤退ラインを設けなければ、極端な散財を起こしかねない。要するに、「ここまでガチャを引いても、欲しいものが手に入らなければ諦めよう」という心である。これは、原神やその他ソシャゲに限った話ではないと思うが、欲しいものが手に入らずにダラダラと散財してしまう可能性もないわけではない。この段階に入ってしまうと、冷静な判断は下せないため、それよりも前に撤退ラインを決めておけば良いだろう。

計画性の無い課金

これは、目標の無い課金に少し似ているが、目標を達成するために必要な金額がどれくらいなのかを検討した方が良いということである。原神の場合、必要な原石の数と手に入る予定の原石の数の差を取れば、いくら課金すれば欲しいキャラが手に入るかがわかる。めっちゃ簡単ですね。この「手に入る予定の原石の数」というのは、ガチャ期間終了までに手に入る原石のことであり、空月の祝福とか天空紀行を購入する場合は、それらも考慮するといいでしょう。

生活に支障をきたす課金

これは、たまにニュースや記事に取り上げられるタイプの課金であるが、これは良くない事態であるのは言うまでもない。メディアでよく取り上げられているのは、相当な額を課金して日々の営みに苦しむ人間についての記事だ。この状態に至るまでつぎ込んでしまうのは良くない課金だ。

楽しめない課金

これは私の個人的な意見になってしまうが、課金してガチャを回した結果が芳しくない場合、精神的に厳しい部分があると思う。娯楽の一環としてソシャゲの課金をするとなれば、お金を費やす以上、しっかりと楽しくあるべきであろうと思うし、そっちの方が費やしたお金も報われる。なので、どのようにしてガチャ課金を楽しむかということも考えた方が良さそうである。

"良い課金"とは何か

上に示した項目が、悪い課金とするのであれば、良い課金とは何か。それは、目標を立て、撤退ラインを設け、計画的に課金し、生活に支障が出ない程度に、楽しく課金すればよいということになる。しかし、この中でも「楽しく課金する」とはどういうことなのかについて考えたい。どうすれば、楽しく課金できるのだろうか。

友人に引いてもらう

課金してガチャを引くにしても、自分が淡々と引くのではなく友人に引かせるのはどうだろうか。それも、同じゲームをプレイしている人の間でガチャをすれば、さぞ盛り上がると思う。もしかしたら、欲しいキャラクターが手に入るかもしれない、という緊張感を友人と共有できるので楽しいだろう。それに、一人で引いて落胆するよりも精神的ダメージは少ない(ような気がする)。

目標達成が約束されている

友人とガチャを引くのは良いが、結構な額を課金し、他者に引かせておいて最終的にお通夜状態になるのも良いとは言えない。なので、天井確定分まで課金して原石を確保したりすれば問題ないかもしれない。

後で後悔しない課金

結局のところこれである。ガチャ課金をした挙句、欲しいキャラクター(もしくは武器)を手に入れることができなかったときに、一番のショックを覚えるかも知れない。これを避ける解決策としては、

  • 手に入れるのに必要な原石の数を計算する
  • できるだけ少ない金額で手に入れようと思わない

正直これくらいしかないと思う。いくら安く手に入れようと考えたところで、課金対象がランダム要素である以上、いくら課金すれば手に入るのか正確な値はわからない。その上、後悔する可能性は非常に高いので、後悔しない努力をしようと思っても難しい話だ。しかし、後悔するにしろ、その度合いを軽減できればそのほうが良いと思う。これらからいえることは、ガチャ課金をする場合ある程度決心をしてから行った方がよいだろう。そっちの方が後悔をしないというよりも、ずるずると沼にはまって後味の悪い課金をする可能性は低くなると思う。

課金に対する世間の目

課金をすることに対して肯定的な姿勢を示す人はあまりいないと思う。彼らの多くは、「罪悪感を覚える」なり「ゲームごときに課金など情けない」なり「喪失感を覚える」など、ネガティブな意見を抱いているだろう。私もそう思う。少し前までは、あらゆる大人達に「ゲームをすれば馬鹿になる」、「馬鹿にゲームは創れない」と言われる始末で、大人達のゲームに対する印象は散々なものであった。しかし、今となってはゲーム主体の大々的なイベントを開催したり、テレビでも大会の生中継を実施したり、その他ゲーマー向けのサービスも発展してきており、ゲームに対する世間の目は見違える程変化し、ある程度の地位を獲得してきているように思えた。ただ、ゲームとは所詮遊びであり、そんな遊びに対して多額の金額を費やすのは、どうしても気乗りしない。ただ、よく考えてみれば、遊びにお金をかけること自体、別に変なことではないと思う。むしろ、タダで遊ぼうだなんて言語道断であろう。何をして遊ぶにも、お金がかかるのはおかしいことではない。そう考えれば、例えばスポーツを楽しむために投資したり、アニメを見るためにサブスクリプションを契約したり、好きなアーティストのライブに行くためにお金を費やすことは罪悪感を覚えるだろうか?

罪悪感を覚える理由

お金を投資する対象が、ガチャであることが問題であると考えられる。結局のところ、ガチャというのはランダム要素であり、場合によってはこちらが損することもあり得る。それを考えると、ゲーム課金に対して否定的な態度をとるのも分からなくもない。

ゲームの購入は問題ない

ゲーム課金と言っても、これはゲームを購入することとは訳が違う。ゲームの購入とは、ゲームそのものを購入することであり、ランダム要素は一切存在しない。これに関して否定的な意見を持つ者は多くないと思う。少なくとも、ガチャ課金と比較すれば肯定的な意見を持つ者が多いと思う。
これから分かることとしては、ランダム要素が介入する課金は罪悪感を覚えやすいということだろうか。

課金以外のお金の使い道

ゲームに課金することがくだらないというのであれば、普段のお金の使い道はくだらなくないのか?例えば、ふと立ち寄ったコンビニでちょっとした散財をすることはくだらない事なのか?人間は意外にも、散財という行為に対してをあまり意識をしていない場合が多い。また、意識してお金を消費している場合でも、合算すると想像よりもお金を使っているという事実に驚く事もある。私もそうだ。こう考えると、くだらない散財というのはゲーム課金に限った話ではないかもしれない。むしろこうして、消費に対してしっかりと考える場合の方が、よっぽど有意義なようにも思える。意外な場面で散財していると思うと、少し恐ろしく感じる。

理想的な課金方法

ここまで、以下について述べてきた。

  • 課金のメリット、デメリット
  • 絶対的に悪い課金
  • 課金に対する世間の目

これから考えるに、どのように課金するのが幸せになりやすいのかを考えていきたい。

課金をする前に考えること

課金する予定の金額が大きい場合や、ガチャを回すために課金する場合は、以下に示す内容について考えた後に課金をしようと思う。

無料配布分の石を貯めておく

原神で、原石160個貯まった途端にガチャを引く方がいらっしゃると思いますが、これは得策じゃないかもしれない。次の復刻ガチャ、新キャラガチャなどに備えておけば、そもそも多額の課金をしなくとも天井まで引くことが可能だ。しかも、欲しいと思ったキャラクターがガチャに並んだ時に、撤退ラインを決めたり、課金を始めた後に結果が芳しくなくて諦めるという苦渋の決断をしなくなる可能性が高い。そのため、特に理由がない限りはガチャを引かないほうが良いだろう。

いくら課金すればで満足した結果になるか

一番良くないのは、理想的な結果を得られるまでだらだらと課金を続けてしまうことだ。仮に、理想的な結果を得られたところで後味が悪すぎるし、大きな損失を出してしまう。なので、ある程度金額を絞って、その金額を超えない範囲で課金を楽しむようにすれば良さそうである。

手に入ればこっちのもの

ガチャという楽しみは一瞬だが、手に入れたキャラクターはずっと使うことができる。人によっては、ガチャによる楽しみは一瞬だけだと考える人もいるかもしれないが、ガチャで手に入ったキャラクターはずっと楽しんで扱うことができる。よく考えてみれば、数万という金額が一瞬にしてガチャ石に変換されてしまうと考えると、一瞬にして大金を溶かしたようにも思えるが、大事なのはガチャを引く楽しみではなく、ガチャの結果だ。なので、ガチャのために奮発しても、今後もそのゲームで遊ぶつもりであれば、問題はないと思う。

無益な訳ではない

宝くじやパチンコと言ったギャンブルとは違い、全く利益が出ないということはない。いくらソシャゲのガチャがランダム要素を内包しているとはいっても、何も手に入らないということはない。少なくとも、原神の場合は星5の天井が用意されているし、星4の場合は10回引けば確定で手に入る仕様である。しかも、ピックアップされているキャラが手に入らなかった場合、次にキャラが手に入るときには確定でピックアップキャラが手に入るようになっている。かなり親切だと思う。なので、ガチャ課金が馬鹿らしいとは言っても、そこまで憂慮するほどのことではないと思うし、本当に欲しいと思ったキャラクターだったり、他の友人とゲームを楽しみたい出会ったりする場合は、むしろ引いた方が幸せになれると思う。

中毒性が高い事は間違いない

「次こそ出るだろう」とか「次は何が出るだろう」といった不確定な未来に対する期待からくる中毒性や、「他者より優位に立ちたい」とか「ゲームの流行に乗っかっていたい」といった純粋な"強さ"に対する欲求が、中毒性の原因なのかもしれないと考えた。これらを理由に、課金によって沼にはまる人もいるだろう。いくら課金しようとも、この中毒性が高さを忘れてはならない。

結論

というわけで、ここまで可能な限り多角的に考察をしてきたが結局のところ正解を出すのは難しい。しかし、課金するかどうかの判断は、個人の見方次第で大きいく変わると思う。ガチャ課金を無駄な浪費だとみなせば、課金に対して否定的な意見を持つだろう。しかし、ゲームも遊びであり趣味である。時には、現実世界でもコミュニケーションの起点ともなりうる。それを、「課金はよくない」の一点張りで片づけるのは良くない。なので、少なくとも悪い結果を生むような課金をしないようにすることを絶対に意識したい。そのほかに、可能な限り少ない金額で望んだ結果を手に入れようと思わない事も重要だ。

おまけ

最後に、課金したいという感情があっても、どうしても課金をできない状況(もしくは、課金する事が許せないプライド)の二つの感情が衝突したときに、どのように対処すればよいかを考えたい。

課金欲を鎮める方法

高ぶる課金欲を鎮める方をいくつか考えたので紹介する。

他のゲームをする

課金をしたいと感じる原因は、そのゲームに夢中になっているからであろう。夢中になっていないゲームに対して課金することは、まずないと思っている。例えば、廃課金者を対象にした記事を目にしたとき、「○○〇〇というゲームにこれまでに年間数十万の課金をしています」などと表現されることはよくあることだと思う。それを見た読者は「よくも○○○○にそこまで本気になれるな」といった感情を抱くのではないだろうか?そう思ってしまう原因としては、読者の多くがそのゲームをプレイしているとは限らないからだと考えている。なので、一つのゲームに集中するのではなく、いくつかのゲームを分散してプレイする事が、異常な課金欲をそそらなくさせる策なのではないかと考えている。

他者と会話する

他者と会話することは、人間関係を豊かにするだけでなく、自分の知らない世界を知ることにもつながる。なので、ゲームのことばかりを考えるのではなく、ゲーム以外の要素に注目しているうちに、課金に対する興味が冷めてくるかもしれない。しかも、相手と会話することによるアウトプットで、自己開示による信頼性の向上にもつながる。

課金の理由をアウトプット

まさに今の私である。これは、悩み全体に言えることだろう。どんなことであ、紙でもブログでも他者でも、自分の悩みを吐き出してみることで客観的にその悩みを見ることができる。最近では、悩みの解決手段として有名になりつつある方法だが、これは本当に効果があると思うので、どんな悩みであっても試してみたい。これのいいところは、紙に書いてアウトプットすることができるので、人に話すのが恥ずかしい悩みでも問題ないという事だ。

新たなことを始める

これは、他のゲームをするという項目に少し似た部分があるが、何か新しい事を始めるというのも手段の一つだ。例えば、ブログを書いてみるとか、ゲームをするのではなく創ってみるとか、普段は読まない本を読んでみるといった具合である。できるだけ、今自分が夢中になっているゲーム以外の部分に興味を分散させるように努力すれば、うまくいくかもしれない。

というわけで、以上で終わります。

【Java】Android Studio でバーコードリーダーを実装したい

この記事は、群馬高専AdventCalendar14日目の記事です。

Android Studioを使って、バーコードリーダを作成していきたいと思います。
また、作成するにあたって押さえておきたい点も以下に列挙します。

  1. カメラ画面のカスタマイズ
  2. アクセス許可を尋ねるダイアログの表示
  3. JANコードのみを認識するようにする
  4. 連続的な読み取りを可能にする

この三つです。それぞれ、順番に説明いたします。

アクセス許可のダイアログ表示

よくあるのは、「カメラのアクセスを許可しますか」とか、「マイクのアクセスを許可しますか」とか、「位置情報の利用を許可しますか」といったやつです。アプリが、スマホに実装されているUIを利用することを許可するか否かを聞いてくるダイアログはよく見かけると思います。今回はせっかくなので「カメラのアクセスを許可しますか」的なダイアログを表示させて、許可をタップしたらアプリの開始、許可をしなかったらアプリが終了する、という簡単なプログラムを組んでみたいと思います。

JANコードのみを認識させる

バーコードと一言にいっても、QRコードなどの二次元コードから、JANコードITFコードなどの様々なバーコードがありますが、これらを自動的に認識して、JANコードであればその内容を取得するというシステムにするということです。

連続的に読み取りができる

これは要するに、一回目のバーコード読み取りの後に、すぐに二回目のバーコード読み取りを可能にするということです。三回目以降も同じです。ただし、同じバーコードを連続して読み取ってもしょうがないので、最後に読み取ったバーコードと違う番号のバーコードである場合に限って、その後の処理をさせるようなプログラムに仕上げます。今回は、バーコードを読み取った場合に、トーストを表示させるようにしていきます。

カメラ画面をカスタマイズする

バーコードの読み取り画面には、当然ですがカメラを使って、その映像を映している部分があります。デフォルトの設定だと、画面いっぱいにカメラ画面が広がってしまうので、同じカメラ画面にも、カメラ映像部分を自由に配置したり、テキストを表示させたり、ボタンの配置をしたりなど、UIを加えていこうと思います。

アプリの作成

使用するライブラリ

今回の開発に使用するライブラリは、ZXingというライブラリです。これで、「ゼブラクロッシング」と読むそうです。
まずは、このライブラリをインストールするところから始まります。以下に、ZXingのライブラリの元を置いておくので、興味のある方はご覧になってください。
github.com
それでは、実際にインストールしていきます。以下に示したコードをbuild.gradle(:app)に記入します。dependenciesはすでに書かれていると思うので、中括弧内を記入するようにしてください。

dependencies {
    implementation 'com.journeyapps:zxing-android-embedded:3.0.2@aar'
    implementation 'com.google.zxing:core:3.2.0'
}

記入したら、「Gradleファイルが変更されましたよ」的なメッセージとともに、右上付近に「Sync now」が出てくると思うので、それをクリックしてください。
これでインストールは完了です。簡単にできてしまいますね。続いて、UIの配置をしていきます。

レイアウトの作成

今回は、起動したらバーコード読み取り画面が開き、バーコード読み取ることでテキストボックスに読み取った内容を表示するような設計にしたいと思います。以下が、そのソースコードになります。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.journeyapps.barcodescanner.CompoundBarcodeView
        android:id="@+id/barcodeView"
        android:layout_width="340dp"
        android:layout_height="160dp"
        android:layout_marginTop="40dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
    </com.journeyapps.barcodescanner.CompoundBarcodeView>

    <TextView
        android:id="@+id/getNumber"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="60dp"
        android:text="Hello World!"
        android:textSize="30sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/barcodeView" />

</androidx.constraintlayout.widget.ConstraintLayout>

このソースコードを入力した際の、ブループリントが以下のスクショです。

カメラ画面のブループリント

見てもらうと分かる通り、バーコードリーダのカメラ画面は、com.journeyapps.barcodescanner.CompoundBarcodeView で配置できるようになります。あとは、layout_width や layout_height などで、サイズを決めたり、android:id などでidを付けたりします。この辺りは、他のオブジェクトと変わりありません。
続いて、Javaを用いてプログラムを書いていきます。

Javaプログラムの作成

とりあえず、ソースコードを張り付けておきます。

package com.samplegame.barcode_sample;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;

import android.Manifest;
import android.content.pm.PackageManager;
import android.graphics.Camera;
import android.os.Bundle;
import android.widget.TextView;
import android.widget.Toast;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.ResultPoint;
import com.journeyapps.barcodescanner.BarcodeCallback;
import com.journeyapps.barcodescanner.BarcodeResult;
import com.journeyapps.barcodescanner.CompoundBarcodeView;
import com.journeyapps.barcodescanner.camera.CameraSettings;

import java.util.List;

public class MainActivity extends AppCompatActivity {
    CompoundBarcodeView barcodeView;
    private String lastResult;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if(ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED){
            String[] permissions = {Manifest.permission.CAMERA};
            ActivityCompat.requestPermissions(MainActivity.this, permissions, 100);
            return;
        }
        CameraSetting();
        readBarcode();
    }

    private void CameraSetting(){
        barcodeView = findViewById(R.id.barcodeView);
        CameraSettings settings = barcodeView.getBarcodeView().getCameraSettings();
        barcodeView.getBarcodeView().setCameraSettings(settings);
        barcodeView.setStatusText("バーコードが読めます");
        barcodeView.resume();
        readBarcode();
    }
    private void readBarcode(){
        barcodeView.decodeContinuous(new BarcodeCallback() {
            final TextView getNumber = findViewById(R.id.getNumber);
            @Override
            public void barcodeResult(BarcodeResult result) {
                //このif文で、不必要な連続読みを防ぐ
                if (result.getText() == null || result.getText().equals(lastResult)){
                    return;
                }
                //このif文で、読み取られたバーコードがJANコードかどうか判定する
                if (result.getBarcodeFormat() != BarcodeFormat.EAN_13){
                    return;
                }
                lastResult = result.getText();
                Toast.makeText(MainActivity.this, "読み取りました", Toast.LENGTH_LONG).show();
                getNumber.setText(result.getText());
            }

            @Override
            public void possibleResultPoints(List<ResultPoint> resultPoints) {

            }
        });
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == 100 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
            if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED){
                return;
            }
        }
        CameraSetting();
    }
}

ネストif文があったり、一行が長い部分が存在したりと、綺麗なコードとは言えませんが、ざっとこんな感じです。

カメラ画面のカスタマイズ

よくある方法としては、IntentIntegratorクラスのinitiateScanメソッドを使用する方法があります。ところが、この方法では画面いっぱいにカメラが広がったアクティビティが立ち上がってしまいます。これでは、カメラ画面のカスタマイズという最初に掲げた目標からそれてしまうので、今回はCameraSettingクラスを使用する方法で実装しました。

アクセス許可を尋ねるダイアログの表示

まず、アプリの起動時にカメラの使用が許可されているかをチェックします。その判定を行っているのが、以下に示したコードです。

        if(ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED){
            String[] permissions = {Manifest.permission.CAMERA};
            ActivityCompat.requestPermissions(MainActivity.this, permissions, 100);
            return;
        }

これをonCreate内に記述しています。もし、カメラの使用許可が下りていない場合は、ダイアログを表示します。そのダイアログの表示を担っているのが、以下のコードです。

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == 100 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
            if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED){
                return;
            }
        }
        CameraSetting();
    }

onRequestPermissionResultメソッドを継承しています。ここでもう一度、許可が下りているかどうか判定を行っています。こうして、ユーザーからカメラの使用を許可された場合に、CameraSettingメソッドを実行しています。もし拒否された場合は、アプリは起動したままですが、カメラには何も映りません。

アクセス許可のダイアログ
JANコードのみを認識するようにする

バーコードがJANコードなのかどうかを判定するには、BarcodeFormat という列挙クラスを使うと都合が良さそうだったので、これを使用しました。以下のスクショは、裏表紙のISBNを読み取った時のものです。

実際に読み取った様子

試しに、当ブログのURLをQRコードに変換したものを撮影してみましたが、反応していないのがわかります。

QRコードには反応しない
連続的に読み取りができる

最後に読み取ったバーコードと違う番号を認識した場合に、テキストビューに新たに読み取った方の番号を表示します。これは、decodeContinuousメソッドで実装できます。仮に、一回のみ読み取りたい場合は、decodeSingleメソッドを用いるといいでしょう。

まとめ

まとめると、

  • カメラ画面はCameraSettingクラスを用いることでカスタマイズ可能
  • アクセス許可ダイアログは、標準ライブラリであるonRequestPermissionResultメソッドを実装すれば割と簡単に作れる
  • 認識したバーコードは、BarcodeFormaから取得できる
  • 連続的な読み取りはdecodeContinuousメソッドを用いる

といった感じです。
出来るだけわかりやすくなるように解説したつもりでしたが、私自身、能力不足を感じる部分もありますので、説明が下手になっている部分もあると思いますが、参考になれば幸いです。

それではまた。

【Java】ISBNから書籍情報を取得するAndroidアプリを作成しました

この記事は、群馬高専Advent Calendaの7日目の記事です。
adventar.org


非同期通信処理を用いて、HTTP通信処理を実装していきます。また、今回ではHTTP通信をする際に、Google Books APIを使用して実際にインターネット接続を経由して、書籍の情報を入手してみたいと思います。

今回作るもの

インターネット経由で情報を取得するという、考えるだけでは簡単なアプリを作りますが、一応最終的な完成品を以下に示しておきたいと思います。

f:id:PsyTouSan:20211202134031j:plain:w250
作るもの(ISBNからタイトルと著者を表示する)
Google Books API とは

Google Books APIは、特定のURLに書籍の裏表紙に記されているISBNをクエリパラメータとしてアクセスすることで、そのISBNの情報から、書籍のタイトル、著者、出版社、説明、などの情報を含んだJSONデータを返してくれるAPIです。特別な申請などはしなくても問題なく、誰でも無料で扱うことができるので、今回はこのAPIを活用したいと思います。試しに、以下のURLを踏んでみてください。

https://www.googleapis.com/books/v1/volumes?q=9784065163870
JSONデータが返ってくると思います。

非同期通信処理の準備

そもそも非同期通信処理とは

まず非同期通信処理について簡単に説明します。理解しているという人は飛ばしてもらっても構いません。
HTTP接続や、ダウンロードなどの時間のかかる処理を、メインで処理を実行するスレッド(メインスレッド)に任せてしまうと、それ以降の処理が滞ってしまうので、これは避けるべきことです。そこで、時間のかかる処理を、メインスレッドではない新たなスレッド(サブスレッド)に実行させることで、時間のかかる処理をしている間に、別の処理を実行できるので、効率的かつ安全であるということです。

f:id:PsyTouSan:20211120210929p:plain
非同期通信処理の簡略図

処理の記述

ここからが本題です。Androidアプリ内で、非同期通信をしていきます。とはいっても、いきなりソースコードを示すと分かりにくくなりそうなので、手順だけ先に示しておきます。

  1. looperとhandlerを宣言する
  2. 時間のかかる処理をするのクラスを作成し、インスタンス化する
  3. サブスレッドを用意する
  4. サブスレッドで処理を実行する

と言った感じです。結構長いコードを書くことになります。

マニフェストの設定

このアプリケーションは、デバイスにインターネットの接続を必要とします。なので、インターネット接続をさせるために、AndroidManifestを少しいじります。
以下のコードを参考に、一行だけ追加してください。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android=...省略

    //以下の一行を記述
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        ...省略
</manifest>

レイアウトの作成

レイアウトはこんな感じです。

f:id:PsyTouSan:20211127184733p:plain:w450
レイアウトとブループリント

ソースコードは以下の通りです。今回は、簡単なアプリを作るだけですし、何かと面倒だったのでstrings.xmlファイルは使用しませんでした。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/editIsbn"
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="120dp"
        android:inputType="number"
        android:hint="ISBNを入力してください"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/search_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:hint="検索"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/editIsbn" />

    <TextView
        android:id="@+id/resultTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:textSize="25sp"
        android:text="Hello World!"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/search_button" />

    <TextView
        android:id="@+id/resultAuthor"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="25sp"
        android:text="Hello Android!"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/resultTitle" />

</androidx.constraintlayout.widget.ConstraintLayout>

簡単に説明しますと、テキストボックスにISBNが入力されて「検索」ボタンが押下されると、その下にあるテキストビューにその書籍のタイトルと著者が表示されるといった仕組みです。
その他、EditText内のinputTypeで、表示されるキーボードと、そのテキストボックスに入力可能な文字が指定できます。今回の場合は、numberを指定しているので、テキストボックスをタップしたときに表示されるキーボードは、一般的な日本語配列キーボードではなく、数字や記号が打てるキーボードが立ち上がり、さらに数字以外の文字の入力が不可能になります。そのほかにも、ここのnumberのところを「textPassword」と指定すれば、入力時に文字が見えないように「・」で隠してくれたり、「phone」とすれば、電話番号を入力しやすいテンキーが立ち上がるようになったりします。

Javaプログラムの作成

とりあえずコメント付きのソースコードを張り付けておきます。ソースコードの後に、いろいろな説明を書いてありますが、onCreateメソッドの役割だったり、クリック処理の記述方法など、非同期に関する以外の処理の説明は省かせていただきます。

package com.example.multithread_sample;

import androidx.annotation.UiThread;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.os.HandlerCompat;

import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MainActivity extends AppCompatActivity {
    private static final String GoogleBooksAPI = "https://www.googleapis.com/books/v1/volumes?q=";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        EditText isbn = findViewById(R.id.editIsbn);
        Button search = findViewById(R.id.search_button);
        search.setOnClickListener(v -> {
            try {
                if (isbn.getText().toString().length() != 13){
                    Toast.makeText(MainActivity.this, "適切な値を入れてください", Toast.LENGTH_LONG).show();
                } else{
                    BookSearch(isbn.getText().toString());
                }
            } catch (NullPointerException e){
                Log.d("Null Exception","EditText is null");
                Toast.makeText(MainActivity.this, "適切な値を入れてください", Toast.LENGTH_LONG).show();
            }

        });
    }

    //メインスレッドでの動作を保証するアノテーション
    @UiThread
    private void BookSearch(final String Isbn) {
        String urlFull = GoogleBooksAPI + Isbn;
        Looper mainLooper = Looper.getMainLooper();
        Handler handler = HandlerCompat.createAsync(mainLooper);
        BookDataReceiver receiver = new BookDataReceiver(handler, urlFull);
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        executorService.submit(receiver);
        executorService.shutdown();

    }

    private class BookDataReceiver implements Runnable {
        private final Handler _handler;
        private final String _urlFull;

        private BookDataReceiver(Handler handler, String urlFull) {
            _handler = handler;
            _urlFull = urlFull;
        }

        //サブスレッドでの動作することを保証するアノテーション
        @WorkerThread
        @Override
        public void run() {
            //ここに非同期処理を記述している
            //HTTP通信するオブジェクトの作成
            HttpURLConnection connection = null;
            //連続的なデータを格納するオブジェクト
            InputStream stream = null;
            //取得したJSONデータを格納する
            String result = "";
            try {
                URL url = new URL(_urlFull);
                //接続用のオブジェクトを生成する
                connection = (HttpURLConnection) url.openConnection();
                //接続のタイムアウトを指定する
                connection.setConnectTimeout(1000);
                //値を取得するタイムアウトを時間を指定
                connection.setReadTimeout(1000);
                //値を送信する場合はPOST、取得する場合はGETを指定する
                connection.setRequestMethod("GET");
                //接続を行う
                connection.connect();
                //streamに取得した値を格納する
                stream = connection.getInputStream();
                //129行目に示したinputStream関数を用いてString型のデータに変換
                result = inputStream(stream);
            } catch (MalformedURLException e) {
                Toast.makeText(MainActivity.this,"URLの変換に失敗", Toast.LENGTH_LONG).show();
            } catch (SocketTimeoutException e) {
                Toast.makeText(MainActivity.this, "接続タイムアウト", Toast.LENGTH_LONG).show();
            } catch (IOException e) {
                Toast.makeText(MainActivity.this, "通信できませんでした",Toast.LENGTH_LONG).show();
            } finally {
                if (connection != null){
                    //切断する
                    connection.disconnect();
                }
                if (stream != null){
                    try {
                        stream.close();
                    } catch (IOException e){
                        Toast.makeText(MainActivity.this, "解放失敗", Toast.LENGTH_LONG).show();
                    }
                }
            }
            JsonDecoder decoder = new JsonDecoder(result);
            _handler.post(decoder);
        }

        private String inputStream(InputStream stream) throws IOException {
            BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
            StringBuffer result = new StringBuffer();
            char[] buffer = new char[1024];
            int line;
            while (0 <= (line = reader.read(buffer))) {
                result.append(buffer, 0, line);
            }
            return result.toString();
        }
    }

    private class JsonDecoder implements Runnable {
        private final String _result;

        private JsonDecoder(String result) {
            _result = result;
        }

        @UiThread
        @Override
        public void run() {
            String title = "";
            String author = "";
            try {
                //JSONオブジェクトを生成
                JSONObject jsonObject = new JSONObject(_result);
                //取得したJSONデータから「items」と名づけられた配列を取得する
                JSONArray items = jsonObject.getJSONArray("items");
                //取得した「items」配列のゼロ番目の要素を取得
                JSONObject itemValue = items.getJSONObject(0);
                //さらに、itemValue内の「volumeInfo」と名付けられた配列を取得
                JSONObject info = itemValue.getJSONObject("volumeInfo");
                //タイトルを取得
                title = info.getString("title");
                //「authors」と名付けられた配列を取得
                JSONArray authors = info.getJSONArray("authors");
                //著者を取得
                author = authors.getString(0);
            } catch (JSONException e) {
                e.printStackTrace();
            }
            TextView resultTitle = findViewById(R.id.resultTitle);
            TextView resultAuthor = findViewById(R.id.resultAuthor);
            resultTitle.setText("タイトル:" + title);
            resultAuthor.setText("著者:" + author);
        }
    }
}

Looper

Looperは、自身の属したスレッドのMessageQueueに格納された処理を、先入先出方式で実行し、これを無限にループします。MessageQueueとは、処理を順番に実行する箱のようなものです。メインスレッドは、このMessageQueueというものを最初から保持しています。

Handler

Handlerを用いることで、別スレッドで実行させたい処理をMessageQueueに追加できます。この別スレッドというのは、Looperを持っているスレッドのことです。これにより、HandlerからLooperを通じて、スレッド間の通信を可能にします。

CreateAsync

これだけの説明では、HandlerとLooperに関する説明としては不十分です。しかし、62行目に示したCreateAsyncメソッドを用いることで、この複雑な仕組みを簡略化して実装することができます。62行目では、HandlerをCreateAsyncメソッドにメインメソッドのLooperを渡すことで生成しています。こうすることで、「メインメソッドのLooperの持つQueueに、処理を追加できるHandler」が完成するわけです。これにより、非同期で実行させたい処理をさせることもできれば、後述する「post」を用いた、メインスレッドに戻って処理を実行させる(厳密には、getMainLooperを実行したスレッドに戻って実行させる)ということも可能になります。
LooperとHandlerに関する詳しい解説は、以下のリンクを参考にさせていただきましたので、掲載させていただきます。

LooperとHandlerに関して参考になったサイト
academy.realm.io
developer.android.com

サブスレッドに実行させる処理

さて、次はGoogle Books APIを用いた書籍情報の取得をを行うクラスの作成をしていきたいと思います。ここで扱う内容は以下の通りです。

  1. HTTP接続を行う
  2. 返されたJSONデータを文字列(バッファ)として取得する
  3. バッファからお望みのデータを抽出する
  4. 画面に表示する

上に示した番号の、2と3の部分で書籍を取得しています。まず2の部分で、帰ってきた文字列データをバッファとして格納します。そして、3の部分でそのバッファ内にある文字列から必要な情報を抽出するといった感じです。

HTTP接続の開始

HTTP接続はサブスレッドで実行するため、Runnableインターフェースの中にある、runメソッドの中に記述しています。Runnableインターフェースは、runメソッドを必ず表記する必要があります。ちなみに、runメソッドは、引数も戻り値もありません。このrunメソッドは、Runnableインターフェースを実装したスレッドが開始されたときに、自動的に呼び出されます。したがって、ボタンを押下したときにサブスレッドを開始することで、runメソッド内の処理が実行されることになります。HTTP接続を行う処理の記述は、例外処理の中に入れています。

JSONデータをバッファとして取得

HTTP経由でJSONデータを取得します。129行目のメソッドでJSONデータをString型に変換しています。以下は、そのメソッドの抜粋です。

 private String inputStream(InputStream stream) throws IOException {
            BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
            StringBuffer result = new StringBuffer();
            char[] buffer = new char[1024];
            int line;
            while (0 <= (line = reader.read(buffer))) {
                result.append(buffer, 0, line);
            }
            return result.toString();
        }

望みのデータを取得する

String型に変換したJSON生データから、タイトルと著者を取得します。以下は、JSON生データから、必要な値を取得するメソッドの抜粋です。

 private class JsonDecoder implements Runnable {
        private final String _result;

        private JsonDecoder(String result) {
            _result = result;
        }

        @UiThread
        @Override
        public void run() {
            String title = "";
            String author = "";
            try {
                //JSONオブジェクトを生成
                JSONObject jsonObject = new JSONObject(_result);
                //取得したJSONデータから「items」と名づけられた配列を取得する
                JSONArray items = jsonObject.getJSONArray("items");
                //取得した「items」配列のゼロ番目の要素を取得
                JSONObject itemValue = items.getJSONObject(0);
                //さらに、itemValue内の「volumeInfo」と名付けられた配列を取得
                JSONObject info = itemValue.getJSONObject("volumeInfo");
                //タイトルを取得
                title = info.getString("title");
                //「authors」と名付けられた配列を取得
                JSONArray authors = info.getJSONArray("authors");
                //著者を取得
                author = authors.getString(0);
            } catch (JSONException e) {
                e.printStackTrace();
            }
            TextView resultTitle = findViewById(R.id.resultTitle);
            TextView resultAuthor = findViewById(R.id.resultAuthor);
            resultTitle.setText("タイトル:" + title);
            resultAuthor.setText("著者:" + author);
        }

このメソッドは、メインスレッドで実行しています。なので、アノテーションでも@UIThreadを指定しています。非同期通信を行うときと同様、Runnableインターフェースを実装して、その中のrunメソッド内に処理を記述しています。サブスレッドからメインスレッドに処理を戻す場合でも、非同期処理を行うときと同じように、runメソッド内に処理を記述します。

JSON生データからデータを抽出する

このrunメソッド内の処理を簡単に説明します。まず、前段階において、テキストボックスに入力されたISBNから、Google Books API を用いて、HTTP接続によりJSONの生データをString型で受け取ることを行いました。今度は、そのString型データから、書籍のタイトルと著者の情報を引き出します。とりあえず、実際のJSONデータを見てみます。以下のリンクにアクセスすると、JSONデータを取得できるのがわかると思います。

https://www.googleapis.com/books/v1/volumes?q=9784065163870

このデータに基づいて、JSONオブジェクトを取得したり、JSON配列を取得したり、JSON配列内の要素を抽出したりしています。先ほどの非同期処理では、このデータを取得していたということになります。
(余談ですが、この書籍はFlutter開発に関する書籍です。AndroidStudioで開発でき、Androidだけでなく、iOSアプリやWebアプリも作れるので、近年注目が集まっています。そのうち、これに関する記事も出していきたいと思っています…)
基本的には、ソースコードのコメント通りではありますが、簡潔にまとめると

  • JSONObjectクラスをnewし、そこにStringを渡して、JSONオブジェクトを生成
  • JSONArrayメソッドに、Stringを渡して、その名前の配列を取得
  • JSONObjectクラスののgetStringメソッドへStringを渡して、その名前の要素を取得

という感じになり、基本的にこれらでデータを抽出できます。

画面に表示する

レイアウト画面のタイトル、著者欄に文字を表示させます。上に示したソースコードの31から34行目に記述された内容が、文字の表示を担っています。
非同期処理の記述に比べたら遥かに簡単ですね。

動作確認

さて、ここまで記述が終えたので動作させてみます。

f:id:PsyTouSan:20211202133759j:plain:w250
起動直後の画面の様子

このテキストボックスに適当にISBNを入力してみてください。入力が終えたら、検索ボタンをタップしてください。HelloWorldとHelloAndroidと書かれている文章がタイトルと著者に代わるはずです。

f:id:PsyTouSan:20211202134031j:plain:w250
検索結果

まとめ

今回の記事の内容をまとめると

  • HTTP接続は基本的に非同期処理で行う
  • Androidの非同期処理には、LooperとHandlerが必要
  • スレッドの開始時にRunnable内のrunメソッドが実行される
  • runメソッド内に、非同期処理、もしくはサブからメインスレッドに戻ってきたときの処理を記述する
  • AndroidでもJSONデータを扱うことができる

といった感じになります。

非同期処理を用いて、ISBNから書籍データを取得することに成功しました。今回は、Google Books API を利用した方法でしたが、ほかにも、天気の情報を取得するとか、ニュースを取得するなどの他社のAPIサービスを利用すれば、応用の幅が一気に広がるので、ぜひ活用していきたいですね。

以上です。