🌱

Androidの未使用リソースを自動で削除する『resource-pruner-plugin』を開発した

に公開

resource-pruner-plugin

はじめに

こんにちは、Androidエンジニアの@syarihuです。

Androidアプリ開発では、プロジェクトが成長するにつれて未使用のリソースが蓄積していきます。R8のリソース縮小機能を使えばAPK生成時に未使用リソースを除去できますが、ソースコード自体は残るため、ビルド時間の増加やIDEのインデックス作成の遅延、コードベースの可読性低下といった問題は解決できません。Android Studioには「Remove Unused Resources」機能がありますが、手動での実行が必要なため、継続的に運用するのが難しいという課題があります。

本記事では、CIで自動化でき、マルチモジュールプロジェクトでも正確に未使用リソースを検出・削除できるGradleプラグイン「resource-pruner-plugin」を紹介します。

開発の背景

筆者はこれまで、本業のAndroidプロジェクトでkonifar/gradle-unused-resources-remover-pluginをCIで定期実行し、未使用リソース削除のPRを自動作成する運用を行っていました。

このプラグインは便利でしたが、長らく本家のメンテナンスが行われていませんでした。そのため、知人がJava 17やAGP 8に対応させたフォーク版をベースに、さらに自前で修正を加えてJitPackに公開して運用を続けていました。具体的には、Gradleアップデートに伴うビルドエラーの修正や、Paraphraseへの対応などを独自に行っていました。

しかし、今後Gradle 9やAGP 9への対応が必要になることを考えると、古くなったベースをメンテナンスし続けるのは困難だと感じるようになりました。そこで、完全新規で設計し、モダンな実装で未使用リソースの検出をより精度高く行えるプラグインを自分で開発することにしました。

Android Studioにも「Remove Unused Resources」機能がありますが、人間が定期的に実行して差分をコミットする必要があり、継続的な運用には向いていません。CIでの自動化を考えると、やはりGradleプラグインが必要でした。

resource-pruner-pluginで実現したこと

CIでの自動化と検出精度の向上を目指して、次のような特徴を持つresource-pruner-pluginを開発しました。

  • CIで定期実行して未使用リソース削除のPRを自動作成可能
  • さまざまなリソース参照パターンに対応
  • マルチモジュールプロジェクトで依存プロジェクトのソースをスキャン
  • プレビュー機能で削除前に確認可能
  • 柔軟な除外設定
  • カスケード削除で連鎖的な未使用リソースも完全に削除

実際にAndroid Studioで未使用リソースを削除した後に本プラグインを実行したところ、さらにいくつかのリソースが検出されました。Android Studioでは検出しきれないケースもあるようです。

resource-pruner-pluginの主な機能

このプラグインは、さまざまなリソース参照パターンを検出できます。対応しているリソース型の一覧はREADMEを参照してください。

検出可能なリソース参照パターン

プラグインは次のパターンでリソースの使用を検出します。

パターン
直接的なRクラス参照 R.drawable.icon, R.string.app_name
インポートエイリアス import com.example.R as MyRMyR.drawable.icon
XML リソース参照 @string/app_name, @drawable/icon
テーマ属性参照 ?attr/colorPrimary
カスタムビュー属性 R.styleable.CustomView_customAttr
暗黙的なスタイル継承 TextStyle.BodyTextStyle を継承
ViewBinding ActivityMainBinding, FragmentHomeBinding
Paraphrase FormattedResources.greeting_format()
AndroidManifest @style/Theme.App

ViewBindingの正確な検出

resource-pruner-pluginは、ViewBindingの使用を正確に検出します。ソースコードでActivityMainBindingクラスを参照している場合、対応するactivity_main.xmlレイアウトを使用中と判定します。

PascalCaseのバインディングクラス名からsnake_caseのレイアウト名への変換は自動で行われます。

// このコードがあれば、activity_main.xml は使用中と判定される
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
    }
}

