AndroidでTwitter4Jを使うときのノウハウまとめ(前半)

とても今更ですが、意外とこの手のまとまった記事を見たことがないので、自分なりにまとめてみます。

私がAndroidの話をするとほぼ100%の確率でこのライブラリが絡んでくるんですが、この記事では頑張って封印します。超めんどくせー。

目次

前半はこんな感じです。

  1. Twitterとの通信を行う際の注意点
  2. AndroidアプリでOAuth認証を行う
  3. Twitterから情報を取得する - PagingとCursor
  4. StatusをListViewで表示する

後半はこんな内容を予定しています。

  1. アイコンのURLをBitmapに変換しキャッシュする
  2. Timelineそのものをキャッシュする
  3. UserStreamを使用する

Twitterとの通信を行う際の注意点

AndroidアプリのUIはシングルスレッドモデルです。UIスレッドでHTTP通信を行うことは禁止されています。逆に、UIスレッド以外でUIを操作する(TextViewの中身を書き換える、Toastを表示するetc…)ことも禁止されています。

そのため、Twitterとの通信は別スレッド、通信結果を受けてUIに反映する場合はUIスレッドで行わなくてはなりません。

非同期処理を簡便化してくれるクラスとしてAsyncTaskクラスがあります。これを継承して、こんなものを作っておくといいかもしれません。

public class TwitterAsyncTask<Params, Progress, Result> extends AsyncTask<Params, Progress, TwitterResult<Result>> {

    private TwitterPreExecute mPreExecute;
    private TwitterAction<TwitterResult<Result>, Params> mAction;
    private TwitterCallback<Result> mCallback;
    private TwitterOnError mOnError;


    public TwitterAsyncTask(TwitterPreExecute preExecute
            , TwitterAction<TwitterResult<Result>, Params> action
            , TwitterCallback<Result> callback
            , TwitterOnError onError) {

        this.mPreExecute = preExecute;
        this.mAction = action;
        this.mCallback = callback;
        this.mOnError = onError;
    }

    @Override
    protected void onPreExecute() {
        mPreExecute.run();
    }

    @Override
    protected TwitterResult<Result> doInBackground(Params... param) {
        return mAction.run(param[0]);
    }

    @Override
    protected void onPostExecute(TwitterResult<Result> result) {
        if(!result.hasError()) {
            mCallback.run(result.getResult());
        } else {
            mOnError.run(result.getError());
        }
    }

    /**
     * onPreExecuteの処理を委譲する
     */
    public static interface TwitterPreExecute {
        public void run();
    }

    /**
     * doInBackgroundの処理を委譲する
     * @param <Result>
     * @param <Params>
     */
    public static interface TwitterAction<Result, Params> {
        public Result run(Params param);
    }

    /**
     * onPostExecuteの処理を委譲する
     * @param <Result>
     */
    public static interface TwitterCallback<Result> {
        public void run(Result result);
    }

    /**
     * TwitterExceptionが発生した時の処理を委譲する
     */
    public static interface TwitterOnError {
        public void run(TwitterException e);
    }

}
public class TwitterResult<Result> {
    private Result result;
    private TwitterException error;
    public Result getResult() {
        return result;
    }
    public void setResult(Result result) {
        this.result = result;
    }
    public boolean hasError() {
        return error != null;
    }
    public TwitterException getError() {
        return error;
    }
    public void setError(TwitterException error) {
        this.error = error;
    }
}

後はやりたいことを全部コンストラクタに突っ込めば安全に非同期通信+コールバックを実現出来ます。action内でチェック例外となるTwitterExceptionはTwitterResultにつっこんでしまいましょう。

別に移譲する処理はコンストラクタに書かせなくてもgetter/setterでもOKです。と言うか、そっちの方が取り回しやすいです。合成もできるしね。

ここまで全部委譲するなら匿名クラスでもいいじゃんって言うのはごもっともなんですが、結構面倒ですよ?あれ。

また、より汎用的に作ったものをこちらの[Android]AsyncTaskの各種イベントを全部クロージャでフックすると言う記事で紹介しています。

AndroidアプリでOAuth認証を行う

[2014/10/23追記]

!以下のOAuth認証に関するコードは古いです!

と言うか、なんか動作がおかしいので書き直しました

