日々是好日

プログラミングについてのあれこれ、ムダ知識など

Twitterのフォロー一覧を取得してテストする

やったこと。

  • Twitter4Jで自身のフォロー一覧を取得

実装の手順

  1. TwitterUserUseCaseインターフェースを定義
    suspend fun getFollowUsers(...) : List<UserEntity>だけ定義
  2. TwitterUserUseCaseを実装したTwitterUserInteractorクラスを作成
    この時点では、getFollowUsersの返りは単にreturn listOf()とかでいい
  3. TwitterUserInteractorTestを作成
    class TwitterUserInteractorにカーソルあわせてAlt+Enterとかでtest配下にテストクラス生成。test配下に手動で作ってもいい。

TwitterUserUseCase

まずはTwitterUserUseCaseインターフェースを定義する。

interface TwitterUserUseCase {
  suspend fun getFollowUsers(userId: Long, listener: OnNotifyProgressListener? = null): List<UserEntity>
  
  // 進捗通知用のリスナー
  interface OnNotifyProgressListener {
    suspend fun onNotifyFollowUsersCount(count: Int)
    suspend fun onNotifyProgress(arg: Int)
  }
}

data class UserEntity {
  // Twitter4JのUserをラップするだけのデータクラス
}

OnNotifyProgressListenerは無くても全然構わない。 ユーザを取得したときにプログレスバーとかに反映させる用。

TwitterAPIとの通信で別スレッドで実行する必要があるため、suspend修飾子を付ける。

TwitterUserInteractor

次にTwitterUserUseCaseを実装したInteractorを作成する。 この段階では、ただ空のリストを返すだけの実装で十分。

class TwitterUserInteractor(
  private val context: Context,
  private val twitter: Twitter
  ) : TwitterUserUseCase {
  
  // UseCase で定義したメソッド
  override suspend fun getFollowUsers(userId: Long, listener: ...): List<UserEntity> {

    // とりあえず空のリストを返すだけの実装
    return listOf()
  }
}

次にテストを作成する。

TwitterUserInteractorTest

テストを書く! RobolectricとAssertJ使っています。

// context が必要なので RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class TwitterUserInteractorTest : CoroutineScope {
  override val coroutineContext: CoroutineContext
    get() = Dispatchers.Default + Job()

  // InstrumentationRegistryのimportがうまくいかない場合は、
  // キャッシュ削除して再起動(File→Invalidate Caches Restart)するといいかも
  private val context = InstrumentationRegistry.getInstrumentation().context
  private var accessToken: AccessToken? = null
  private lateinit var twitter: Twitter
  private lateinit var useCase: TwitterUserUseCase
  private val userId = 3243831438 // kcpoipoiのユーザID
  
  // Coroutine でバックグラウンド処理をするため用意
  private lateinit var latch: CountDownLatch

  @Before
  fun setUp() {
    // Twitterインスタンスの初期化など
    // なんかもうちょい良い書き方がある気がする
    accessToken = if (BuildConfig.IS_DEBUG) {
      AccessToken("token", "tokenSecret")
    } else {
      null
    }
    twitter = TwitterUtils.getTwitter(context, accessToken)
    useCase = TwitterUserInteractor(context, twitter)
  }

  @Test
  fun getFollowUsers() {
    latch = CountDownLatch(1)

    // Coroutine起動
    launch {
      // ユーザ取得の開始、リスナーのセット
      val users = useCase.getFollowUsers(userId, object : TwitterUserUseCase.OnNotifyProgressListener {
        override suspend fun onNotifyFollowUsersCount(count: Int) {
          if (BuildConfig.IS_DEBUG) {
            println(LogDecorator.decoCyan("onNotifyFollowUsersCount = $count"))
          }
          
          // ユーザ総数が合っているか検査
          assertThat(count)
            .isEqualTo(USERS)
        }

        override suspend fun onNotifyProgress(arg: Int) {
          if (BuildConfig.IS_DEBUG) {
            println(LogDecorator.decoGreen("onNotifyProgress = $arg"))
          }
          
          // 進捗がゼロでないか検査
          assertThat(arg)
            .isNotZero()
        }
      })

      // ユーザリストの件数を検査
      assertThat(users)
        .isNotEmpty
        .hasSize(USERS)
      latch.countDown()
    }

    latch.await()
  }

  companion object {
    // R1年12月29日時点でのわいのフォロー数
    private const val USERS = 2101
  }
}

この雑な検査を通過するようにTwitterUserInteractorを実装していく。

TwitterUserInteractor最終形

最終形には次のようになる。 チョットナガイ。

class TwitterUserInteractor(
  private val context: Context,
  private val twitter: Twitter
  ) : TwitterUserUseCase {
  
  // UseCase で定義したメソッド
  override suspend fun getFollowUsers(userId: Long, listener: ...): List<UserEntity> {
    // ページング処理用の cursor は必ず初期値を -1L にする
    val cursor = -1L
    
    // userId をレートリミットに達するまで取得。再帰的に処理
    val idsList = getFriendsIDs(userId, cursor, mutableListOf())
    
    // ユーザ総数を通知
    listener?.onNotifyFollowUsersCount(idsList.size)

    // ユーザがゼロなら空のリストを返す
    if (idsList.isEmpty()) {
      return listOf()
    }

    // userId のリストから User オブジェクトのリストを取得。再帰的に処理
    return getUsers(idsList, 0, mutableListOf(), listener)
  }

  private suspend fun getFriendsIDs(
    userId: Long,
    cursor: Long,
    idsList: MutableList<Long>
    ): List<Long> {
    
    val ids = twitter.getFriendsIDs(userId, cursor)
    if (ids != null) {
      idsList.addAll(ids.iDs.toList())
      val rateLimit = ids.rateLimitStatus
      return when {
        // レートリミットに達したらその時点で返す
        rateLimit.remaining <= 0 -> {
          idsList
        }
        // cursorをシフトして再帰的に呼び出し
        ids.hasNext() -> {
          getFriendsIDs(userId, ids.nextCursor, idsList)
        }
        // ids.hasNext == false で返す
        else -> {
          idsList
        }
      }
    } else {
      return idsList
    }
  }

  @Suppress("ConstantConditionIf")
  private suspend fun getUsers(
    ids: List<Long>,
    startIndex: Int, 
    users: MutableList<UserEntity>, 
    listener: TwitterUserUseCase.OnNotifyProgressListener?
    ): List<UserEntity> {

    // Userオブジェクトを取得する lookupUsers は一度に100件までしか処理できない
    val endIndex = if (startIndex + 99 > ids.lastIndex) {
      ids.lastIndex
    } else {
      startIndex + 99
    }

    // ユーザIDリストを100件スライス
    val idsSlice = ids.slice(startIndex..endIndex)
    
    // Userオブジェクトを取得
    val userResponseList = twitter.lookupUsers(*idsSlice.toLongArray())
    
    // 生のUserオブジェクトを扱いやすいようUserEntityにmap
    val userEntities = userResponseList
      .map { user ->
        val name = Normalizer.normalize(user.name, Normalizer.Form.NFKC)
        if (BuildConfig.IS_DEBUG) {
          println(name)
        }
        UserEntity(
          0,
          user.id,
          name,
          user.screenName,
          user.profileImageURL,
          user.description,
          "")
      }

    // リストに追加
    users.addAll(userEntities)

    // 進捗を通知
    listener?.onNotifyProgress(users.size)

    val rateLimit = userResponseList.rateLimitStatus

    return when {
      rateLimit.remaining <= 0 || endIndex == ids.lastIndex -> {
        // レートリミットに達するか、全件読み終えたら return
        users
      }
      else -> {
        // 再帰的に呼び出し
        getUsers(ids, endIndex + 1, users, listener)
      }
    }
  }
}

再帰呼び出しチョット頑張った。

所感

  • Coroutineで外部との通信処理がものすごい簡潔に(同期的に)書ける。
  • 再帰呼び出しにすることでページング処理も楽に。

Coroutine中断時の処理とかも実装しなければ。