Paraphraseの正確な検出

cashapp/paraphraseは、ICU形式の文字列リソースをタイプセーフに扱えるライブラリです。resource-pruner-pluginは、FormattedResourcesクラスの使用を検出し、対応する文字列リソースを使用中と判定します。

// このコードがあれば、greeting_format 文字列リソースは使用中と判定される
val greeting = FormattedResources.greeting_format(userName)

マルチモジュール対応

マルチモジュールプロジェクトでは、共通ライブラリモジュールにリソースを定義することがあります。resource-pruner-pluginは、依存プロジェクトのソースコードもスキャンするため、ライブラリモジュールのリソースが正しく使用中と判定されます。

次のようなプロジェクト構成を考えます。

project-root/
├── app/                    # common-uiを使用
│   └── build.gradle.kts    # implementation(project(":common-ui"))
└── common-ui/              # 共通UIライブラリ
    └── src/main/res/       # 共通リソース

common-uiモジュールにプラグインを適用すると、appモジュールのソースコードもスキャンして、common-uiのリソースが使われているかを判定します。

./gradlew :common-ui:pruneResourcesPreviewDebug

導入手順

resource-pruner-pluginはMaven Centralに公開しているため、追加のリポジトリ設定は不要です。

プラグインの適用

build.gradle.ktsにプラグインを追加します。最新バージョンはリリースノートで確認できます。

// build.gradle.kts
plugins {
    id("net.syarihu.resource-pruner") version "<latest-version>"
}

基本設定

プラグインはデフォルト設定のままでも動作しますが、resources.getIdentifierなどでリソース名を動的に生成して参照している場合、静的解析では検出できないため除外設定が必要です。

// このようなコードは静的解析で検出できない
val resId = resources.getIdentifier("icon_$type", "drawable", packageName)
val levelBadge = resources.getIdentifier("badge_level_$level", "drawable", packageName)

このような動的参照を行っている場合は、次のように除外パターンを設定します。

resourcePruner {
    // 除外するリソース名のパターン(正規表現)
    excludeResourceNamePatterns.addAll(
        "^icon_.*",           // 動的に参照されるリソースを保持
        "^badge_level_.*"     // 動的に参照されるリソースを保持
    )
}

その他の設定オプションについては、「設定オプションの詳細」を参照してください。

使い方

resource-pruner-pluginの使い方を、導入時と運用時に分けて説明します。

導入時の手順

プラグインをはじめて導入するときは、次の手順で進めます。プラグイン導入のPRと未使用リソース削除のPRを分けることで、レビューしやすくなります。

1. プラグイン導入のPRを作成する

まず、プラグインの導入(build.gradle.ktsへの追記と設定)だけのPRを作成します。

2. 削除されるリソースを事前に確認する

導入PRをマージする前に、実際にpruneResourcesを実行して問題がないか確認します。この確認を先に行うことで、未使用リソース削除時の手戻りを防げます。

まず、Gitで差分をコミットして差分がない状態にします。次に、すべてのビルドバリアントに対してpruneResourcesタスクを実行します。

./gradlew pruneResourcesDebug pruneResourcesRelease

すべてのビルドバリアントで実行する理由は、ビルドバリアントによって使用されるリソースが異なる場合があるためです。Product Flavorsを使っている場合は、すべてのフレーバーとビルドタイプの組み合わせで実行してください。

削除されたリソースを確認し、すべてのビルドバリアントでビルドが通ることを確認します。

./gradlew assembleDebug assembleRelease

ビルドエラーが発生した場合は、次の観点で原因を切り分けます。

  • アプリの実装の問題: たとえば、debugビルドでしか使われていないリソースがmainソースセットに定義されている場合、releaseバリアントでpruneを実行すると未使用と判断されて削除されてしまいます。このような場合は、リソースをmainからdebugソースセットに移動するか、移動が難しい場合は除外設定に追加してください。また、未使用のlayoutリソース内で@+id/hogeが定義されており、実際には呼ばれていない処理でR.id.hogeを参照しているケースもあります。この場合、layoutが未使用として削除された結果、R.id.hogeが参照エラーになります。その場合は未使用の処理を削除するなど、アプリの実装の問題を解決してください。
  • プラグインの検出ミス: プラグインが特定のリソース参照パターンを検出できていないケース。この場合は、GitHubのIssueで報告していただけると助かります。PRも歓迎します。

ビルドエラーが発生していなくても、外部SDK(Brazeなど)のリソースで、ビルドエラーにはならないが削除されると問題があるケースがあります。
実行に問題が発生することを防ぐため、削除されたリソースが本当に削除して問題がないかどうかを差分を見て確認し、問題がありそうなら除外設定に追加するなどの対応をすると安心です。

確認が完了したら、削除されたリソースを元に戻します。

git checkout .

なお、ファイルを削除せずに削除対象のリソースだけを確認したい場合は、プレビュータスクも使用できます。

./gradlew pruneResourcesPreviewDebug

3. 導入PRをマージする

事前確認で問題がなければ、プラグイン導入のPRをマージします。

4. 未使用リソース削除のPRを作成する

導入PRがマージされたら、再度すべてのビルドバリアントに対してpruneResourcesタスクを実行し、未使用リソース削除のPRを作成します。

./gradlew pruneResourcesDebug pruneResourcesRelease

5. 未使用リソース削除PRをマージする

ビルドが通ることを確認したら、未使用リソース削除のPRをマージします。

導入・未使用リソース削除後の運用

初回の導入と未使用リソースの削除が完了したら、継続的に未使用リソースを管理するためのCI運用を整えます。

CIでの自動化

定期実行またはPR単位でpruneResourcesタスクを実行し、差分があれば自動でPRを作成するCIワークフローを構築することをお勧めします。

未使用リソースがPR毎に発生することは考えにくいため、週1回や隔週での定期実行がお勧めです。

GitHub Actionsでの設定例を示します。

name: Prune Unused Resources

on:
  schedule:
    # 毎週月曜日の午前9時(JST)に実行
    - cron: '0 0 * * 1'
  workflow_dispatch:

jobs:
  prune:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - name: set up JDK 17
        uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
        with:
          java-version: '17'
          distribution: 'zulu'

      - name: Prune unused resources
        run: ./gradlew pruneResourcesDebug pruneResourcesRelease

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
        with:
          commit-message: Auto-modify files by resource-pruner
          committer: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
          author: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
          title: 'Remove unused resources'
          body: |
            ### Summary
            - Unused resources were automatically removed by resource-pruner

            ### Checklist
            - [ ] Run the app on devices

            ---

            This PR has been generated by [create-pull-request][1]

            [1]: https://github.com/peter-evans/create-pull-request
          branch: refactor/remove-unused-resources
          branch-suffix: short-commit-hash

このワークフローでは、定期的にpruneResourcesタスクを実行し、差分があればPRを自動作成します。

ビルドバリアントへの対応

タスク名のDebug部分はビルドバリアントに応じて変わります。Releaseバリアントの場合はpruneResourcesRelease、Product Flavorsを使っている場合はpruneResourcesStagingDebugのようにフレーバー名とビルドタイプを組み合わせた名前になります。

カスケード削除機能

resource-pruner-pluginには、カスケード削除機能があります。これは、リソースを削除すると他のリソースが未使用になる場合に、未使用リソースがなくなるまで繰り返し検出と削除を行う機能です。

たとえば、style_button.xmlというスタイルがlayout_form.xmlでのみ使われている場合を考えます。layout_form.xmlが未使用で削除されると、style_button.xmlも未使用になります。カスケード削除機能は、このような連鎖的な未使用リソースを自動で検出し、まとめて削除します。

