Blog Infos
Author
Published
Topics
Published
Topics

Photo by Sven Mieke on Unsplash

 

In Part 2, we finished our GHA workflow which generates apk size diff report and then adds it as a comment to the PR. In this post we’re going to implement the same workflow but using a plugin called Ruler and also look at some of it’s additional features.

Let’s rewind a bit to the Part 1, where we discussed about when we want to reduce the apk size we try to look at the size contribution by each high level categories (i.e. .dex files, resources, assets, JNI libs). An even more advanced stage in this “Apk Size Reduction” exercise is to understand the contribution by the 3rd party SDKs (which is not a trivial task by any means 😅). One of the way to calculate it can be to generate two apks with and without the SDK and then calculate the difference. But this is very time consuming and manual process.

This is where Spotify’s Ruler plugin comes in very handy as it provides insights into how much size is contributed by each module and dependencies present in your app 😲. You can check the sample report generated with the help of Ruler plugin in the below screenshot.

Ruler report showing Download (left side) Install (right side) size contribution by all modules and 3rd party SDKs

One problem that I faced while using Ruler plugin is that it’s not compatible with configuration-cache. But it should not be a blocker as you can always disable configuration-cache setting via passing “ — no-configuration-cache” flag while running the Ruler’s gradle tasks.

Integrating this plugin is very straightforward and doesn’t require much effort. Once this plugin is integrated it’ll expose analyze{BUILD_VARIANT}Bundle gradle tasks. For an app with no custom build variant it should expose two tasks i.e. analyzeDebugBundle & analyzeReleaseBundle. One important feature of this plugin apart from showing the size contribution, is showing ownership of different modules of your app. I highly suggest to give it a try as well.

Once you run this analyze gradle task then it should generate two files i.e. an HTML report and a json file. HTML report is a more human readable which you can use to visually inspect the size contribution. You can see the sample report for the same below:

HTML report sample

The file that we’re more interested in is the json report. As we can process the contents of this json file to further generate the data in the format that we want. The json file content looks as shown in below screenshot:

json report sample

So our goal here is to process the json file content and generate the same kind of report that we generated previously. The final result should look like below:

Size Diff Report comment added by our workflow (using Ruler plugin)

We’re going to create new workflow which is very similar to the one we created earlier.

This workflow is also divided into three jobs:

  • Generate html+json reports for both base branch and a merge branch (i.e. head branch + base branch)
  • Generate the size diff report (using custom python script)
  • Add the size diff report as a PR comment (same as previous workflow)
