2016/05/28

Androidの新プッシュ通知FCMへの移行

GCM-3.0 のサンプルを書いたばかりなのに Google IO 2016 で新たなプッシュ通知のAPIである FCM(Firebase Cloud Messaging) が発表されました。

いい加減にしろよ Google。 なんで API が1年しか寿命ねーんだよ。 学習コストとか保守コストか考えろよ!!

しかたが無いので GCM 3.0 から FCM への移行のまとめです。
前回の GCM 3.0 のサンプルを移行します。

基本情報

基本的な情報へのリンクです。

GCM3.0との差異

  • プロジェクト管理が Developer console から Firebase console に変更になります。
  • API は com.google.android.gms から com.google.firebase に変更になります。
  • コード埋め込みだった各種キーコードは google-services.json に集約されます。
  • サーバ側のメッセージ送信先 URL が gcm-http.googleapis.com/gcm/send から fcm.googleapis.com/fcm/send に変更になります。
    • これはオプションなのでそのままでも当面問題無し。

その他の基本的な仕組みは GCM3.0 と変わっていません。

準備

FCM を使うための準備をします。 ここでは GCM からの移行を行います。

Firebase console をブラウザで開いてログインします。

「google プロジェクトからのインポート」をクリックします。


元になるプロジェクトを選択します。
国/地域を「日本」に設定します。
「FIREBASEを追加」をクリックします。


「AndroidアプリにFirebaseを追加」をクリックします。


アプリのパッケージ名を設定して「アプリを追加」をクリックします。
以降のステップは「続行」、「完了」で飛ばします。


メニューの「管理」を選択します。


google-services.json をダウンロードして保管します。

ソースコードの変更

基本的には公式ドキュメントのままなのですがそのままだと動かないので注意が必要です。

前の GCM-3.0 のサンプルからの変更点です。

gradle

単純に 公式FCM移行ドキュメント の設定だけを行うと以下のエラーが出てハマります。

Error Failed to resolve: com.google.firebase:firebase-messaging:9.0.0

google-services のライブラリが必要なようなので一緒に追加する必要があります。

build.gradle

buildscript {
        :
    dependencies {
            :
        classpath 'com.google.gms:google-services:3.0.0'
    }
}

app/build.gradle

dependencies {
        :
    compile 'com.google.android.gms:play-services-gcm:9.0.0'
    compile 'com.google.firebase:firebase-messaging:9.0.0'
}
apply plugin: 'com.google.gms.google-services'

AndroidMAnifest.xml

<!--
        <receiver
            android:name="com.google.android.gms.gcm.GcmReceiver"
            android:exported="true"
            android:permission="com.google.android.c2dm.permission.SEND">
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE"/>
                <category android:name="org.kotemaru.android.gcm_sample"/>
            </intent-filter>
        </receiver>
-->
        <service
            android:name="org.kotemaru.android.gcm_sample2.lib.TokenRefreshService">
            <intent-filter>
                <action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
            </intent-filter>
        </service>

        <service
            android:name="org.kotemaru.android.gcm_sample2.lib.GCMReceiverService">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
  • GcmReceiver は不要になります。
  • Service2つはフィルターが変更になってexportedが有効になります。

Javaソース

GCMReceiverService.java

