53
58

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

AndroidのWidgetだけ開発

Last updated at Posted at 2017-12-27

こんにちは。

はじめに

会社の同僚が、「関東ITソフトウェア健康保険組合(略称ITS)の保養施設1抽選申込期間って、ついつい見逃してしまう」と悔しがりだしました。たとえば、8月に保養施設行きたい場合、5/29~6/5に申し込まないといけないんですよ。油断すると申込受付が過ぎちゃいがちです。:confounded:

ざっくりITS保養施設の抽選申し込み方法を説明すると、

  • だいたい2か月前が受付期間である(例:8月分の受付期間は5/29~6/5)。
  • 申し込み方法は「郵送・FAX」と「WEB」の2通りがある。
  • 特に「3月分」の抽選は12月中なので気を付けて(1月中ではない、ということ)。
  • 年度単位で一斉に発表される。

そこでAndroidで、Activityなしのウィジェットだけのアプリを作ろうと思い立ちました。こういうのです。

appwidget_preview.png

ということで、作るウィジェットは「なるべく小さく、情報も必要最低限にとどめる」を心がけて、「年度」と「〇月分」と「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」を選びます。

androidstudio_startnewproject.png

Activityなしのウィジェットだけのアプリを作る ので、「Add No Activity」を選びます。

createnewproject_noactivity.png

プロジェクトができたら、「app」を右クリックして、[New]→[Widget]→[App Widget]です。

new_widget_appwidget.png

タテ×ヨコなど決めて、Finishです。

createnewappwidget.png

お膳立てされるアプリのソース

ウィジェットクラス

ウィジェットは、AppWidgetProviderのサブクラスとして作ります。AppWidgetProviderは、BroadcastReceiverのサブクラスであるということがポイントです。

NewAppWidget.java
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のサブクラスですので、マニフェストに登録してあります。

AndroidManifest.xml
<?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)ですしね、レイアウトは設定しないと。

new_app_widget.xml
<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"と書いてありましたしね。
作るウィジェットの設定を定義します。

new_app_widget_info.xml
<?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/の中に、こんな画像がありました。

example_appwidget_preview.png

お膳立てされたやつで、とにかく実行してみる

*アプリのようで、アプリではない。それがウィジェットだ。*ということで、instant runでは実行できません。
プルダウンから「Edit Configurations...」をクリックしてください。

editconfig.png

Activity一切なしのウィジェットだけのアプリなので、「Launch:」の項目プロダウンから、「Nothing」を選んでください。

launch_nothing.png

これで実行すると、インストールされます。でも、ウィジェットなので、自力でウィジェットランチャーから、ドラッグ&ドロップして、ホーム画面に落としてください。

device-2017-12-14-162121.png

なるほど、サンプル画像は、ここで登場するのか。

「受付期間」の要件を考えてみる

従来の申し込み方法は、WebブラウザでITSのページを開いて目視することから始まるわけです

moushikomi_kikan.png

なので、HttpURLConnectionを使うなり、OkHttpなどのなにがしかのライブラリを使用して受付期間を取得しようと思いつきました。

jsoupを使ってスクレイピング編

しかしITSではWeb APIをサービスしているわけではないので、ナマのHTMLを解析しなければなりません。こんなHTMLです。

ITSの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か所です。:sob:

ということで、スクレイピングせねばならぬと思い立ち、jsoupというHTMLパーサーライブラリを使うことにしました。

そもそもAndroidではネット通信は非UIスレッドで実行しなければならない

ウィジェットの本体であるAppWidgetProviderクラスは、BroadcastReceiverのサブクラスです。すなわち、Activityではありませんので、UIのためのコンポーネントではありません...と言い切れないわけですね、だってウィジェットってUIですから。なので、ウィジェットとは、「UIを表示できるBroadcastReceiver」であると言えます。

お膳立てされたAppWidgetProviderサブクラスに実装されたメソッドたちの第1引数にContextを受け取っているあたりからも、UIを表示や操作をする気マンマンだということがうかがえます。

ということで、AsyncTaskなどで、「非UIスレッド(メインスレッドではない、別スレッド)でネット通信するけど、その通信結果をUIで表示する」という処理を書くのは面倒くさそうだぞ...:thinking:ときな臭い感じがしたので、この案は却下しました。いちいちウィジェットにネット通信させるのもオーバーヘッドになりそうだし、という理由もあります。