Creating Python Script (to parse json report)
import sys
import json
# read json file
def read_report_file(file_path):
with open(file_path, 'r') as file:
json_data = json.load(file)
return json_data
# generate dictionary of the grouped contents of an apk file
def get_apk_components(json_data):
components = {}
for component in json_data['components']:
for file in component['files']:
if file['name'].startswith('/lib/'):
update_if_present(components, 'Native libraries (arm64-v8a)', file['downloadSize'])
elif file['name'].startswith('/resources.arsc') or file['name'].startswith('/res/'):
update_if_present(components, 'Resources', file['downloadSize'])
elif file['name'].startswith('/assets/'):
update_if_present(components, 'Assets', file['downloadSize'])
elif file['name'].startswith('/'):
update_if_present(components, 'Others', file['downloadSize'])
else:
update_if_present(components, 'Classes', file['downloadSize'])
return components
# add/update the value (i.e. size) based on the presence of provided key
def update_if_present(components, key, value):
if key in components:
components[key] = components[key] + value
else:
components[key] = value
# generate html file containing size diff in HRF
def generate_size_diff_html():
html = "<html>"
html += "<body><h1>Ruler Size Diff Report</h1><h3>Affected Products</h3>"
html += "<ul><li><h4><code>release</code></h4><table>"
html += f"<tr><th>Component</th><th>Base ({apk_1_sha})</th><th>Merge ({apk_2_sha})</th><th>Diff</th></tr>"
# print diff of each components of both of the apk files
for component in set(components_1.keys()) | set(components_2.keys()):
size_1 = components_1.get(component, 0)
size_2 = components_2.get(component, 0)
html += f"<tr><td>{component}</td><td>{format_size(size_1)}</td><td>{format_size(size_2)}</td><td>{format_size_with_indicator(size_2 - size_1)}</td></tr>"
# calculate size of the apk files
apk_1_download_size = apk_1_json['downloadSize']
apk_2_download_size = apk_2_json['downloadSize']
html += f"<tr><td>apk (Download Size)</td><td>{format_size(apk_1_download_size)}</td><td>{format_size(apk_2_download_size)}</td><td>{format_size_with_indicator(apk_2_download_size - apk_1_download_size)}</td></tr>"
html += "</li></ul></table></body></html>"
with open("ruler_diff_report.html", "w") as file:
file.write(html)
# format bytes to KB or MB. Any size less than a KB is treated as 0KB
def format_size(size):
if abs(size) > mb_in_bytes:
return f"{round(size / mb_in_bytes, 2)} MB"
elif abs(size) > kb_in_bytes:
return f"{round(size / kb_in_bytes, 2)} KB"
else:
return "0 KB"
# add an indicator to highlight the size diff
def format_size_with_indicator(size):
size_indicator = "🔴" if size > kb_in_bytes else "🟢"
return f"{format_size(size)} {size_indicator}"
# read arguments passed to this script
apk_1_sha = sys.argv[1]
apk_2_sha = sys.argv[2]
kb_in_bytes = 1024
mb_in_bytes = 1024 * 1024
apk_1_json = read_report_file(f"{apk_1_sha}.json")
apk_2_json = read_report_file(f"{apk_2_sha}.json")
# generate dictionaries for the apk components size
components_1 = get_apk_components(apk_1_json)
components_2 = get_apk_components(apk_2_json)
generate_size_diff_html()
Python script to parse the json report generated by Ruler plugin

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Pruning Your App- Good Practices for Reducing App Size

App size reduction should be a critical aspect of app development, and is especially necessary for end users who may have restricted device storage or limited data plans.
Watch Video

Pruning Your App- Good Practices for Reducing App Size

Chrystian Vieyra
Engineering Manager
Comcast

Pruning Your App- Good Practices for Reducing App Size

Chrystian Vieyra
Engineering Manager
Comcast

Pruning Your App- Good Practices for Reducing App Size

Chrystian Vieyra
Engineering Manager
Comcast

Jobs

The main difference in this script compared to previous one is the contents of get_apk_components function. Here we no longer have to use apkanalyzer tool and just need to iterate through the json_data (parsed from reading the json report generated by using Ruler plugin) and update the content of components dictionary.