この機能はデフォルトで有効になっており、最大5回のイテレーションで未使用リソースを完全に削除します。無効にする場合は、次のように設定します。

resourcePruner {
    cascadePrune.set(false)
}

設定オプションの詳細

設定オプションの一覧は次のとおりです。

オプション 説明
excludeResourceNamePatterns List<String> 削除対象から除外するリソース名の正規表現パターン
targetResourceTypes Set<String> 対象とするリソース型(空の場合はすべての型が対象)
excludeResourceTypes Set<String> 削除対象から除外するリソース型
sourceSets Set<String> スキャン対象のソースセット(デフォルト: ["main"]
scanDependentProjects Boolean 依存プロジェクトのソースをスキャンするか(デフォルト: true
cascadePrune Boolean 未使用リソースがなくなるまで繰り返し削除するか(デフォルト: true、最大5回)

用途に応じた設定例を紹介します。

特定のリソース型のみを対象にする

drawableとstringリソースのみを対象にする場合は、次のように設定します。

resourcePruner {
    targetResourceTypes.addAll("drawable", "string")
}

特定のリソース型を除外する

menuリソースを対象外にする場合は、次のように設定します。

resourcePruner {
    excludeResourceTypes.addAll("menu")
}

依存プロジェクトのスキャンを無効にする

ライブラリモジュールで、依存プロジェクトのスキャンを無効にする場合は、次のように設定します。

resourcePruner {
    scanDependentProjects.set(false)
}

ただし、この設定を有効にすると、依存プロジェクトで使用されているリソースが未使用と判定される可能性があります。通常はデフォルトのtrueのまま使うことをお勧めします。

技術的な実装の工夫

プラグインの実装でいくつかの工夫をしています。

コメントと文字列の除外

ソースコードをスキャンする際、コメントや文字列リテラル内のリソース参照を除外しています。次のようなコードでは、コメント内のR.drawable.old_iconは使用とみなされません。

// 以前は R.drawable.old_icon を使っていた
val icon = R.drawable.new_icon

これにより、コメント内の参照による誤検出を防いでいます。

インポートエイリアスの対応

Kotlinでは、Rクラスをエイリアスでインポートできます。

import com.example.R as MyR

val name = MyR.string.app_name

resource-pruner-pluginは、インポート文を解析してエイリアスを検出し、MyR.string.app_nameのような参照も正しく使用中と判定します。

生成コードの回避

ViewBindingやParaphraseの生成コードは、すべてのリソースを参照しています。これらの生成コードをスキャンすると、すべてのリソースが使用中と判定されてしまいます。

resource-pruner-pluginは、生成ディレクトリ(build/generated/など)をスキャン対象から除外し、実際のソースコードのみをスキャンします。

XMLリソース定義の多様な形式に対応

Androidのリソースは、さまざまな形式でXMLファイルに定義できます。resource-pruner-pluginは、次のような形式にも対応しています。

<!-- item要素でtype属性を指定する形式 -->
<item name="icon_check" type="drawable">@drawable/ic_check</item>

<!-- drawable要素で直接定義する形式 -->
<drawable name="selector_button">#FF0000</drawable>

これらの形式で定義されたリソースも正しく収集され、使用状況が検出されます。

まとめ

resource-pruner-pluginは、Android Studioの「Remove Unused Resources」機能と同等以上の検出精度で、CIでの自動化も可能にするGradleプラグインです。

機能 説明
ViewBinding対応 ViewBindingクラスの使用を正確に検出
Paraphrase対応 FormattedResourcesの使用を正確に検出
マルチモジュール対応 依存プロジェクトのソースをスキャン
プレビュー機能 削除前に対象リソースを確認可能
柔軟な除外設定 正規表現で除外パターンを指定可能
カスケード削除 連鎖的に発生する未使用リソースも自動で削除

プロジェクトのリソース管理にぜひ活用してみてください。

リンク

Discussion