[2014/10/23追記ここまで

最初の難関です。が、Twitter4Jにはちゃんとヘルパークラスやヘルパーメソッドが用意されています。活用しましょう。

まず大前提として、Twitter Developersからアプリの登録をしないといけません。この件に関しては当記事の趣旨から外れるのでこの辺を読んで自分でやってください。

API key(Consumer Key)とAPI secret(Consumer Secret)を手に入れたら準備完了です。

まずはマニフェストにインターネットと接続出来るパーミッションを記述しましょう。

これをapplicationの直前に突っ込んでおきます。

<uses-permission android:name="android.permission.INTERNET" />

また、同じくマニフェストファイルにOAuth認証用のActivityの記述を先に済ませておきます。

<activity
    android:name=".OauthTestActivity"
    android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="test"/>
    </intent-filter>
</activity>

次にOAuth認証を行うためのActivityを作成します。当然UIスレッドでHTTP通信を行うことは出来ないので、必要な箇所はTwitterAsyncTaskで通信します。

public class OauthTestActivity extends Activity {

    private static final String CONSUMER_KEY = "";
    private static final String CONSUMER_SECRET = "";
    private static final String CALLBACK = "myapp://test";
    private OAuthAuthorization mOauth;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oauth);

        //Oauth認証用のUri作成
        ConfigurationBuilder conf = new ConfigurationBuilder()
                                        .setOAuthConsumerKey(CONSUMER_KEY)
                                        .setOAuthConsumerSecret(CONSUMER_SECRET);
        mOauth = new OAuthAuthorization(conf.build());
        mOauth.setOAuthAccessToken(null);

        new TwitterAsyncTask<Object, Void, String>(new TwitterPreExecute() {
            @Override
            public void run() {}
        }, new TwitterAction<TwitterResult<String>, Object>() {
            @Override
            public TwitterResult<String> run(Object param) {
                TwitterResult<String> r = new TwitterResult<String>();

                try {
                    r.setResult(mOauth.getOAuthRequestToken(CALLBACK).getAuthorizationURL());
                } catch (TwitterException e) {
                    r.setError(e);
                }

                return r;
            }
        }, new TwitterCallback<String>() {
            @Override
            public void run(String result) {
                //Oauth認証開始
                Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(result));
                startActivity(intent);
            }
        }, new TwitterOnError() {
            @Override
            public void run(TwitterException e) {
                e.printStackTrace();
                Toast.makeText(getApplicationContext(), "Twitterとの接続に失敗しました", Toast.LENGTH_LONG).show();
                finish();
            }
        })
        .execute(new Object());
    }

    /**
     * onNewIntent
     * callbackで返ってきたuriからAccessTokenを取得する
     */
    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        Uri uri = intent.getData();
        if (uri != null && uri.toString().startsWith(CALLBACK)) {

            new TwitterAsyncTask<Uri, Void, AccessToken>(new TwitterPreExecute() {
                @Override
                public void run() { }
            }, new TwitterAction<TwitterResult<AccessToken>, Uri>() {
                @Override
                public TwitterResult<AccessToken> run(Uri param) {
                    TwitterResult<AccessToken> r = new TwitterResult<AccessToken>();
                    //AccessTokenの取得
                    String verifier = param.getQueryParameter("oauth_verifier");
                    try {
                        r.setResult(mOauth.getOAuthAccessToken(verifier));
                    } catch (TwitterException e) {
                        r.setError(e);
                    }

                    return r;
                }
            }, new TwitterCallback<AccessToken>() {
                @Override
                public void run(AccessToken result) {
                    // TODO Oauth認証に成功した後の処理
                }
            }, new TwitterOnError() {
                @Override
                public void run(TwitterException e) {
                    e.printStackTrace();
                    Toast.makeText(getApplicationContext(), "Oauth認証に失敗しました", Toast.LENGTH_LONG).show();
                    finish();
                }
            }).execute(uri);
        }
    }
}

これだけ読んでも割とわけがわからないと思います。

そもそもOAuth認証の仕組みをざっくりと説明するとこんな感じです。

ConsumerKeyとConsumerSecretとCallback用URIをクエリパラメータとして持った認証用URIにアクセス
↓
ユーザが認証に同意するとoauth_verifierパラメータとしてAccessTokenを持ったURIをCallback URIに返却
↓
以後、ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecretの正しい組み合わせを持っていればそれは認証済みアカウントだと認識

Callback用URIマニフェストファイルとActivityに記述しました。Twitterでの認証後、CallbackとしてこのOauthTestActivityにIntentを送って欲しいからです。この二つのうちどちらか一方のみを書き換えると上手くいかなくなるので気をつけてください。

ConsumerKeyとConsumerSecretはアプリ登録時に貰えます。AccessTokenとAccessTokenSecretは、Twitter4JのAccessToken#getToken()とAccessToken#getTokenSecret()で取得することが出来ます。この4つを何らかの形で保存しておけば、以後OAuth認証なしでTwitterAPIにアクセス出来るわけです。

保持する方法はSharedPreferencesを使用するか、SQLiteを使ったDB処理のどちらかになると思います。もしアカウントが一つでいいならSharedPreferencesの方がいいですし、複数アカウントに対応させたいのであればDBの方がいいです。この辺は事前にちゃんと設計しておかないと後で面倒ですよ。

私はこんなヘルパークラスを作ってあります。

/**
 * 各種Twitterに関するインスタンスを取得するヘルパークラス
 */
public class TwitterInstance {

    public static String CONSUMER_KEY = "";
    public static String CONSUMER_SECRET = "";

    /**
     * Twitterのインスタンス
     * @param context
     * @return
     */
    public static Twitter getTwitter(Context context) {
        Account account = AccountDao.getCurrentAccount(context);
        ConfigurationBuilder conf  = new ConfigurationBuilder()
                                        .setOAuthAccessTokenSecret(account.getAccessSecret())
                                        .setOAuthConsumerKey(CONSUMER_KEY)
                                        .setOAuthConsumerSecret(CONSUMER_SECRET)
                                        .setOAuthAccessToken(account.getAccessToken())
                                        .setUseSSL(true);
        return new TwitterFactory(conf.build()).getInstance();
    }
}

Twitterから情報を取得する - PagingとCursor

さて、twitter4j#Twitterが作れるようになりました。Twitterから情報を取得する場合は基本的にすべてこのTwitterクラスを介して行います。

が、誰それのUserTimelineが欲しいとか、ここからここまでのn件が欲しいとかそう言った情報も当然必要になってきます。

そのことを念頭に置いて設計/開発しないと、後で泣きを見ます。

まずはPagingクラスの説明をします。中のメンバやコンストラクタを見ればなんとなくわかると思いますが、データを取得する範囲を設定出来ます。

ただまぁ、ぶっちゃけ全部コンストラクタでやるの結構大変なんですよ。なのでBuilderを作ります。

/**
 * Pagingを作成するためのBuilder
 */
public class PagingBuilder {
    private int count = 50;
    private long sinceId = 0;
    private long maxId = 0;
    private int page = 0;

    /**
     * 取得件数
     * @param count
     * @return
     */
    public PagingBuilder setCount(int count) {
        this.count = count;
        return this;
    }

    /**
     * 取得する最も古いID
     * @param sinceId
     * @return
     */
    public PagingBuilder setSinceId(long sinceId) {
        this.sinceId = sinceId;
        return this;
    }

    /**
     * 取得する最も新しいID
     * @param maxId
     * @return
     */
    public PagingBuilder setMaxId(long maxId) {
        this.maxId = maxId;
        return this;
    }

    /**
     * ページ数
     * @param page
     * @return
     */
    public PagingBuilder setPage(int page) {
        this.page = page;
        return this;
    }

    /**
     * Paging作成
     * @return Paging
     */
    public Paging build() {
        Paging paging = new Paging();

        paging.setCount(count);
        if(sinceId != 0) paging.setSinceId(sinceId);
        if(maxId != 0) paging.setMaxId(maxId);
        if(page != 0) paging.setPage(page);

        return paging;
    }

}

sinceIdとmaxIdにはstatusIdを設定します。statusIdとはそれぞれのTweetに割り振られているIDです。https://twitter.com/<ScreenName>/status/<StatusId>とすることでその発言だけを見ることもできます。Twitter4JではStatus#getIdで取得することが出来ます。

また、案外忘れがちなんですが、取得できる範囲はmaxId≧取得するデータ≧sinceIdとなっています。maxId/sinceIdも取得するデータに含まれてしまいます。外したい場合はIDから-1 or +1してやりましょう。

次にCursorです。これはクラスとしてあるわけではなく、longで表現されています。主にFollow/Follower関係の一覧を取得する時に使われます。初期値は-1です。(わかりにくいので強調しておきました)

こいつが中々の曲者で、綺麗にコードを書くには結構な手間がかかります。とても雑に書くとこんな感じです。

Twitter twitter = TwitterInstance.getTwitter(getApplicationContext());
PagableResponseList<User> userList = null;
long cursor = -1;

do {
    try {
        userList = twitter.getFollowersList(userId, cursor);
    } catch(TwitterException e) {
        e.printStackTrace();
        break;
    }
    
    cursor = userList.hasNext() ? userList.getNextCursor()
                                : -2;
} while(cursor != -2)

一気に全部取ってくる場合はこれでいいんですが、ちょこちょこ取ってきたい場合はcursorをメンバ変数などで持っておかないといけません。ちなみにこれを無理矢理Iterableにしてみた[Twitter4J]CursorSupportをIterableにすると言う記事もあります。

StatusをListViewで表示する

ArrayAdapterを継承したクラスの自作が必須となります。初心者は必ずと言っていいほどここで詰まります。

と言うか、Androidアプリの作成は常にListViewとAdapterとの戦いです。ListViewとAdapterの関係がわかれば他は大体何とかなります。

ListViewはあくまで器であり、器のレイアウト(全体の大きさとか)を決めることは出来ます。が、中身のレイアウトに関しては知りません。表示している中身はどんなクラスで、どんなレイアウトで、どんなデータで、何を表示しているかまでは情報として所持していません。