Ruler report workflow
name: Ruler Report
# cancel in-progress workflow if new commits are pushed to same head branch
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
on:
pull_request:
branches: [ "master", "main", "release/*", "feature/*" ]
env:
ruler_diff_report_file: ruler_diff_report.html
jobs:
generate_report:
name: Generate Ruler Report
runs-on: ubuntu-latest
outputs:
base_short_sha: ${{ steps.base-short-sha.outputs.base_short_sha }}
head_short_sha: ${{ steps.head-short-sha.outputs.head_short_sha }}
steps:
- name: Checkout base (PR target) branch
uses: actions/checkout@v3
with:
ref: ${{ github.base_ref }}
# this step is optional if you don't use JDK 17 as ubuntu-latest runner already
# has java setup which defaults to JDK 11
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
# this step is optional and should be added in case you get "Failed to install the following
# Android SDK packages as some licences have not been accepted" error
- name: Setup Android SDK
uses: android-actions/setup-android@v2
# setup cache so that every build trigger doesn't require downloading all the
# dependencies used in project again and again saving tonnes of time
- name: Setup Gradle & Android SDK Cache
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
/usr/local/lib/android/sdk/build-tools
/usr/local/lib/android/sdk/system-images
key: ${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }}
- name: Generate shorter github.sha for base branch
id: base-short-sha
run: |
chmod +x ./.github/scripts/commit_short_sha.sh
short_sha=$(./.github/scripts/commit_short_sha.sh)
echo "base_short_sha=${short_sha}" >> "$GITHUB_ENV"
echo "base_short_sha=${short_sha}" >> "$GITHUB_OUTPUT"
# generate component wise report using ruler gradle task
- name: Generate Report
run: |
./gradlew analyzeReleaseBundle --no-configuration-cache -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
mv app/build/reports/ruler/release/report.html app/build/reports/ruler/release/${{ env.base_short_sha }}.html
mv app/build/reports/ruler/release/report.json app/build/reports/ruler/release/${{ env.base_short_sha }}.json
# upload generated ruler reports
- name: Upload the generated html report
uses: actions/upload-artifact@v3
with:
name: ${{ env.base_short_sha }}.html
path: app/build/reports/ruler/release/${{ env.base_short_sha }}.html
# upload generated ruler reports
- name: Upload the generated json report
uses: actions/upload-artifact@v3
with:
name: ${{ env.base_short_sha }}.json
path: app/build/reports/ruler/release/${{ env.base_short_sha }}.json
- name: Checkout head branch
uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Generate shorter github.sha for head branch
id: head-short-sha
run: |
chmod +x ./.github/scripts/commit_short_sha.sh
short_sha=$(./.github/scripts/commit_short_sha.sh)
echo "head_short_sha=${short_sha}" >> "$GITHUB_ENV"
echo "head_short_sha=${short_sha}" >> "$GITHUB_OUTPUT"
- name: Merge base (PR target) branch
run: |
git config --global user.email "akash.mercer@gmail.com"
git config --global user.name "Akash Khunt"
git merge origin/${{ github.base_ref }}
# generate component wise report using ruler gradle task
- name: Generate Report
run: |
./gradlew analyzeReleaseBundle --no-configuration-cache -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
mv app/build/reports/ruler/release/report.html app/build/reports/ruler/release/${{ env.head_short_sha }}.html
mv app/build/reports/ruler/release/report.json app/build/reports/ruler/release/${{ env.head_short_sha }}.json
# upload generated ruler reports
- name: Upload the generated html report
uses: actions/upload-artifact@v3
with:
name: ${{ env.head_short_sha }}.html
path: app/build/reports/ruler/release/${{ env.head_short_sha }}.html
# upload generated ruler reports
- name: Upload the generated json report
uses: actions/upload-artifact@v3
with:
name: ${{ env.head_short_sha }}.json
path: app/build/reports/ruler/release/${{ env.head_short_sha }}.json
size_report:
needs: generate_report
name: Size Report
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v3
with:
name: ${{ needs.generate_report.outputs.head_short_sha }}.json
- uses: actions/download-artifact@v3
with:
name: ${{ needs.generate_report.outputs.base_short_sha }}.json
# here we use our custom python script to generate file matching ruler_diff_report_file
- name: Generate size report
run: |
chmod +x ./.github/scripts/ruler_report_parser.py
python3 ./.github/scripts/ruler_report_parser.py ${{ needs.generate_report.outputs.base_short_sha }} ${{ needs.generate_report.outputs.head_short_sha }}
- name: Upload Size Report html file
uses: actions/upload-artifact@v3
with:
name: ${{ env.ruler_diff_report_file }}
path: ${{ env.ruler_diff_report_file }}
pr_comment:
needs: size_report
name: PR Comment (Size Report)
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
name: ${{ env.ruler_diff_report_file }}
- name: Add PR Comment
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
try {
const filePath = 'ruler_diff_report.html'
const sizeReportContent = fs.readFileSync(filePath, 'utf8')
const result = await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: sizeReportContent
})
console.log(result)
} catch (error) {
console.error('Error reading file:', error);
}

This workflow is also very similar to the earlier one with some minor tweaks in build job (instead of executing assembleRelease task and uploading the apks we’re going to execute the analyzeReleaseBundle tasks and upload the html & json reports) & size_report job (using the updated python script which doesn’t require apkanalyzer tool). The final pr_comment job stays same in both workflows as the input file is still going to be ruler_diff_report.html file and we’re going to use it’s content to add a comment on the PR.

Conclusion

The result of both size_metrics.yml & ruler_report.yml workflows is same except the fact that the later one also exposes html report which allows us to do some granular/deeper level investigation into size contribution visually.

Reference Material Links and Credits:

Hope you liked reading the article. Please feel free to reach me on Twitter or LinkedIn. Thank You!

 

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
In Part 1, we discussed about the importance of continuous monitoring of APK size…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu