はじめに
大きくまとまったPRは、レビューが進みにくく、指摘が錯綜して結論が出るまでに時間がかかりがちです。そうしているうちにベースブランチとのコンフリクトが増え、手戻りや修正が積み重なり、リリース全体が遅れてしまうことも少なくありません。
一方で、目的ごとに小さく整理されたPRは「この変更では何を見るか」が明確で、レビュアも短時間で集中して確認できます。結果として合意形成が早まり、後工程の不確実性も減ります。
この記事では、Android開発の現場でPRを小さく切るための基本として「基盤を先に切る」「責務を混ぜない」という2つの観点を取り上げます。
1. 基盤を先に切る
原則
- 完成形を出さず、小粒なベースだけをPRにする
- 例:UseCaseの契約1つ、UiStateの定義1つ、Activityの殻だけなど
- 目的は“合意形成”であり、動作実装ではない
- 命名・戻り値・状態の網羅といった取り決めを先に固める
- 実装・UI・結線は後続PRに回す
- 役割を分けて積み上げやすくするため、基盤PRは呼び水として小さく出す
効果
- レビュー観点が一意になる
- 命名・戻り値・状態の網羅性・公開APIの形だけを短時間で確認できる
- 合意形成が早まる
- 実装に入る前の段階で取り決めを固めるため、後工程の手戻りが減る
- リスク分離ができる
- 土台/実装/UIを独立にロールバックでき、影響範囲を最小化できる
- 並行作業に強くなる
- 基盤を早期に共有することで、方向性のズレや差し戻しを減らし、小さな後続PRを切りやすくする
基盤PR サンプルコード
1) UseCase の契約(1ファイル)
// 呼び出し側が合意すべき入口だけ定義
interface GetUserUseCase {
suspend operator fun invoke(id: String): Result<User>
}
2) 画面の語彙(UiState / UiEvent)(1ファイル)
// 画面が扱う最小の語彙だけ
sealed interface UserUiState {
data object Loading : UserUiState
data class Content(val user: User) : UserUiState
data class Error(val message: String) : UserUiState
}
sealed interface UserEvent {
data object Retry : UserEvent
}
3) ドメイン最小モデル(1ファイル)
// まずは必要最小限の構造だけ
data class User(val id: String, val name: String)
4) ViewModelの公開APIだけ(中身は後続)(1ファイル)
// 公開面の“約束”だけを固める(実装は後続PR)
class UserViewModel(
private val getUser: GetUserUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState
fun load(id: String) {}
fun onEvent(event: UserEvent) {}
}
5) Activityの殻(結線の最小形)(1ファイル)
class UserActivity : ComponentActivity() {
// VM の入手方法は後続PRで(HiltでもFactoryでもOK)
private val viewModel: UserViewModel by lazy { TODO("Provide later") }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { /* 後続PRで Composable を追加。今は殻だけ */ }
}
}
ここまでで「この画面はGetUserUseCaseを呼び、UserUiStateを流す」という土台の合意ができます。実装は一切入れません。
2. 責務を混ぜない
原則
基盤が整ったら、次は実際に処理やUIを積んでいきます。
そのときに大事なのが「責務を混ぜない」こと。
UI(Compose)とロジック(ViewModel/UseCase)、あるいはリファクタリングと新機能追加のように、目的が異なる変更は同じPRに含めないようにします。
効果
- レビュー観点が明確になる
- UIだけなら見た目・レイアウトに集中できる
- ロジックだけなら状態遷移や非同期処理に集中できる
- 指摘が整理されやすい
- 変更の意図が一貫しているため、不要な指摘や見落としが減る
- リスクを分離できる
- UIだけ、リファクタだけ、といった単位で安全にリリース判断やロールバックが可能になる
代表例
- UI専用PR:Composableやレイアウトの改善だけを含める
- ロジック専用PR:状態管理や非同期処理など、ビジネスロジックの変更に限定する
サンプルコード
UI専用PR(見た目と結線だけ追加)
class UserActivity : ComponentActivity() {
private val viewModel: UserViewModel by lazy { TODO("Provide later") }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { UserScreen(viewModel) }
}
}
@Composable
fun UserScreen(viewModel: UserViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UserUiState.Loading -> Text("Loading…")
is UserUiState.Content -> {
val content = uiState as UserUiState.Content
Column {
Text("Hello ${content.user.name}")
Button(onClick = { viewModel.refresh(content.user.id) }) {
Text("Reload")
}
}
}
is UserUiState.Error -> {
val error = uiState as UserUiState.Error
Column {
Text("Error: ${error.message}")
Button(onClick = { viewModel.onEvent(UserEvent.Retry) }) {
Text("Retry")
}
}
}
}
}
ここではUIの見た目・文言・イベント結線だけレビュー対象。ViewModelの実装は空のまま。
ロジック専用PR (状態遷移・非同期処理のみ)
class UserViewModel(
private val getUser: GetUserUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState
private var lastId: String? = null
fun refresh(id: String) {
lastId = id
viewModelScope.launch {
_uiState.value = UserUiState.Loading
val result = getUser(id)
_uiState.value = result.fold(
onSuccess = { user -> UserUiState.Content(user) },
onFailure = { throwable -> UserUiState.Error(throwable.message ?: "読み込みに失敗しました") }
)
}
}
fun onEvent(event: UserEvent) {
when (event) {
is UserEvent.Retry -> lastId?.let { refresh(it) }
}
}
}
ここでは ViewModelの状態遷移と非同期処理の正しさだけレビュー対象。
UIは一切触らないので、UI専用PRと並行してレビューができる。
レビューしやすいPRの基準
レビューのしやすさは「量」だけでなく「意味のまとまり」によって決まります。
以下の基準を基本の目安とすると、PRはぐっと読みやすくなります。
- コード差分が 500行以下
- 理想としては 50 行程度、200〜400行以下が推奨されるとの調査結果もあり、レビューやマージ速度・リビジョンの質が向上します
https://www.gustavwengel.dk/pr-sizes
- 理想としては 50 行程度、200〜400行以下が推奨されるとの調査結果もあり、レビューやマージ速度・リビジョンの質が向上します
- 変更ファイル数(リソースを除く)が 5つ以下
- ファイル数が多くなると、レビュアの認知負荷が急激に上昇します(最大8〜10ファイル程度でレビュー効率が低下する傾向)
https://graphite.dev/blog/your-github-pr-workflow-is-slow
- ファイル数が多くなると、レビュアの認知負荷が急激に上昇します(最大8〜10ファイル程度でレビュー効率が低下する傾向)
- 基盤を先に切っている
- Repository の interface、UiState・UiEvent、最小のモデル定義など、後続の実装やUIに積みやすい“小粒のベース”だけを独立PRにする
- 責務を混ぜていない
- UIとロジック、リファクタリングと機能追加など異なる責務は分ける
関連記事
基盤を先に切る
- Graphite.dev: Pull Request Best Practices
“Initial PRs establish core infrastructure, while subsequent ones build on those changes.”
https://graphite.dev/blog/pull-request-best-practices
→ PRを積み上げるとき、まず基盤となるPRを出してから後続を重ねる「stacked PR」の考え方。この記事でいう「基盤を先に切る」に相当します。
責務を混ぜない
- Artsy Engineering Blog: Strategies for Small, Focused Pull Requests
https://artsy.github.io/blog/2021/03/09/strategies-for-small-focused-pull-requests/
→ 「小さく」「焦点を絞る」PRを推奨。複数の責務を1PRに混ぜるとレビュー効率が落ちることを具体的に指摘しています。この記事でいう「責務を混ぜない」に直結します。
- Codacy Blog: Small Pull Requests: 6 reasons why they are the best choice
https://blog.codacy.com/small-pull-requests
→ 小さなPRが品質・レビュー速度・理解のしやすさに寄与する理由を解説。責務を分けて小さくすることの価値を裏付けます。