public class GCMReceiverService
        //extends GcmListenerService      // for GCM-3.0
        extends FirebaseMessagingService  // for FCM
{
                    :
    @Override
    public void onMessageReceived(RemoteMessage message){
        String from = message.getFrom();
        Map<String,String> data = message.getData();

        Bundle bundle = new Bundle();
        for (Map.Entry<String,String> ent : data.entrySet()) {
            bundle.putString(ent.getKey(), ent.getValue());
        }
        onMessageReceived(from, bundle);
    }
  • 継承元を GcmListenerService から FirebaseMessagingService に変更します。
  • onMessageReceived() の引数が変わるので変換して旧APIに転送するメソッドを追加します。

TokenRefreshService.java

    public class TokenRefreshService
        // extends InstanceIDListenerService  // for GCM-3.0
        extends FirebaseInstanceIdService     // for FCM
  • 継承元を InstanceIDListenerService から FirebaseInstanceIdService に変更します。

GCMRegister.java

    private static String registerSync(final Context context, final String senderId) {
        try {
            // for GCM-3.0
            //InstanceID instanceID = InstanceID.getInstance(context);
            //String regId = instanceID.getToken(senderId, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
            // for FCM
            String regId = FirebaseInstanceId.getInstance().getToken();

            Log.d(TAG, "registerSync: " + senderId + ":" + regId);
            return regId;
        } catch (Exception e) {
            Log.e(TAG, "Failed get token:" + senderId, e);
            return null;
        }
    }
  • InstanceID.getToken() を FirebaseInstanceId.getInstance().getToken() に変更します。
  • ※1:アプリによって実装場所は違います。
  • ※2:senderId は google-services.json で持っているため FCM では使っていません。

app/google-services.json

  • ダウンロードしておいた google-services.json を app/ の直下に配置します。

  

サーバ

サンプルのサーバは nodejs です。

var API_KEY = 'google-services.jsonのapi_key/current_keyの値';

            :

function requestGCM(postData, callback) {
    var buff = new Buffer(JSON.stringify(postData));
    var requestOpts = {
        method : 'POST',
        //host : 'gcm-http.googleapis.com', // for GCM-3.0
        //path : '/gcm/send',               // for GCM-3.0
        host : 'fcm.googleapis.com',        // for FCM
        path : '/fcm/send',                 // fot FCM
        port : 443,
        headers : {
            'Content-length' : buff.length,
            'Connection' : 'close',
            'Content-Type' : 'application/json;charset=utf-8',
            'Authorization' : 'key=' + API_KEY
        }
    };
  • API-KEY を google-services.json 内の apikey/currentkey の項目の値に差し替えます。
    • プログラムが google-services.json を参照するようにしておく方が良いかと思います。
  • 送信先の URL を FCM に切り替えます。
    • ※3: 送信先の URL は当面変更しなくても問題無いようです。
    • ※4: FCM の URL に変更した場合に GCM のアプリに届くのかは未検証です。

所感

gradle の設定でつまずきましたがそれ以外は思ったより手間がかからず移行できました。
GCM-3.0は当面使えるらしいので慌てて移行する必要は無いと思われます。
これから実装する物に関しては FCM にしておくべきでしょうが。


2015/11/22

GCMのサンプル再び

前に GCM のサンプルを書いたのですが Deprecated になって GCM-3.0 がでていました。
って言うか 2.0 の存在にすら気づいていませんでしたw

2年間で2回のAPI変更が有ったようです。しかも後方互換性無しに。 似たような事多いですが最近の Google の方向性なんでしょうかね。

基本情報

基本的な情報へのリンクです。

GCMの仕組み

GCMの基本的な仕組み依然と変わっていません。

  • (1) アプリ起動後、GCMサーバから 端末ID を取得します。
  • (2) ユーザID端末ID のペアをアプリサーバに送信してDBに保存します。
  • (3) 目的の ユーザ に向けた メッセージ をアプリサーバに送信します。
  • (4) アプリサーバは ユーザID から 端末ID を引いてGCMサーバにメッセージを転送します。
  • (5) GCMサーバは繋ぎっぱなしのセッションに メッセージ を送信します。

準備

サーバ用の API-KEY とクライアント用の SENDER-ID が必要な事は同じです。
但し、Googleのデベロッパ・コンソールが大幅変更になっているので過去記事は役にたちません。

以降の手順で取得します。

クライアント用 SENDER-ID の取得

SENDER-ID はプロジェクト番号なのでプロジェクトを作成します。

プロジェクトIDの右に表示される数値が SENDER-ID です。

プロジェクトの利用可能 API に 「Cloud Message for Android」 を追加します。

有効にします。

サーバ用 API-KEY の取得

認証情報の追加からAPIキーを選択します。

「サーバキー」を選択します。

表示された値がサーバ用 API-KEY になります。

クライアントの実装

サービス

GCM3.0 ではアプリは以下の3つの Service を必要とします。

目的継承元クラス
RegistrationIdの更新通知を受け取るextends InstanceIDListenerService
GCMのメッセージを受け取るextends GcmListenerService
RegistrationIdの更新をアプリサーバに送信するextends IntentService

3つに分ける理由はよくわかりませんが少なくとも GcmListenerService は onStartCommand() が final なので機能追加はできません。

TokenRefreshService.java:

RegistrationId の更新タイミングを受け取るだけのサービスです。
GCMRegisterService にイベントを転送するだけです。

package org.kotemaru.android.gcm_sample;
import ...

public class TokenRefreshService extends InstanceIDListenerService {
    @Override
    public void onTokenRefresh() {
        Intent intent = new Intent(this, GCMRegisterService.class);
        startService(intent);
    }
}
GCMRegisterService.java:

RegistrationId を更新してアプリサーバへ登録・登録解除処理を行う。

package org.kotemaru.android.gcm_sample;
import ...

public class GCMRegisterService extends IntentService {
    private static final String TAG = GCMRegisterService.class.getSimpleName();
    private Handler toaster = new Handler(Looper.getMainLooper());

    public GCMRegisterService() {
        super("GCMRegisterService");
    }
    @Override
    protected void onHandleIntent(Intent intent) {
        String oldRegId = GCMRegister.getRegistrationId(this);
        String regId = GCMRegister.registerSync(this, SENDER-ID);
        if (regId != null) {
            if (oldRegId != null)  onUnregistered(this, oldRegId);
            onRegistered(this, regId);
        }
    }
    protected void onRegistered(Context context, String registrationId) {
        Log.i(TAG, "onRegistered: regId = " + registrationId);
        // GCMから発行された端末IDをアプリサーバに登録する。
        String uri = SERVER_URL + "?action=register" + "&userId=" + USER_ID + "&regId=" + registrationId;
        Util.doGet(uri);
    }
    protected void onUnregistered(Context context, String registrationId) {
        Log.i(TAG, "onUnregistered: regId = " + registrationId);
        // GCMから発行された端末IDをアプリサーバから登録解除する。
        String uri = SERVER_URL + "?action=unregister" + "&userId=" + USER_ID + "&regId=" + registrationId;
        Util.doGet(uri);
    }
}
GCMReceiverService.java:

GCM のメッセージを受け取るサービス。

package org.kotemaru.android.gcm_sample;
import ...

public class GCMReceiverService extends GcmListenerService {
    private static final String TAG = GCMReceiverService.class.getSimpleName();
    @Override
    public void onMessageReceived(String from, Bundle data) {
        String msg = data.getString("msg");
        Log.d(TAG, "onMessageReceived: from=" + from + "  message=" + msg);
    }
    @Override
    public void onDeletedMessages() {
        Log.d(TAG, "onDeletedMessages:");
    }
    @Override
    public void onMessageSent(String msgId) {
        Log.d(TAG, "onMessageSent:" + msgId);
    }
    @Override
    public void onSendError(String msgId, String error) {
        Log.d(TAG, "onSendError:" + msgId + "," + error);
    }
}

その他

GCMRegister.java:

旧APIの GCMRegister に似せた RegistrationId 管理クラス。
一旦取得した RegistrationId は Preferences に保存して無駄な通信をしないようにします。

package org.kotemaru.android.gcm_sample;
import ...

public class GCMRegister {
    private static final String TAG = GCMRegister.class.getSimpleName();
    public static final String GCM_PREF_NAME = "GCM";
    public static final String KEY_REG_ID = "RegistrationId";

    /**
     * 登録済の RegistrationId を返す。
     *
     * @param context
     * @return null=未登録
     */
    public static String getRegistrationId(Context context) {
        SharedPreferences prefs = context.getSharedPreferences(GCM_PREF_NAME, Context.MODE_PRIVATE);
        String regId = prefs.getString(KEY_REG_ID, null);
        Log.d(TAG, "getRegistrationId:" + regId);
        return regId;
    }

    /**
     * GCM3.0の InstanceID から RegistrationId を取得する。
     * <li>RegistrationId は Preferences に保存する。</li>
     * <li>通信をするのでUI-Threadでは実行不可</li>
     *
     * @param context
     * @param senderId アプリのSENDER-ID
     * @return RegistrationId (null=取得失敗)
     */
    public static String registerSync(final Context context, final String senderId) {
        SharedPreferences prefs = context.getSharedPreferences(GCM_PREF_NAME, Context.MODE_PRIVATE);
        try {
            InstanceID instanceID = InstanceID.getInstance(context);
            String regId = instanceID.getToken(senderId, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
            Log.d(TAG, "registerSync: " + senderId + ":" + regId);
            prefs.edit().putString(KEY_REG_ID, regId).apply();
            return regId;
        } catch (Exception e) {
            Log.e(TAG, "Failed get token:" + senderId, e);
            prefs.edit().putString(KEY_REG_ID, null).apply();
            return null;
        }
    }
}
MainActivity.java:

アプリとしての初期化処理を行います。
RegistrationId が未登録ならばサービスに登録要求を出すだけです。

package org.kotemaru.android.gcm_sample;
import ...

/**
 * クライアントアプリ本体。
 */
public class MainActivity extends Activity {
    // https://console.developers.google.comのProject Number。
    public static final String SENDER-ID = "99999999999";
    // アプリサーバーのURL。
    public static final String SERVER_URL = "http://192.168.0.9:8888/";
    // アプリのユーザID。本来はログイン中のユーザとかになるはず。
    public static final String USER_ID = "TarouYamada";

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

        final String regId = GCMRegister.getRegistrationId(this);
        if (regId == null || regId.isEmpty()) {
            // GCMへ端末登録。
            Intent intent = new Intent(this, GCMRegisterService.class);
            startService(intent);
        }
    }
}
AndroidManifest.xml

パーミッションは変わっていません。
service/recevier の定義はほぼテンプレなのでクラス名の差し替えだけで済むと思います。

<?xml version="1.0" encoding="utf-8"?>
<manifest
    package="org.kotemaru.android.gcm_sample"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <meta-data
        android:name="com.google.android.gms.version"
        android:value="@integer/google_play_services_version"/>

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.WAKE_LOCK"/>
    <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
    <uses-permission android:name=".permission.C2D_MESSAGE"/>
    <!-- <uses-permission android:name="android.permission.GET_ACCOUNTS"/> under API-15 -->
    <permission
        android:name=".permission.C2D_MESSAGE"
        android:protectionLevel="signature"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme">
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <receiver
            android:name="com.google.android.gms.gcm.GcmReceiver"
            android:exported="true"
            android:permission="com.google.android.c2dm.permission.SEND">
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE"/>
                <category android:name="org.kotemaru.android.gcm_sample"/>
            </intent-filter>
        </receiver>

        <service
            android:name=".TokenRefreshService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.android.gms.iid.InstanceID"/>
            </intent-filter>
        </service>

        <service
            android:name=".GCMRegisterService"
            android:exported="false">
        </service>

        <service
            android:name=".GCMReceiverService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE"/>
            </intent-filter>
        </service>
    </application>

</manifest>
app/build.gradle:

dependencies に play-services を追加します。

dependencies {
    :
    compile 'com.google.android.gms:play-services:8.3.0'
}

サーバ

サーバは特に変更が無いはずですが Android Struio でシンプルな Servlet が作れなかったので NodeJS で書いてみました。

GCMサーバへのリクエストは JSON で行うのでライブラリ無しでも行けます。
公式サイトの以下を参考にしました。

app.js:
/**
 * GCM アプリサーバ
 * - API
 * -- ?action=register&userId={ユーザID}&regId={端末ID}
 * -- ?action=unregister&userId={ユーザID}
 * -- ?action=send&userId={ユーザID}&msg={送信メッセージ}
 */
var TAG = "app:"
var Http = require('http');
var Https = require('https');
var Url = require('url');
var QueryString = require('querystring');

var PORT = 8888;
// https://console.developers.google.comで生成したAPIキー。
var API_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

var deviceMap = {};
function doService(req, res) {
    var url = Url.parse(req.url);
    req.parsedUrl = url;
    req.params = QueryString.parse(url.query);

    var action = req.params.action;
    var registrationId = req.params.regId;
    var userId = req.params.userId;
    var msg = req.params.msg;

    if ("register" == action) {
        // 端末登録、Androidから呼ばれる。
        deviceMap[userId] = registrationId;
        console.log("register:", userId, registrationId);
        res.end();
    } else if ("unregister" == action) {
        // 端末登録解除、Androidから呼ばれる。
        delete deviceMap[userId];
        console.log("unregister:", userId, registrationId);
        res.end();
    } else if ("send" == action) {
        // メッセージ送信。任意の送信アプリから呼ばれる。
        registrationId = deviceMap[userId];
        var postData = {
            registration_ids : [registrationId],
            data : {
                msg : msg
            }
        };
        requestGCM(postData, function(statusCode, response, err) {
            res.statusCode = statusCode;
            var buff = new Buffer(JSON.stringify(response));
            res.setHeader('Content-Type', 'application/json;charset=utf-8');
            res.setHeader('Content-length', buff.length);
            res.write(buff);
            res.end();
        });

    } else {
        res.statusCode = 500;
        res.end();
    }
}

function requestGCM(postData, callback) {
    var buff = new Buffer(JSON.stringify(postData));
    var requestOpts = {
        method : 'POST',
        host : 'gcm-http.googleapis.com',
        port : 443,
        path : '/gcm/send',
        headers : {
            'Content-length' : buff.length,
            'Connection' : 'close',
            'Content-Type' : 'application/json;charset=utf-8',
            'Authorization' : 'key=' + API_KEY
        }
    };
    console.log("GCM request:", postData);

    var svrReq = Https.request(requestOpts, function(svrRes) {
        var rawBody = "";
        svrRes.on('data', function(chunk) {
            rawBody += chunk;
        });
        svrRes.on('end', function() {
            console.log("GCM response:", rawBody);
            if (svrRes.statusCode == 200) {
                callback(svrRes.statusCode, JSON.parse(rawBody));
            } else {
                callback(svrRes.statusCode, rawBody);
            }
        });
    });
    svrReq.on('error', function(e) {
        callback(500, null, e);
    });

    svrReq.write(buff);
    svrReq.end();
}

// HTTPサーバ作成
var server = Http.createServer();
server.on('request', doService);
server.listen(PORT);

console.log("listen porxy", PORT);

補足

onTokenRefresh() のテストが難しいです。
一時的に TokenRefreshService を exported="true" にして adb からイベントを送ります。

adb shell am startservice -a com.google.android.gms.iid.InstanceID\
    --es "CMD" "RST" \
    -n org.kotemaru.android.gcm_sample/.TokenRefreshService

所感

なんか色々面倒くさくなった感じ。 RegistrationId の更新を制御したかったんだと思うけどもう少し整理した API にできなかったんだろうか。

全てのソースはこちら

ライブラリ的に使えるように整理した版


2015/11/15

gradleでlayout.xmlからクラス生成

layout.xml の id から View クラスを引いてくるのは結構面倒くさいので自動生成する方法を考えてみました。

アノテーションでやる方法は有りますが1つの項目にたいして2行の宣言が必要です。
通常は layout.xml に id を指定した時点で命名規則に従っているのでこの2行も必要無いはずです。

やりたいことはこんな感じです。

レイアウト定義:

res/layout/activity_main.xml:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
             xmlns:tools="http://schemas.android.com/tools"
             android:layout_width="match_parent"
             android:layout_height="match_parent"
             tools:context=".MainActivity">
    <ListView
        android:id="@+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</FrameLayout>
res/layout/list_item.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:orientation="horizontal">
    <ImageView
        android:id="@+id/icon"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:src="@android:drawable/ic_menu_edit"/>
    <TextView
        android:id="@+id/label"
        android:layout_gravity="left|center_vertical"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Sample"
        android:textSize="16dp"/>
</LinearLayout>

生成コード

フィールド名は id をキャメル変換してプレフィックスを付けています。

generated/layout/src/.../layout/ActivityMainViews.java:
package org.kotemaru.android.myapp.layout;
import ...
public class ActivityMainViews {
    public final ListView mListView;
    public ActivityMainViews(android.app.Activity root) {
        this.mListView = (ListView) root.findViewById(R.id.list_view);
    }
}
generated/layout/src/.../layout/ListItemViews.java:
package org.kotemaru.android.myapp.layout;
import ...
public class ListItemViews {
    public final ImageView mIcon;
    public final TextView mLabel;
    public ListItemViews(View root) {
        this.mIcon = (ImageView) root.findViewById(R.id.icon);
        this.mLabel = (TextView) root.findViewById(R.id.label);
    }
}

アプリ実装

MainActivity.java:
package org.kotemaru.android.myapp;
import org.kotemaru.android.myapp.layout.ActivityMainViews;
import org.kotemaru.android.myapp.layout.ListItemViews;

public class MainActivity extends AppCompatActivity {
    private ActivityMainViews mViews;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mViews = new ActivityMainViews(this);
        mViews.mListView.setAdapter(new PkgListAdapter());
    }

    class PkgListAdapter extends BaseAdapter {
        private PackageManager mPackageManager  = getPackageManager();
        private LayoutInflater mInflater = getLayoutInflater();
        private List<ApplicationInfo> mItemInfos;

        …中略…

        @Override
        public View getView(final int position, View view, ViewGroup parent) {
            if (view == null) {
                view = mInflater.inflate(R.layout.list_item, null, false);
                view.setTag(new ListItemViews(view));
            }
            ListItemViews views = (ListItemViews) view.getTag();
            ApplicationInfo info = mItemInfos.get(position);
            views.mIcon.setImageDrawable(info.loadIcon(mPackageManager));
            views.mLabel.setText(info.loadLabel(mPackageManager));
            return view;
        }
    }
}

プラグインの実装

gradle のプラグインで実装します。

app/build.gradle:

build.gradle でプラグインを適用するだけです。

apply plugin: 'com.android.application'
apply from: './generateLayoutClass.gradle'   // <-- insert this line

android {
    :
app/generateLayoutClass.gradle:

プラグインの本体はこれファイルだけです。

  • res/layout/*.xml を読み込んで generated/layout/src/ に Javaクラスを出力します。
  • srcDirs に generated/layout/src/ を追加しています。

 

apply plugin: GenerateLayoutClassPlugin
tasks.preBuild.dependsOn 'generateLayoutClass';

class GenerateLayoutClassPluginExtension {
    def final String generatedSrcDir = "generated/layout/src/main/java/";
    def String appPackage = null;
    def String layoutSubPackage = ".layout";
    def String classSuffix = "Views";
    def String fieldPrefix = "m";
}

class GenerateLayoutClassPlugin implements Plugin<Project> {
    GenerateLayoutClassPluginExtension extension;

    void apply(Project project) {
        project.extensions.create('generateLayoutClass', GenerateLayoutClassPluginExtension);
        extension = project.generateLayoutClass;

        File generatedSrcDir = new File(project.buildDir, extension.generatedSrcDir);
        project.android {
            sourceSets {
                main {
                    java {
                        srcDirs += generatedSrcDir;
                    }
                }
            }
        }

        project.task('generateLayoutClass') << {
            if (extension.appPackage == null) extension.appPackage = project.android.defaultConfig.applicationId;

            FileTree tree = project.fileTree(dir: 'src/main/res/layout');
            tree.include('*.xml');
            tree.each { File file ->
                generateLayoutClass(file, generatedSrcDir);
            }
        }
    }

    String snake2camel(String snake, boolean isFirstUpper) {
        StringBuilder sbuf = new StringBuilder(snake.length());
        String[] words = snake.split('_');
        for (String word : words) {
            sbuf.append(Character.toUpperCase(word.charAt(0)));
            sbuf.append(word.substring(1));
        }
        if (!isFirstUpper) {
            sbuf.setCharAt(0, Character.toLowerCase(sbuf.charAt(0)));
        }
        return sbuf.toString();
    }

    class View {
        String name, id, fieldName, baseName;

        def View(node) {
            this.name = node.name();
            this.id = node.'@android:id'.toString().replaceFirst(/^.*\//, "");
            this.baseName = snake2camel(this.id, (extension.fieldPrefix != ""));
            this.fieldName = extension.fieldPrefix + this.baseName;
        }
    }

    void generateLayoutClass(File xmlFile, File outDir) {
        String appPackage = extension.appPackage;
        String layoutPackage = appPackage + extension.layoutSubPackage;

        def views = [];
        def layout = new XmlSlurper().parse(xmlFile).declareNamespace(
                android: 'http://schemas.android.com/apk/res/android',
                tools: 'http://schemas.android.com/tools'
        );
        def isActivity = (layout.'@tools:context' != "");
        layout.'**'.each { node ->
            if (node.'@android:id' != "") {
                views.add(new View(node));
            }
        }

        int start = xmlFile.absolutePath.lastIndexOf(File.separator);
        int end = xmlFile.absolutePath.lastIndexOf('.');
        String className = snake2camel(xmlFile.absolutePath.substring(start + 1, end), true) + extension.classSuffix;

        File parentDir = new File(outDir, layoutPackage.replace('.', '/'));
        parentDir.mkdirs();
        File outFile = new File(parentDir, className + ".java");
        if (outFile.exists() && outFile.lastModified() > xmlFile.lastModified()) {
            return;
        }

        FileWriter out = new FileWriter(outFile);

        out.println("package ${layoutPackage};");
        out.println("import ${appPackage}.R;");
        out.println("import android.view.*;");
        out.println("import android.widget.*;");
        out.println("public class ${className} {");

        for (def view : views) {
            out.println("    public final ${view.name} ${view.fieldName};");
        }
        if (isActivity) {
            out.println("    public ${className}(android.app.Activity root) {");
        } else {
            out.println("    public ${className}(View root) {");
        }
        for (def view : views) {
            out.println("        this.${view.fieldName} = (${view.name}) root.findViewById(R.id.${view.id});");
        }
        out.println("    }");
        out.println("}");
        out.close();
    }
}

実行結果

ビルドするとちゃんとクラスが生成されています。

まとめ

gradleでのコード生成は手軽な上に拡張性もあるので今後重宝しそうです。
Velocity と組み合わせができるともっとパワフルに使えるかもしれません。


2015/11/08

AndroidStudioのビルド高速化

Android Studio の gradle は柔軟で良いのですが大規模プロジェクトだとビルドが遅くて萎ます。

開発途中のビルドを少しでも早くする為にサブモジュールになっている 自前ライブラリのコンパイルを一時的に止めて外部参照する方法を考えました。

build.gradle を色々いじってみたのですが結局 settings.gradle で include してしまうと dependencies から外してもサブモジュールのビルドが走ってしまうようです。

結果として以下の手順でうまくいきました。

  • gradle.properties に切り替え用プロパティを追加する。
  • settings.gradle で上のプロパティを参照して include を切り替える。
  • app/build.gradle で上のプロパティを参照して dependencies のライブラリの参照先を .aar or .jar に切り替える。

.aar や .jar は一度フルコンパイルすればビルドされたものが残っているので repositories の flatDir で指定すると外部ライブラリの扱いで参照できます。

以下は

  • mylibrary に Androidライブラリ・モジュール
  • libtest に Javaライブラリ・モジュール

が存在するケースの例です。

gradle.properties:

compileMode=single or multi

settings.gradle:

println("compileMode="+compileMode)
if (compileMode == "single") {
    println("include ':app'")
    include ':app'
} else {
    println("include ':app',':mylibrary',':libtest'")
    include ':app',':mylibrary',':libtest'
}

app/build.gradle:

…省略…
dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:23.1.0'

    if (compileMode == "single") {
        releaseCompile(name: 'mylibrary-release', ext: 'aar')
        debugCompile(name: 'mylibrary-debug', ext: 'aar')
        compile(name: 'libtest', ext: 'jar')
    } else {
        compile project(':mylibrary')
        compile project(':libtest')
    }
}
repositories {
    flatDir {
        dirs = [
                '../mylibrary/build/outputs/aar',
                '../libtest/build/libs'
        ]
    }
}

まだ、実務環境に適用できていないですがこれで早くなって欲しいな~

追記:

実務に適用してみましたが数秒しか効果がありませんでした。orz


2015/09/13

Lolipopのバッテリー・ドレインやっと解決?

5.1.1にしてからずっと悩まされていたバッテリー・ドレインですがやっと回避法がわかってきました。
尚、私の環境・現象は以下のようなものです。

  • 環境:Nexus5/5.1.1 SIM:IIJMio
  • 現象:ずっとスリープモードでもバッテリーが1日で0%になる。
    • 4.4.4 の時は数日もった。

解決方法

  • 長距離移動したら機内モードON->OFF を行う。

どうやら LTE の基地局が切り替わると暴走が始まるようです。
それが機内モードでリセットされるっぽい。
普通の人は通勤後にやればほぼ以前の運用に戻せると思います。

その他、こちらの対策もして置くとさらに良さげです。

キャリアにも依存しそうなので「機内モードON->OFF」 で解決できた方は環境をコメントしていただけると情報共有ができて良いかと思います。 お願いします。


2015/09/01

Android アプリで top コマンド

Lolipop にしてからバッテリー・ドレインが止まらないので調査の為に Linux の top コマンド相当の機能をアプリで作ってみました。

Android/5.x(Lolipop) からアプリではプロセス一覧が取れなくなっています。 API level 21 からは ActivityManagerのgetRunningTasksやgetRunningAppProcessesは使えません。 (自分しか取れない。)

公式の代替策は UsageStatsManager ですがちょっと目的が違うしパーミッションが面倒な事になってます。

端末アプリからは ps コマンドも動かないので無理かと思ったのですが、調べた結果 /proc 配下はパーミッション無しでもアプリから直接参照可能ということが分かりました。 (セキュリティ的に良いのだろうか...)

処理手順

以下、力技の手順です。

  1. /proc/stat からシステム全体の CPU 時間を取得する。
    • 先頭行の 2~10 カラムの合計値。
  2. new File("/proc").list() の結果から数値のみのファイル名をフィルタしてPIDの一覧とする。
  3. /proc/{PID}/cmdline からパッケージ名を取得する。
    • ps コマンドで表示されるコマンド名が入っています。
    • アプリの場合はパッケージ名でその他のプロセスはコマンド名になります。
  4. /proc/{PID}/stat から各プロセスの CPU の使用状況を取得する。
    • 13,14 カラムの合計値。

/proc/stat, /proc/{PID}/stat の具体的な内容はこんな感じです。

$ cat /proc/stat
cpu  362 17 1759 0 0 0 4 0 0 0
cpu0 362 17 1759 0 0 0 4 0 0 0
intr 7589 41 0 0 362 1 0 370 0 0 65 0 144 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
ctxt 96435
btime 1440812419
processes 1948
procs_running 3
procs_blocked 0
softirq 12783 0 5763 114 36 0 0 69 0 0 6801

$ cat /proc/931/stat
931 (jbd2/mtdblock1-) S 2 0 0 0 -1 2097216 0 0 0 0 0 2 0 0 20 0 1 0 42 0 0 18446744073709551615 0 0 0 0 0 0 0 2147483647 0 18446744071580385435 0 0 17 0 0 0 101 0 0 0 0 0 0 0 0 0 0

読み方は Linux と同じなので Man page of PROC を読めば分かります。

サンプル・ソース

具体的なサンプル・ソースはこんな感じです。

package org.kotemaru.android.testprocdir;
import ...

public class TopCommandSample {
    private static final String TAG = "TopCommandSample";

    private static class ProcInfo {
        String pid;
        String commandLine;
        long lastTime;
        long time;
    }

    private Map<String, ProcInfo> mProcInfos = new HashMap<String, ProcInfo>();
    private long mLastTotalTime;
    private Handler mHandler = new Handler(Looper.getMainLooper());

    public void refresh() {
        doRefresh();
        mHandler.postDelayed(new Runnable() {public void run() {refresh();}}, 5000L);
    }

    private void doRefresh() {
        long totalTime = getTotalTime();
        long currentTotalTime = totalTime - mLastTotalTime;
        mLastTotalTime = totalTime;

        List<ProcInfo> list = new ArrayList<ProcInfo>();
        String files[] = new File("/proc").list();
        for (String pid : files) {
            char ch = pid.charAt(0);
            if ('0' > ch || ch > '9') continue;
            ProcInfo info = getProcInfo(pid);
            long time = getTime(pid);
            info.time = time - info.lastTime;
            info.lastTime = time;
            list.add(info);
        }

        Collections.sort(list, new Comparator<ProcInfo>() {
            public int compare(ProcInfo lhs, ProcInfo rhs) {return (int) (rhs.time - lhs.time);}
        });

        Log.d(TAG, "----- ------ -------------------------------------");
        for (int i = 0; i < 10; i++) {
            ProcInfo info = list.get(i);
            Log.d(TAG, String.format("%-5s %5.2f%% %s", info.pid, ((float) info.time / currentTotalTime), info.commandLine));
        }
    }

    private ProcInfo getProcInfo(String pid) {
        ProcInfo info = mProcInfos.get(pid);
        if (info != null) return info;
        info = new ProcInfo();
        info.pid = pid;
        info.commandLine = getCommandLine(pid);
        return info;
    }

    private long getTotalTime() {
        String data = readFile("/proc/stat");
        if (data == null) return 0;
        String[] part = data.split("\\s\\s*");
        long val = 0;
        for (int i = 1; i < 10; i++) {
            val += Long.valueOf(part[i]);
        }
        return val;
    }

    private String getCommandLine(String pid) {
        String data = readFile("/proc/" + pid + "/cmdline");
        if (data == null) return "?";
        return data.trim();
    }

    private long getTime(String pid) {
        String data = readFile("/proc/" + pid + "/stat");
        if (data == null) return 0;
        String[] part = data.split("\\s\\s*");
        return Long.valueOf(part[12]) + Long.valueOf(part[13]);
    }

    private String readFile(String name) {
        try {
            FileInputStream in = new FileInputStream(name);
            try {
                byte[] buff = new byte[1024];
                int n = in.read(buff); // 手抜き
                if (n < 0) return "";
                return new String(buff, 0, n);
            } finally {
                in.close();
            }
        } catch (IOException e) {
            return null;
        }
    }
}

実行結果:

上位10件を5秒おきに繰り返しログに出力します。

D/TopCommandSample( 9781): ----- ------ -------------------------------------
D/TopCommandSample( 9781): 9781   8.68% org.kotemaru.android.testprocdir
D/TopCommandSample( 9781): 1304   7.39% system_server
D/TopCommandSample( 9781): 950    5.47% /sbin/adbd▒▒--root_seclabel=u:r:su:s0
D/TopCommandSample( 9781): 960    2.24% zygote
D/TopCommandSample( 9781): 959    2.18% zygote64
D/TopCommandSample( 9781): 1393   2.18% com.android.systemui
D/TopCommandSample( 9781): 1491   1.29% com.android.launcher
D/TopCommandSample( 9781): 943    1.16% /system/bin/surfaceflinger
D/TopCommandSample( 9781): 1      0.87% /init
D/TopCommandSample( 9781): 957    0.79% /system/bin/installd
  • サンプル・アプリ自体が高負荷になっているのでチューニングは必要そうです。

応用アプリ

タスク・キラー・アプリの機能に組み込んでみました。

まとめ

やりたい事はほぼ完全にできました。
但し、将来使えなくなる可能性は高そうなので商用アプリには使えないですね。
まあ正規のAPIでも使えなくなるんで同じかもしれないですけど。


2015/08/27

Android Studio でコンパイルの前処理

Android Studio の gradle でコンパイル前に処理を入れる方法を調べたメモ。

  • 環境:Android studio 1.3

build.gradle の拡張については意外に情報が少ないです。
公式の資料は以下です。

翻訳は有志の方がされていますが内容はすこし古いです。

やりたいことは アプリバージョン と GitのコミットID を asstes のファイルに埋め込む事です。

src/main/assets/about-template.html:
    Version:@VERSION@ (Commit:@COMMIT_ID@)
        ↓
src/main/assets/about.html:
    Version:1.0.0 (Commit:cc2e3ac008092db2cd46b80336921471e6716af4)

結論としては app/build.gradle に以下を追加することでできました。

import org.apache.tools.ant.filters.ReplaceTokens

def getCommitId() {
    try {
        def stdout = new ByteArrayOutputStream()
        exec {
            commandLine 'git', 'rev-list', 'master', '--max-count', '1'
            standardOutput = stdout
        }
        return stdout.toString().trim();
    }
    catch (Exception e) {
        println e.toString();
        return "";
    }
}
task makeAboutHtml {
    copy {
        from 'src/main/assets/about-template.html'
        into 'src/main/assets/'
        rename(/-template.html$/,".html");
        filter(ReplaceTokens, tokens: [
                VERSION: android.defaultConfig.versionName,
                COMMIT_ID: getCommitId()
        ])
    }
}
android.applicationVariants.all { variant ->
    variant.mergeAssets.dependsOn('makeAboutHtml')
}

補足:

  • git.exe に実行環境でパスが通っている必要があります。
  • applicationVariants はエディタに Cannot resolve symbol と言われますが無視して問題ありません。
    • これでだいぶ悩みました。
  • 本当は build/ に直接コピーしたいのですがまだそこまでやり方がわかりません。

2015/08/08

Android Studio で自動化テスト

Android Studio でテストコードを実行する方法をまとめておく。

  • Android Studio 1.3 が対象。

準備

古い解説サイトを見ると build.gradle の変更が必要と書いてあるが 1.3 では不要。
プロジェクト生成時に src/androidTest フォルダが作成されていてすぐにテストコードが書けるようになっている。

メニューの Run > Edit Configrations を選択して Android Tests を追加すれば準備完了。

テスト実行環境設定で変更する項目は3つ

  • テストの名前
  • Module は通常 App
  • デバイスは選択にしといたほうがよさげ

テストコード

これは Eclipse と変わらない。

単純な Activity テストのサンプル

package org.kotemaru.test1;

import android.test.ActivityUnitTestCase;
import android.util.Log;
import android.view.View;
import android.widget.TextView;

public class MainActivityTest extends ActivityUnitTestCase<MainActivity> {

    public MainActivityTest(Class<MainActivity> activityClass) {
        super(activityClass);
    }

    // ボタンクリックのテスト
    public void test_onTestClick() throws Exception {
        Log.v("test", "test_onTestClick");
        assertEquals("Hello world!", getText(R.id.textView));
        click(R.id.testButton);
        assertEquals("Test message!", getText(R.id.textView));
    }

    //-----------------------------------------------------------------
    // Util
    private void click(int rid) {
        final View view = getActivity().findViewById(rid);
        getInstrumentation().runOnMainSync(new Runnable() {
            @Override
            public void run() {
                view.performClick();
            }
        });
    }

    private CharSequence getText(int rid) {
        return ((TextView) getActivity().findViewById(rid)).getText();
    }
}

複数の Activity 遷移のサンプル

package org.kotemaru.test1;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.test.ActivityTestCase;
import android.test.ActivityUnitTestCase;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.widget.TextView;

public class MultiActivityTest extends ActivityTestCase {
    @Override
    protected void setUp() throws Exception {
        super.setUp();
        Intent intent = new Intent(Intent.ACTION_MAIN);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.setClassName("org.kotemaru.test1", MainActivity.class.getName());
        Activity act = getInstrumentation().startActivitySync(intent);
        setActivity(act);
    }

    /**
     * Main->Sub->Main の Activity 遷移のテスト
     */
    public void test_onSubActivityClick() throws Exception {
        Log.v("test", "test_onSubActivityClick");
        Instrumentation instrumentation = getInstrumentation();
        Instrumentation.ActivityMonitor monitor =
                instrumentation.addMonitor(SubActivity.class.getCanonicalName(), null, false);

        click(R.id.subActivityButton);
        Activity act = instrumentation.waitForMonitorWithTimeout(monitor, 10000);
        assertEquals(SubActivity.class, act.getClass());

        monitor = instrumentation.addMonitor(MainActivity.class.getCanonicalName(), null, false);
        Thread.sleep(1000); // TODO: wait activity resumed
        sendKeys(KeyEvent.KEYCODE_BACK);

        act = instrumentation.waitForMonitorWithTimeout(monitor, 10000);
        assertEquals(MainActivity.class, act.getClass());
    }

    //-----------------------------------------------------------------
    // Util
    private void click(int rid) {
        final View view = getActivity().findViewById(rid);
        getInstrumentation().runOnMainSync(new Runnable() {
            @Override
            public void run() {
                view.performClick();
            }
        });
    }

    private CharSequence getText(int rid) {
        return ((TextView) getActivity().findViewById(rid)).getText();
    }
}

実行

実行の種類を test に変更して実行するだけ。

デバイスの選択画面がでるのでエミュレータか実機を選択。

エミュレータで自動でテストが進む。

失敗するとスタックトレースがでるので当該の assert に飛ぶことができる。

まとめ

余計な手間がなく目的のテストコードだけ書けば良いようになっている。

操作系のユーティリティが必要だなと思ったら robotium というのが既にあるらしい。

問題は UI の仕様変更は頻繁なのでテストが追従できないことなんだよねー。


プロフィール
20年勤めた会社がリーマンショックで消滅、紆余曲折を経て現在はフリーランスのSE。 失業をきっかけにこのブログを始める。

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿【android】

リンク

アーカイブ