2012年12月30日

【Android】リアルタイム通信へ挑戦



Android Advent Calendar 2012に参加したdommyです。
2012年もあと2日、今年も山あり谷ありの1年でした。

12月30日はすでにAdvent Calendarに載るべき日付でも無いですが、
Androidに関する記事ならなんでも良いとの事だったので、
リアルタイム通信関連の記事を書いてみたいと思います。


前提として、Androidでの通信といえばHTTPになります。
これはクライアントからのリクエストに対して、サーバがレスポンスを返すという形で、
サーバからリクエストする事は出来ないので、
リアルタイム通信とは違います。

socket通信

AndroidはNDKを使えばC言語でも実装可能なので、
サーバ側にレスポンスを返す部分を用意して、
Cでサーバへ接続する部分も掛けるが、
ルート的にもソース的にも面倒臭い。
レイヤーは高いが、もっと簡単な方法があるはず。

WebSocket

HTML5の技術になるのですが、
Androidでより簡単にリアルタイム通信を実装するには、
WebSocketが楽かなぁと実装プロトコルに選びました。

PCでのMMORPG等はUDPが主流なので、
UDPの実装出来ないWebSocketはどうなの?
との話もありますが、
まぁ、TCP上でソケット通信するならWebSocketが
ライブラリが充実していて実装が楽です。

サーバ、アプリの両方で実装する必要があるので、
まずはサーバを準備しました。

node.js - express - socket.io

サーバの実装方法です。

CentOS 6.0をメインで使っているので、
node.js - express - socket.ioで実装しました。
Web側のインターフェースも用意し、
アプリからとウェブからのsocket.ioのメソッドを準備しました。

このあたりはAndroidのプログラムには関係ないので、
そのうち解説します。

分散処理はこの記事を参考にしました。
node.js アプリの負荷分散構成を考える

Android側のライブラリ

最初はjWebSocketを選びました。

はい、ここで間違っていました。
socket.ioとWebSocketは厳密には違います。
socket.ioはWebSocketのラッパーなので、
socket.ioと通信する場合は、
クライアントもsocket.ioじゃないとダメです。

と、いう訳でライブラリの選び直し。

socket.io-java-clientを選びました。
早速jarファイルの作成です。

$ git clone https://github.com/Gottox/socket.io-java-client.git
$ cd socket.io-java-client
$ ant jar

jar/socketio.jarが作成されました。

Androidの実装

socket.ioのCallbackはもうライブラリで指定されており、
implements IOCallbackとすれば大体の作業は終わってしまいます。

Service化

socket.ioでは、Acknowledgementが10秒に1回ぐらいあったので、
メインスレッドで実装させるには抵抗がありました。
かと言って、Threadを利用しても、Acitivityの遷移があったらどうする?
となってしまいますので、サービスを作って、そちらに任せます。
ついでに、ライブラリ化出来ればいいなぁと考えていました。

とりあえずはService化に着手。

/**
 * Service for Socket.IO connection
 *
 * @author morodomi
 */
class SMService extends Service implements IOCallback {
    protected static final String MESSAGE_INTENT  = "socket messaging message intent";
    protected static final String ERROR_INTENT    = "socket messaging error intent";
    protected static final int    INTENT_ACTIVITY = 38427;
    private static SocketIO       socket;

    /**
     * Service Binder
     * 
     * @author morodomi
     * 
     */
    class ServiceBinder extends Binder {
        SMService getService() {
            return SMService.this;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onCreate() {
        super.onCreate();
    }

    /**
     * Trying to connect with server.
* {@inheritDoc}
     */
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (socket == null) {
            try {
                socket = new SocketIO(SMConfig.URL);
            } catch (MalformedURLException e) {
                e.printStackTrace();
            }
        }
        if (!socket.isConnected()) {
            try {
                socket.connect(this);
            } catch (Exception e) {
                Log.e(SMConfig.TAG, e.getMessage(), e);
            }
        }
        // TODO: create a task which connecting if disconnected;

        return START_STICKY_COMPATIBILITY;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onDestroy() {
        socket.disconnect();
        super.onDestroy();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public IBinder onBind(Intent intent) {
        return new ServiceBinder();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onRebind(Intent intent) {
        super.onRebind(intent);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean onUnbind(Intent intent) {
        return super.onUnbind(intent);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void on(String event, IOAcknowledge ack, Object... args) {
        if (SMConfig.DEBUG) {
            Log.d(SMConfig.TAG, "SMService.on(" + event + ", " + (String) args[0] + ")");
        }
        Intent intent = new Intent();
        intent.putExtra("event", event);
        intent.putExtra("message", (String) args[0]);
        intent.setAction(MESSAGE_INTENT);
        sendBroadcast(intent);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onConnect() {
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onDisconnect() {
        if (SMConfig.DEBUG) {
            Log.d(SMConfig.TAG, "SMService.onDisconnect()");
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onError(SocketIOException error) {
        if (SMConfig.DEBUG) {
            Log.d(SMConfig.TAG, error.getMessage(), error);
        }
        Intent intent = new Intent();
        intent.setAction(ERROR_INTENT);
        sendBroadcast(intent);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onMessage(String data, IOAcknowledge ack) {
        if (SMConfig.DEBUG) {
            Log.d(SMConfig.TAG, "SMService.onMessage(" + data + ")");
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onMessage(JSONObject json, IOAcknowledge ack) {
        if (SMConfig.DEBUG) {
            try {
                Log.d(SMConfig.TAG, "SMService.onMessage(" + json.toString(2) + ")");
            } catch (JSONException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * Send string message to the server;
     * 
     * @param event
     *            key for the message
     * @param message
     *            value of the event
     */
    protected void send(String event, String message) {
        socket.emit(event, message);
    }

    /**
     * Sending json object to the server
     * 
     * @param event
     * @param json
     */
    protected void send(String event, JSONObject json) {
        socket.emit(event, json.toString());
    }
}

と、こんな感じで書いてみました。
基本的にサーバとやりとりするのはこのクラスですが、
ライブラリ化する為に、このサービスはpublicにしてありません。
ServiceとActivity間をやりとりするServiceConnectionを作成します。

/**
 * client manager for server push
 * 
 * @author morodomi
 */
public class SMManager implements ServiceConnection {
    private static Context    mContext;
    private static SMService  mService;
    private BroadcastReceiver mReceiver;
    private SMManagerListener listener;

    /**
     * Constructor for SMManager
     * 
     * @param context
     *            Application Context
     */
    private SMManager(Context context) {
        mContext = context;
        mReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                /**
                 * message from service
                 */
                if (SMConfig.DEBUG) {
                    Log.d(SMConfig.TAG, "onReceive:" + intent.getAction());
                }
                if (intent.getAction() != null) {
                    if (intent.getAction().equals(SMService.MESSAGE_INTENT)) {
                        if (listener != null) {
                            listener.onMessage(intent.getStringExtra("event"), intent.getStringExtra("message"));
                        }
                    }
                }
            }
        };
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onServiceConnected(ComponentName componentName, IBinder service) {
        if (SMConfig.DEBUG) {
            Log.d(SMConfig.TAG, "WebSocketManager.onServiceConnect");
        }
        mService = ((SMService.ServiceBinder) service).getService();
        if (listener != null) {
            listener.onConnected();
        }
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void onServiceDisconnected(ComponentName componentName) {
        mService = null;
        if (listener != null) {
            listener.onDisconnected();
        }
    }

    /**
     * SMManagerの初期化
     * 
     * @param context
     * @param reciever
     * @return 
     */
    public static SMManager init(Context context) {
        return new SMManager(context);
    }

    /**
     * Setter for SMManagerListener
     * 
     * @param listener
     */
    public void setListener(SMManagerListener listener) {
        this.listener = listener;
    }

    /**
     * bind service to application
     */
    public void connect() {
        Intent intent = new Intent(mContext, SMService.class);
        mContext.startService(intent);
        if (mReceiver != null) {
            IntentFilter filter = new IntentFilter(SMService.MESSAGE_INTENT);
            mContext.registerReceiver(mReceiver, filter);
        }
        mContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
    }

    /**
     * call when the activity destroy
     */
    public void disconnect() {
        mContext.unbindService(this);
        mContext.unregisterReceiver(mReceiver);
        Intent it = new Intent();
        it.putExtra("data", "disconnect");
        mReceiver.onReceive(mContext, it);
        // mService.stopSelf();
    }

    public void send(String event, String message) {
        if (mService != null) {
            mService.send(event, message);
        }
    }

    /**
     * emit message on server
     * 
     * @param event
     * @param json
     */
    public void send(String event, JSONObject json) {
        if (mService != null) {
            mService.send(event, json.toString());
        }
    }
}

一旦、これで動きました。
何かまだ動きがぎこちないので改良が必要です。

最後にServiceからのコールバックを受け取るListenerインターフェースを作成します。

/**
 * Event Listener for SMManager
 * 
 * @author morodomi
 */
public interface SMManagerListener {

    /** 
     * called when the SMService is connected to the activity.
     */
    public void onConnected();

    /** 
     * called when the SMService is disconnected from the activity.
     */
    public void onDisconnected();

    /** 
     * called when the server pushes event
     * 
     * @param event
     *            event key for push
     * @param message
     *            data for the event
     */
    public void onMessage(String event, String message);
}

あと、SMConfig.も書いてますが、4行ぐらいのもんです。

4つのクラスをjarファイルにまとめればライブラリの完成です。

最後に


ライブラリの実装方法ですが、このSocketMessagingライブラリと、socket.io.jarをlibsへ入れます。

で、Activityで、下記のように実装

public class MainActivity extends Activity implements SMManagerListener {
    private SMManager      manager;

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

        /** Web Socket */
        manager = SMManager.init(this);
        manager.setListener(this);
        manager.connect();
    }

    @Override
    protected void onDestroy() {
        manager.disconnect();
        super.onDestroy();
    }

    @Override
    public void onConnected() {
        // 接続完了
        manager.send("msg", "is_connected");
    }

    @Override
    public void onDisconnected() {
    }

    @Override
    public void onMessage(String event, String message) {
        // メッセージ処理
    }
}

こんな感じで動かしました。

あとは、受け取ったメッセージをHandler使ってメインスレで処理するとか、
実はいろいろと問題があったりします。

本当は12月30日までには完了させるつもりだったんですが、
なかなか時間が無く、中途半端な感じになってしまいました。

メッセージのやりとりをチャネリングして、1:1にするか、
ブロードキャストして、1:多にするかはサーバ側で調整可能です。
1:1にするのであれば、アプリ側で相手のID的なものを保持する必要があります。


今年のブログ記事はこれで最後になります。
来年も頑張ってアプリ開発しましょう!

それでは、良いお年を!!!