その情報を持っているのがAdapterです。何らかのコレクションもしくはカーソルを渡し、ListViewの中で表示するViewを作ってもらいます。

それではまずListViewを持った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="match_parent"
    android:orientation="vertical" >
    <ListView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:id="@android:id/list" />
</LinearLayout>

ここで最も大事なのはandroid:idです。

このxmlを表示するActivityがListActivityを継承している場合はandroid:id=”@android:id/list”、それ以外の場合はandroid:id=”@+id/(自分で決めた名前)”にします。

ListActivityは必ず一個はListViewを持っているはずと決め込んでいます。その必ず持っているListViewのIDは@android:id/listであるはず、とも決め込んでいます。

ListActivity#getListView()でListViewを取得できるのはこのおかげです。

もちろん@+idを使ってActivity#findViewByIdをしても構いません。ListActivity以外では、この方法しかないです。(xmlではなくコードで1から生成する方法を除く)

またこの特性はListFragmentでも同様です。

では次にadapterのxmlを作成してみます。面倒なので、とりあえずTweetを表示させるだけ。

<?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="match_parent"
    android:orientation="vertical">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/txvTweet" />
</LinearLayout>

下準備が出来たので実際にAdapterを作成してみましょう。

public class TestAdapter extends ArrayAdapter<Status>{

    private LayoutInflater _layoutInflater;

    public TestAdapter(Context context, List<Status> list){
        super(context, 0, list);
        _layoutInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View v = _layoutInflater.inflate(R.layout.adapter_test, null);
        TextView txvTweet = (TextView)v.findViewById(R.id.txvTweet);

        Status item = getItem(position);

        txvTweet.setText(item.getText());

        return v;
    }
}

これが最小単位です。コンストラクタLayoutInflaterを作成し、getViewでinflateしたViewを返します。

LayoutInflater#inflate(int resource, ViewGroup root)を呼び出すことでそのリソース(xml)と紐付いたViewを作成することが出来ます。その上でfindViewByIdメソッドで参照したいViewを取得し、setTextしているわけです。

また、このAdapterに渡したArrayList<Status> listの中身はgetItem(int position)で取得出来ます。逆に言うとこの方法でしか取得出来ません。もちろんコンストラクタでメンバ変数に渡すことも出来ます。全アイテムを取得するメソッドはありませんが、何らかのメソッドをAdapter内に作成するか、Iterableを継承したクラスを作ってしまうのもアリだと思います。って言うか、私は作りました。

あくまで最小単位なので今回は説明を省きますが、普通はgetView内でViewHolderパターンと呼ばれるListView高速化のためのパターンを用います。その辺は[Android]ListViewのレイアウトを動的に切り替える際の問題点と言う記事で説明しています。

次にActivity側を作成します。先ほど紹介したTwitterAsyncTaskをついでに使ってみます。

public class TestActivity extends ListActivity {

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

        final ProgressDialog prog = new ProgressDialog(this);

        new TwitterAsyncTask<Context, Void, ResponseList<Status>>(new TwitterPreExecute() {
            @Override
            public void run() {
                prog.setTitle("test");
                prog.setMessage("取得中");
                prog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
                prog.setCancelable(false);
                prog.show();
            }
        }, new TwitterAction<TwitterResult<ResponseList<Status>>, Context>() {
            @Override
            public TwitterResult<ResponseList<Status>> run(Context param) {
                TwitterResult<ResponseList<Status>> r = new TwitterResult<ResponseList<Status>>();
                Twitter twitter = TwitterInstance.getTwitter(param);

                try {
                    r.setResult(twitter.getHomeTimeline());
                } catch (TwitterException e) {
                    r.setError(e);
                }

                return r;
            }
        }, new TwitterCallback<ResponseList<Status>>() {
            @Override
            public void run(ResponseList<Status> result) {
                prog.dismiss();
                ListView listView = getListView();
                listView.setAdapter(new TestAdapter(getApplicationContext(), result));
            }
        }, new TwitterOnError() {
            @Override
            public void run(TwitterException e) {
                prog.dismiss();
                Toast.makeText(getApplicationContext(), "エラーが発生しました。", Toast.LENGTH_SHORT).show();
            }
        }).execute(getApplicationContext());
    }
}

ListView#setAdapterを呼び出し、先ほど作ったTestAdapterをセットすることでListViewの中身が自動でセットされます。

ListViewのFooterに「More」みたいなViewを作り、そこをクリックしたら次のn件を取得する、みたいなことがしたかったら、[Android]ListViewのHeader/FooterのViewに直接イベントを設定すると言う記事を参考にしてみてください。

前半のまとめ

後半は…多分…来週?もしくは火曜日に。

[20140219追記]やっと後編書きました。