「受付期間」をDB化する

Androidが内蔵しているRDBMSはSQLiteです。そこでPCでこんなテーブルを作り、12月分のレコードをちゃちゃっと作りました。

toslove.sqlite
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のおかげです。

db_records.png

「自」「至」は、時間や場所を示すときに使われます。「自」(じ)は起点に、「至」(し)は終点に付きます。という意味で名付けた列名です。FROMとかTOとかのような英語でなくてすみません。

このSQLiteのDBファイルを、assetsにコピーして、ウィジェットで読み取ろうという作戦に出ました。なお、assetsに置いたDBファイルをどうやったらアクセスできるのか、のやり方は、この記事では本題ではないので載せませんすみません。

ゆくゆくは

ということで、ITSのWebサイトのHTMLをスクレイピングするのはやめて、DBを使う事だけを採用したわけですが、ゆくゆくは、

  1. 年度が終わってしまって、DBがないのであれば、スクレイピングする。
  2. そのスクレイピング解析結果を、新規にDBテーブルを作ってINSERTしておく。

というようにすれば、年度の最初だけネット通信して、1年間はDBだけでまかなえるウィジェットになるかなー。と思います。

クリックイベント対応方法

Activityであれば、findViewByIdメソッドで取得したViewに、View.OnClickListenerを設定する、という方法が一般的ですが、AppWidgetProviderはそういう手法ではありません!つまり、AppWidgetProviderを、Activityと同じノリだと勘違いしちゃダメ!:no_good_tone1:ですよ。

ブラウザーで見るための画像を用意しておきます。

browser.png

/res/layout/new_app_widget.xml(一部)
<!-- ブラウザーで見る -->
<ImageView
    android:id="@+id/browser"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_gravity="center"
    android:src="@drawable/browser" />

では、ウィジェットのプログラムでクリックイベントに対応させます。

AppWidgetProvider内のコードの一部
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>しておきます。

AndroidManifest.xml(一部)
<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>

自分からブロードキャストして、自分でレシーヴする」といったあたりに、妙な気分が味わえます。

これ以外にも、ウィジェットの特定の場所をクリックしたら、データを更新するとか再検索を行う、などもやってみたいです。となると、

  1. 独自Actionの文字列を用意する。
  2. Intentを生成して、そのActionを設定する。
  3. そのIntentは、自分からブロードキャストして、自分でレシーヴするPendingIntentにする。
  4. RemoteViews#[setOnClickPendingIntent](https://developer.android.com/reference/android/widget/RemoteViews.html#setOnClickPendingIntent(int, android.app.PendingIntent))でビューにそのPendingIntentを設定する。
  5. onReceiveメソッドでレシーヴするので、クリック処理をしましょう。
  6. そうそう、マニフェストのに、独自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だ)。
こういった妙な気分になれた、ウィジェット開発奇譚でした。

よし!これで次は館山のホテルに行くぞ!(当籤しますように:dart:

以上です。

ところが!Oreo!

話はここで終わりません。

Android 8.0(Oreo)からは、 暗黙的なブロードキャストインテントに対して登録されているブロードキャストレシーバーをすべて削除する ことになりました。もっと短く言えば、暗黙的ブロードキャストの禁止です。手前味噌ではございますが、私の別の記事「Android 8.0変更点:マニフェスト ファイルからブロードキャスト レシーバーを削除せよ」をご参照いただければと思います。

ということで、上述でオリジナルのActionを設定したインテントをブロードキャストしていますが、この処理をAndroid 8.0端末上で実行させると、LogCatに、

BroadcastQueue: Background execution not allowed

という旨のWarningログが出力されます。アプリ(ウィジェット)は落ちはしませんが、上述の私の作ったウィジェットの、ブラウザのアイコンをクリックしても(処理してくれないので)反応しません。

当記事では、このAndroid 8.0対応までは言及しません。いつ いつの日か わたしにもわかる 対策に気づいたら 記事書きます。

今度こそ、本当に、以上です。

  1. この健保組合の被保険者らが利用できるホテルで、すごく安くて!すごくきれいで!すごく美味しい!最高です。なのでハイシーズンの抽選倍率は相当になると思われます(よく落籤するので)。

53
58
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
53
58

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?