こんにちは。
はじめに
会社の同僚が、「関東ITソフトウェア健康保険組合(略称ITS)の保養施設1の抽選申込期間って、ついつい見逃してしまう」と悔しがりだしました。たとえば、8月に保養施設行きたい場合、5/29~6/5に申し込まないといけないんですよ。油断すると申込受付が過ぎちゃいがちです。
ざっくりITS保養施設の抽選申し込み方法を説明すると、
- だいたい2か月前が受付期間である(例:8月分の受付期間は5/29~6/5)。
- 申し込み方法は「郵送・FAX」と「WEB」の2通りがある。
- 特に「3月分」の抽選は12月中なので気を付けて(1月中ではない、ということ)。
- 年度単位で一斉に発表される。
そこでAndroidで、Activityなしのウィジェットだけのアプリを作ろうと思い立ちました。こういうのです。
ということで、作るウィジェットは「なるべく小さく、情報も必要最低限にとどめる」を心がけて、「年度」と「〇月分」と「Web受付期間」を記載することにします。そしてブラウザのアイコンをクリックしたら、ブラウザを起動させてITSのサイトに誘いたいと思います。
この記事で言いたいポイント
さて、こんなウィジェットを作ってみた際に、色々とハマりました。
- AppWidgetProviderクラスのサブクラスを作ることになる。
- AppWidgetProviderは、BroadcastReceiverのサブクラスである。
- だけど、AppWidgetProviderは、BroadcastReceiverっぽくありません。Viewを操作できるし、ライフサイクルあるし。
- メンバーはstatic付けておくことになりがちです。なぜなら、updateAppWidgetというメソッドがstaticだからです。
- で、そのupdateAppWidgetというメソッドとは何?
- そのContextは、「ActivityContext」なのか?「ApplicationContext」なのか?で迷います。
- ウィジェットでもネット通信はできる。ですが、当然、非UIスレッドでネット通信します。
- クリックイベントは、自分自身にブロードキャストだ!
私の試行錯誤感を楽しみながら、この記事を読み進めていただけると幸いです。
Android Studioのウィザード手順
開発環境は以下の通りです。Javaでいきます。
Android Studio 3.0.1
Build #AI-171.4443003, built on November 10, 2017
JRE: 1.8.0_152-release-915-b01 amd64
JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
Windows 10 10.0
まず「Start a new Android Studio project」を選びます。
Activityなしのウィジェットだけのアプリを作る ので、「Add No Activity」を選びます。
プロジェクトができたら、「app」を右クリックして、[New]→[Widget]→[App Widget]です。
タテ×ヨコなど決めて、Finishです。
お膳立てされるアプリのソース
ウィジェットクラス
ウィジェットは、AppWidgetProviderのサブクラスとして作ります。AppWidgetProviderは、BroadcastReceiverのサブクラスであるということがポイントです。
package jp.co.casareal.mywidget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.widget.RemoteViews;
/**
* Implementation of App Widget functionality.
*/
public class NewAppWidget extends AppWidgetProvider {
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
CharSequence widgetText = context.getString(R.string.appwidget_text);
// Construct the RemoteViews object
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.new_app_widget);
views.setTextViewText(R.id.appwidget_text, widgetText);
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views);
}
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId);
}
}
@Override
public void onEnabled(Context context) {
// Enter relevant functionality for when the first widget is created
}
@Override
public void onDisabled(Context context) {
// Enter relevant functionality for when the last widget is disabled
}
}
ここで注目していただきたいのは、ライフサイクルイベントメソッドと、特にupdateAppWidget
というメソッドの存在についです。ライフサイクルイベントメソッドに関しては後述します。
updateAppWidgetというメソッド
お膳立てウィジェットのソースにupdateAppWidgetというメソッドがしれっと書かれてあります。どうやら以下のようにコーディングするのがお作法のようです。
- ライフサイクルメソッドonUpdateは、3~4行の定型的なコードにしておいて、
- updateAppWidgetが実質的な初期化処理のためのメソッドとしてコーディングしておく。
onUpdateメソッドの中で、for-each文があり、updateAppWidgetメソッドを呼び出しているところまでお膳立てされています。その親心としては、「初期化処理は兎角コードが長くなりがちだから、別立てのメソッドupdateAppWidget
の中でお書きなさい」ということでしょうか。
なんだったら、このupdateAppWidgetメソッドは、private修飾子をつけてもいいぐらいです。
ということで、privateなフィールドやメソッドはすべてstaticを付けておく必要が”あるある”だということがポイントです。
マニフェスト
AppWidgetProviderは、BroadcastReceiverのサブクラスですので、マニフェストに登録してあります。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="jp.co.casareal.mywidget">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<receiver android:name=".NewAppWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/new_app_widget_info" />
</receiver>
</application>
</manifest>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
や、<meta-data>
は消さないでくださいね。
/res/layout/の中にレイアウトリソース
そりゃウィジェットだって画面(UI)ですしね、レイアウトは設定しないと。
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#09C"
android:padding="@dimen/widget_margin">
<TextView
android:id="@+id/appwidget_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:layout_margin="8dp"
android:background="#09C"
android:contentDescription="@string/appwidget_text"
android:text="@string/appwidget_text"
android:textColor="#ffffff"
android:textSize="24sp"
android:textStyle="bold|italic" />
</RelativeLayout>
ウィジェットで使用可能なViewやViewGroupには限りがあります。ViewならTextView、ImageView、Buttonなど。ViewGroupにいたってはFrameLayout、LinearLayout、RelativeLayout、そしてGridLayoutの4つだけ!公式サイトの「Creating the App Widget Layout」をご覧ください。
/res/xml/の中にAppWidgetProviderInfo Metadataファイル
このファイルこそが、ウィジェット特有のファイルです。そういえばマニフェストにもandroid:resource="@xml/new_app_widget_info"
と書いてありましたしね。
作るウィジェットの設定を定義します。
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/new_app_widget"
android:initialLayout="@layout/new_app_widget"
android:minHeight="40dp"
android:minWidth="110dp"
android:previewImage="@drawable/example_appwidget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"></appwidget-provider>
各属性については詳しくは公式サイトの「Adding the AppWidgetProviderInfo Metadata」をご覧ください。
例えば、1マスが40dpとするのが通例のようです。
サンプル画像
/res/drawable-nodpi/の中に、こんな画像がありました。
お膳立てされたやつで、とにかく実行してみる
*アプリのようで、アプリではない。それがウィジェットだ。*ということで、instant runでは実行できません。
プルダウンから「Edit Configurations...」をクリックしてください。
Activity一切なしのウィジェットだけのアプリなので、「Launch:」の項目プロダウンから、「Nothing」を選んでください。
これで実行すると、インストールされます。でも、ウィジェットなので、自力でウィジェットランチャーから、ドラッグ&ドロップして、ホーム画面に落としてください。
なるほど、サンプル画像は、ここで登場するのか。
「受付期間」の要件を考えてみる
従来の申し込み方法は、WebブラウザでITSのページを開いて目視することから始まるわけです
なので、HttpURLConnectionを使うなり、OkHttpなどのなにがしかのライブラリを使用して受付期間を取得しようと思いつきました。
jsoupを使ってスクレイピング編
しかしITSではWeb APIをサービスしているわけではないので、ナマのHTMLを解析しなければなりません。こんなHTMLです。
<section class="section">
<div>保養施設の申込受付については下記の通りです。</div>
<h4 id="29">平成29年度</h4>
<table>
<thead>
<tr>
<th colspan="2">申込対象期間(希望日)</th>
<th>受付期間<br/>郵送・FAX</th>
<th>受付期間<br/>WEB</th>
<th>申込結果<br/>回答日</th>
<th>空き状況の<br/>照会開始日</th>
</tr>
</thead>
<tbody>
<tr>
<td>H29.4月分</td>
<td>4/1~4/30</td>
<td>1/30~2/3</td>
<td>1/30~2/6</td>
<td>2/14(火)</td>
<td>2/20(月)</td>
</tr>
(中略)
<tr>
<td>3月分</td>
<td>3/1~3/31★</td>
<td>12/12~12/18</td>
<td>12/12~12/19</td>
<td>H30.1/11(木)</td>
<td>H30.1/22(月)</td>
</tr>
</tbody>
</table>
<p>★3月分の抽選は12月となりますのでご注意ください。</p>
<p>※申込対象期間(希望日)は月毎の申し込みとなります。</p>
</section>
id属性が付いているのはたったの1か所です。
ということで、スクレイピングせねばならぬと思い立ち、jsoupというHTMLパーサーライブラリを使うことにしました。
そもそもAndroidではネット通信は非UIスレッドで実行しなければならない
ウィジェットの本体であるAppWidgetProviderクラスは、BroadcastReceiverのサブクラスです。すなわち、Activityではありませんので、UIのためのコンポーネントではありません...と言い切れないわけですね、だってウィジェットってUIですから。なので、ウィジェットとは、「UIを表示できるBroadcastReceiver」であると言えます。
お膳立てされたAppWidgetProviderサブクラスに実装されたメソッドたちの第1引数にContextを受け取っているあたりからも、UIを表示や操作をする気マンマンだということがうかがえます。
ということで、AsyncTaskなどで、「非UIスレッド(メインスレッドではない、別スレッド)でネット通信するけど、その通信結果をUIで表示する」という処理を書くのは面倒くさそうだぞ...ときな臭い感じがしたので、この案は却下しました。いちいちウィジェットにネット通信させるのもオーバーヘッドになりそうだし、という理由もあります。
「受付期間」をDB化する
Androidが内蔵しているRDBMSはSQLiteです。そこでPCでこんなテーブルを作り、12月分のレコードをちゃちゃっと作りました。
CREATE TABLE "NENDO2017" (
`_id` INTEGER PRIMARY KEY AUTOINCREMENT,
`GATSUBUN` INTEGER NOT NULL,
`JI_MONTH` INTEGER NOT NULL,
`JI_DAY` INTEGER NOT NULL,
`SI_MONTH` INTEGER NOT NULL,
`SI_DAY` INTEGER NOT NULL
)
WindowsPC上でSQLiteのDBファイルが作れるのも、DB Browser for SQLiteのおかげです。
「自」「至」は、時間や場所を示すときに使われます。「自」(じ)は起点に、「至」(し)は終点に付きます。という意味で名付けた列名です。FROMとかTOとかのような英語でなくてすみません。
このSQLiteのDBファイルを、assetsにコピーして、ウィジェットで読み取ろうという作戦に出ました。なお、assetsに置いたDBファイルをどうやったらアクセスできるのか、のやり方は、この記事では本題ではないので載せませんすみません。
ゆくゆくは
ということで、ITSのWebサイトのHTMLをスクレイピングするのはやめて、DBを使う事だけを採用したわけですが、ゆくゆくは、
- 年度が終わってしまって、DBがないのであれば、スクレイピングする。
- そのスクレイピング解析結果を、新規にDBテーブルを作ってINSERTしておく。
というようにすれば、年度の最初だけネット通信して、1年間はDBだけでまかなえるウィジェットになるかなー。と思います。
クリックイベント対応方法
Activityであれば、findViewByIdメソッドで取得したViewに、View.OnClickListenerを設定する、という方法が一般的ですが、AppWidgetProviderはそういう手法ではありません!つまり、AppWidgetProviderを、Activityと同じノリだと勘違いしちゃダメ!ですよ。
ブラウザーで見るための画像を用意しておきます。
<!-- ブラウザーで見る -->
<ImageView
android:id="@+id/browser"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:src="@drawable/browser" />
では、ウィジェットのプログラムでクリックイベントに対応させます。
private static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) {
// まずこれが大事!
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.new_app_widget);
// 自分自身に向けたIntentとなる
Intent intent = new Intent("jp.co.casareal.toslovewidget.action.BROWSER");
// ブロードキャストIntentに仕立て上げる
PendingIntent pi = PendingIntent.getBroadcast(context, appWidgetId, intent , PendingIntent.FLAG_UPDATE_CURRENT);
// <ImageView>のandroid:id属性を宛に、クリックイベントの設定
views.setOnClickPendingIntent(R.id.browser, pi);
}
@Override
public void onReceive(Context context, Intent intent) {
super.onReceive(context, intent); // これがないとダメっぽい
switch (intent.getAction()) {
case "jp.co.casareal.toslovewidget.action.BROWSER": // ブラウザー起動
Uri uri = Uri.parse("http://www.its-kenpo.or.jp/");
Intent browserIntent = new Intent(Intent.ACTION_VIEW, uri);
// Activity以外からstartActivityするときにはこれをしないといけない
browserIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(browserIntent);
break;
}
}
独自Actionの文字列"jp.co.casareal.toslovewidget.action.BROWSER"
はマニフェストにも記載することになります。
PendingIntentに仕立て上げて、自分からブロードキャストして、自分でレシーヴすることになります。
そこでもちろん、マニフェストにも<intent-filter>
しておきます。
<receiver
android:name=".TosloveWidget"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="jp.co.casareal.toslovewidget.action.BROWSER" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/toslove_widget_info" />
</receiver>
「自分からブロードキャストして、自分でレシーヴする」といったあたりに、妙な気分が味わえます。
これ以外にも、ウィジェットの特定の場所をクリックしたら、データを更新するとか再検索を行う、などもやってみたいです。となると、
- 独自Actionの文字列を用意する。
- Intentを生成して、そのActionを設定する。
- そのIntentは、自分からブロードキャストして、自分でレシーヴするPendingIntentにする。
- RemoteViews#[setOnClickPendingIntent](https://developer.android.com/reference/android/widget/RemoteViews.html#setOnClickPendingIntent(int, android.app.PendingIntent))でビューにそのPendingIntentを設定する。
- onReceiveメソッドでレシーヴするので、クリック処理をしましょう。
- そうそう、マニフェストのに、独自Actionをで登録しておきましょうね。
という手筈で対応できます。
システムAction(android.intent.action.BOOT_COMPLETEDなど)も上記の5.と6.でレシーヴできます。
ウィジェットのライフサイクル
BroadcastReceiverにはライフサイクルという概念自体が無いのですが、そのサブクラスのAppWidgetProviderには以下のライフサイクルがあります。
- onEnabled :1つ目のウィジェットがホームに追加される時に呼び出される。
- onUpdate :ウィジェットがホームに追加される時に呼び出される。1つ目のときはonEnabledの直後に呼ばれる。2つ目以降(=同じウィジェットが既にホームに置かれている場合)はこのonUpdateだけが呼び出される。android:updatePeriodMillis属性のミリ秒値の周期でもこのonUpdateは呼び出される。
- onDeleted :ウィジェットがホームから削除される時に呼び出される。他にウィジェットが残っているとき(=同じウィジェットがまだホームに置かれている場合)はこのonDeletedだけが呼び出される。
- onDisabled :ホームから最後のウィジェットが削除されるとき、onDeletedの直後に呼び出される
ここで留意していただきたいのは、Activityなどから直接ウィジェットのライフサイクルを制御できないということです。
android:updatePeriodMillis
属性のミリ秒値にも注意してください。"0"
にすると、onUpdateを周期的に呼び出すことはしなくなります。電力消費量を抑えるため、最低値は30分を表す"1800000"
とされています。
さいごに
Activityのようで、Activityではない(そりゃそうだ)。BroadcastReceiverのようでいて、BroadcastReceiverではない(でも、やっぱり、BroadcastReceiverだ)。
こういった妙な気分になれた、ウィジェット開発奇譚でした。
よし!これで次は館山のホテルに行くぞ!(当籤しますように)
以上です。
ところが!Oreo!
話はここで終わりません。
Android 8.0(Oreo)からは、 暗黙的なブロードキャストインテントに対して登録されているブロードキャストレシーバーをすべて削除する ことになりました。もっと短く言えば、暗黙的ブロードキャストの禁止です。手前味噌ではございますが、私の別の記事「Android 8.0変更点:マニフェスト ファイルからブロードキャスト レシーバーを削除せよ」をご参照いただければと思います。
ということで、上述でオリジナルのActionを設定したインテントをブロードキャストしていますが、この処理をAndroid 8.0端末上で実行させると、LogCatに、
BroadcastQueue: Background execution not allowed
という旨のWarningログが出力されます。アプリ(ウィジェット)は落ちはしませんが、上述の私の作ったウィジェットの、ブラウザのアイコンをクリックしても(処理してくれないので)反応しません。
当記事では、このAndroid 8.0対応までは言及しません。いつ いつの日か わたしにもわかる 対策に気づいたら 記事書きます。
今度こそ、本当に、以上です。
-
この健保組合の被保険者らが利用できるホテルで、すごく安くて!すごくきれいで!すごく美味しい!最高です。なのでハイシーズンの抽選倍率は相当になると思われます(よく落籤するので)。 ↩