Closed mountiny closed 6 months ago
Adam from Callstack here, I'd like to work on this.
This is a performance audit of the Expensify app done by Callstack, following the DMAIC methodology. It will consist of 5 phases and result in a report summarising the whole audit.
In the first phase we have defined the following metrics:
Current status:
We've conducted the measurements (will post shortly) and are finishing up analysing them, you can expect separate comments with the findings.
Metric: JS bundle size in MB Phase: Measurements Commit hash: c09a3fead81512a4b01ccafff4c736301e0dbe89
Platform | Size | Command | Artifact |
---|---|---|---|
Android | 10.34 MB | npx react-native-bundle-visualizer@3.1.0 --platform android |
|
iOS | 10.92 MB | npx react-native-bundle-visualizer@3.1.0 --platform ios |
Metric: JS bundle size in MB Phase: Analysis Commit hash: c09a3fead81512a4b01ccafff4c736301e0dbe89
Below is the breakdown of our findings from the analysis process. To sum this up, if we take the associated actions the JS bundle size can be reduced by at least ~11%.
Name | Platforms | Before | After | % Impact | Notes | Actions |
---|---|---|---|---|---|---|
date-fns locale imports | All | 915 KB | 15 KB | 8% | Locales are being imported from the date-fns/locale module using named imports. This causes unused ones to end up in the bundle. |
Importing only 'en-GB' and 'es' separately from their respective paths, eg. date-fns/locale/es to only include the ones in use. |
@formatjs polyfills | Native | 353 KB | 0 B | 3% | Hermes ships Intl polyfills starting from RN 0.65 on Android and 0.70 on iOS. | For native platforms, polyfills bundled with Hermes can be used. |
underscore | All | 20 KB | 0 B | - | Both lodash and underscore utilities are being used. | Underscore can be removed in favor of lodash. |
assets/images | All | Android - 1.93 MB, iOS - 2.57 MB | - | - | High res (svg), but high size images are being used regardless the usecase. | Static assets that are not being animated can be optimised by serving in other, more optimised formats. |
assets/emojis | All | 425 KB | - | - | Current approach to emojis is custom made (maintenance and size cost). | Custom code can be replaced with the node-emoji dependency. A contribution to emojilib will be needed to support βesβ locale. |
Additionally, some unused/deprecated/vulnerable dependencies have been identified in the process.
Name | Notes | Actions |
---|---|---|
npm audit | 49 vulnerabilities (26 moderate, 21 high, 2 critical) | Apply npm audit fix to fix vulnerabilities. |
unused dependencies | - | Remove: @react-native-firebase/analytics , fbjs , save , react-native-image-pan-zoom , react-native-image-size |
unused devDependencies | - | Remove: @babel/plugin-proposal-export-namespace-from , @vercel/ncc diff-so-fancy , react-native-clean-project (replace with RN CLI clean) |
metro-react-native-babel-preset | Soon to be deprecated plugin is being used. | Swap metro-react-native-babel-preset with @react-native/babel-preset before migrating to RN@0.73+. |
@mountiny Happy 2024!! π Please feel free to invite anyone to the discussion here - we can now pick prioritised items from the list above to take actions right now or decide what's already taking place/can be postponed. We'd follow the same for all the metrics :)
Metric: APK Size in MB Phase: Measurement Commit hash: 6365aebb9cdb3dd4a66e77117afd0fb1b1fa8957
We gathered the following metrics on baseline ( without any improvements ) :
Β | APK Size MB | Download Size MB |
---|---|---|
All architectures | 126.6 | 123 |
Β | Β | Β |
These metrics are gathered by generating a release APK using ENVFILE=.env.production ./gradlew assembleProductionRelease
and then opening that APK in the APK Analyzer in Android Studio.
arm64-v8a
, armeabi-v7a
, x86_64
and x86
. By default an APK has all these 4 architectures included.If we take metrics for one architecture like arm64-v8a
, here are the results:
Β | APK Size MB | Download Size MB |
---|---|---|
arm64-v8a | 47.3 | 44.1 |
Β | Β | Β |
Given that, if we are able to reduce the size of the APK for all architectures, it's perceived that each architecture separately would benefit from it.
Metric: APK Size in MB Phase: Analysis Commit hash: 6365aebb9cdb3dd4a66e77117afd0fb1b1fa8957
We will start with enabling the Proguard and removing any resources that aren't used in the app and in the app's library.
shrinkResources true
minifyEnabled true
Once this is done, we will measure the affect of it on the APK:
Β | APK Size MB | Download Size MB |
---|---|---|
All architectures | 117.8 | 114.1 |
Β | Β | Β |
The above measurements uses the default proguard-android.txt
which is generated by android build system itself.
We applied all the optimisations from previous round with one change. And thatβs we used the default proguard-android-optimize.txt
which is also generated by android build system but itβs different than proguard-android.txt
as it has a few optimisations flags.
Β | APK Size MB | Download Size MB |
---|---|---|
All architectures | 116.3 | 112.6 |
Β | Β | Β |
This is a great result, saving around 10-11 MB as compared to the results in Measurements phase, seems like magic πͺ
There are other things to consider here:
expo-modules
was being ripped off and couldn't show images. To fix that we whitelisted the whole package for Proguard. Moving forward, we will take a closer look at it and only whitelist what is essential in expo-modules
package to make the app work as expected.Importing only 'en-GB' and 'es' separately from their respective paths, eg. date-fns/locale/es to only include the ones in use
Ok, this is very interesting, since we have a best practice in place that we should do import * as MyLib from '@libs/MyLib';
When we introduced this best practice, I thought that we determined it would not have an effect on bundle size because babel would strip out the unused imports and end up only importing the named imports that are actually used in the final bundle.
If this is not true, then I'd surmise that our current best practice might actually a bad practice?
In short, I agree with the change to use default imports from the date-fns/locale
sub-modules directly, but I would also like to discuss if there's a new best practice we can extract from that to help optimize our bundle size more holistically.
For native platforms, polyfills bundled with Hermes can be used
I'm all for removing any polyfills we don't need anymore. Just keep in mind all the JS engines we're exposed to β not only Hermes but also the modern browser landscape.
Underscore can be removed in favor of lodash
This is already happening with the TypeScript migration, so ππΌ to any new coordinated effort to ditch underscore for now. Once all files are migrated to TypeScript we should have no more uses of underscore, at which point it can be removed.
Static assets that are not being animated can be optimised by serving in other, more optimised formats
Nah, let's just stick with svg.
Custom code can be replaced with the node-emoji dependency
It's not clear what impact this would have, positive or negative. So unless there's some clear improvement can be made, and it's shown to be significant "worth it", I don't think we should discuss this.
Also, I know in the future we'll want to support custom emojis, that would probably be a better time to discuss overhauls of our emoji implementation.
Apply npm audit fix to fix vulnerabilities
Yeah, sounds good. We should probably think about "chorifying" that β i.e: creating a recurring chore, say, every month or every quarter, that's assigned to someone to run npm audit
and update vulnerable packages.
This would be a good one to bring to slack with a problem/solution. We already have robust code for creating and auto-assigning chores, so that part is pretty trivial if we agree that this would be a valuable chore to introduce. Would be a happy to discuss a P/S about this in slack.
But yes, in the meantime we can patch known vulnerabilities, but I want to make sure that proper testing is happening so we should manually upgrade them one-at-a-time and thoroughly test relevant functionality, rather than upgrading them all in one fell swoop with npm audit fix
and hoping that everything works.
ππΌππΌ to removing any unused dependencies, but I'm skeptical of your analysis. For example, ncc
is not unused. It's used to compile GitHub Actions here.
Maybe it would make sense to create a separate package.json
to separate out GitHubActions-specific dependencies, but honestly doesn't seem worth it because they're all dev dependencies anyways and shouldn't really affect production bundle sizes.
Swap metro-react-native-babel-preset with @react-native/babel-preset before migrating to RN@0.73+.
This change is already included in the 0.73 upgrade PR here, so I think we should be covered there.
We will start with enabling the Proguard and removing any resources that aren't used in the app and in the app's library
Unfortunately I'm not familiar with this Android thing. Are there any potential downsides?
Edit: Honestly, this makes me a bit nervous:
It seems that enabling Proguard is trivial as it's only 2-3 liner but there are a handful of issues which are already taken care of and are not stated here
It sounds like it could be a common source of development headaches in a code space (i.e: Android native bundling) that most React Native developers (myself included) aren't accustomed to thinking about. So I'm not sure if I'm on board or not. I'm not sure 10MB in the native bundle is worth it β does it have any effect on startup time? Reducing the JS bundle size seems more worth it because it will mean the website will load faster.
Hey @mczernek @staszekscp β curious if you have any experience with or recommendation regarding enabling Proguard?
On the one hand, proguard is as natural as air in native Android development - using it is natural and standard pattern once you go to production.
On the other, I don't see gains being huge here and yet I can see some risks. Since proguard is not so natural in RN ecosystem, some libraries might not be ready for it. There are cases when it can actually delete used code and resources if they are not referenced directly. As mentioned that would require extensive testing of whole app - risk it breaks something might not be huge, but it might happen pretty much anywhere.
Being completly honest, with RN apps I usually do one of two - enable proguard from the very start of development or just skip it altogether. However, being huge app we want to become, I think it might be viable to go through this at some point.
TBH, a couple of months ago we've enabled proguard with MichaΕ in order to check if it gives some performance gains on app startup time. The app booted, but we didn't see any difference in performance (also navigating between screens). Because of the risks MichaΕ has mentioned above, we have decided that it is not the path it is worth to take. π However, the app has changed a lot since, so it may be worth to check again.
@roryabraham @staszekscp @mczernek
These are some really valid points brought up and I agree with almost all of them. I would like to discuss a few things in detail:
note: By using ProGuard, we mean using R8 which is enabled by default and leverages the ProGuard rules in order to perform the minification of the APK, if it's enabled.
- Enabling ProGuard in React Native:
As mentioned, some libraries aren't supporting ProGuard which makes it hard to enable it. I have had issues with ProGuard previously but in case of Expensify, things were pretty smooth. Mostly because aapt2
tool did the hardwork of generating a ProGuard configuration of all the classes, methods and fields used in our codebase. Which then later is combined with default proguard-android.txt
or proguard-android-optimize.txt
as configured. Finally we have a configuration.txt
in app/build/outputs/mappings
which have all of the ProGuard rules combined from above.
I had to add keep rules for BuildConfig and Expo Modules in order to pick the environment flags and show the assets and that's pretty much it. I navigated between a couple screens and things looked fine.
Given that there's a chance that in some flow we may face a crash or something, so we will need a regression testing in place.
In future, if we add a dependency to a library and we face a crash we might need to add the relevant keep rules in order to make things work, so it may be a continuous effort.
- Is Improving Native Bundle worth it:
Yes, the reason is related to the growth of the brand itself. It's proven that the apps with larger size face the most uninstalls than the one's with lesser size. You might know it already but stating just for the sake of it. This goes hand in hand with JavaScript Bundle size but we have different techniques to reduce it. For example, the size of an APK with arm64-v8a
architecture for New Expensify is around 40 MB on google play store. The same architecture with ProGuard enabled with optimisations is around 33.7 MB, which is going to positively affect the downloads of the app.
What should we do?
Since we have a dedicated effort going on for performance audit and improvements, I would suggest we should try this and conduct a regression of the app and see if it negatively affects the app. By negatively I mean if it makes the app unusable by throwing ANRs, crashes or reducing the app functionality. If there's no such case or we are able to fix it if there is, then we are good to go with using ProGuard.
Thanks @roryabraham and everyone else jumping in. I agree with Rory's assessment, I think you know the hustle by now, we dont really like to do thing just for sake of doing them/ unless there is a clear reason/ benefit for us so we should skip doing those where this is not clear or negligeable.
In therms fo Proguard, I am on the same boat, I am not familiar with the complexities of this change, seems like everyone who is familiar with it agrees we should do it, but maybe not now. That feels like we can maybe put it on LOW in terms of priority and come back to it once we get some more significant improvements in. Or would you say this is one of the best improvements we can get now?
Underscore can be removed in favor of lodash
This is already happening with the TypeScript migration, so ππΌ to any new coordinated effort to ditch underscore for now. Once all files are migrated to TypeScript we should have no more uses of underscore, at which point it can be removed.
Perfect, thanks for bringing this. Since we're (only) auditing the current state, we had to point out any of the improvements - even the ones that are currently ongoing. Let's skip it then!
For native platforms, polyfills bundled with Hermes can be used
I'm all for removing any polyfills we don't need anymore. Just keep in mind all the JS engines we're exposed to β not only Hermes but also the modern browser landscape.
Absolutely - we can bundle these separately based on the platform ans reduce the size only where it's really possible. Should we treat this as an action item for the next phase? This can potentially shave off ~3% on mobiles.
Static assets that are not being animated can be optimised by serving in other, more optimised formats
Nah, let's just stick with svg.
Noted! Let's skip this part then. Mind sharing the reasoning behind it so we can make it clear for the future and not revisit this later? Is it just the quality or other aspects also play a role here?
Custom code can be replaced with the node-emoji dependency
It's not clear what impact this would have, positive or negative. So unless there's some clear improvement can be made, and it's shown to be significant "worth it", I don't think we should discuss this.
Also, I know in the future we'll want to support custom emojis, that would probably be a better time to discuss overhauls of our emoji implementation.
This is definitely a minor thing so we can ditch it, especially if there's a plan for further iterating on this functionality.
This can potentially bring a very small size improvement (less source code but bringing in a new dependency, the JSON will stay there for each translation) + and a small DX improvement tied to the potentially smaller maintenance costs & offloading some of the work to the external module (implementation, docs).
I understand your reasoning on this, I think it's fair to skip it :)
Apply npm audit fix to fix vulnerabilities
Yeah, sounds good. We should probably think about "chorifying" that β i.e: creating a recurring chore, say, every month or every quarter, that's assigned to someone to run
npm audit
and update vulnerable packages.This would be a good one to bring to slack with a problem/solution. We already have robust code for creating and auto-assigning chores, so that part is pretty trivial if we agree that this would be a valuable chore to introduce. Would be a happy to discuss a P/S about this in slack.
But yes, in the meantime we can patch known vulnerabilities, but I want to make sure that proper testing is happening so we should manually upgrade them one-at-a-time and thoroughly test relevant functionality, rather than upgrading them all in one fell swoop with
npm audit fix
and hoping that everything works.
I love that we already have a process for this π Let me add this as an action item to the next phase on this metric.
Importing only 'en-GB' and 'es' separately from their respective paths, eg. date-fns/locale/es to only include the ones in use
Ok, this is very interesting, since we have a best practice in place that we should do
import * as MyLib from '@libs/MyLib';
When we introduced this best practice, I thought that we determined it would not have an effect on bundle size because babel would strip out the unused imports and end up only importing the named imports that are actually used in the final bundle.
If this is not true, then I'd surmise that our current best practice might actually a bad practice?
In short, I agree with the change to use default imports from the
date-fns/locale
sub-modules directly, but I would also like to discuss if there's a new best practice we can extract from that to help optimize our bundle size more holistically.
This one's tricky, indeed the correct babel setup should transform named imports into the default ones - unfortunately looks like none of this happened, we can see all of the translations being loaded together. Coming up with a single 'best practise' would differ based on the tooling and whether this is an internal or external module (might rely on eg. a weird re-exporting pattern). Eg. a VSC plugin for import cost I use failed on this one, providing correct sizes only for the separated imports (both ~7kb).
For the future, I'd say we should stay cautious especially when importing for the first time from newly introduced dependencies & still rely on babel. Short-term, have an action item for importing these separately. Shall I track this as one on the implementation phase?
ππΌππΌ to removing any unused dependencies, but I'm skeptical of your analysis. For example,
ncc
is not unused. It's used to compile GitHub Actions here.Maybe it would make sense to create a separate
package.json
to separate out GitHubActions-specific dependencies, but honestly doesn't seem worth it because they're all dev dependencies anyways and shouldn't really affect production bundle sizes.
Thank you so much for pointing this out! Totally my error, I've updated the original comment with this one striked through. I think it makes sense to iteratively try to strip down the unused dependencies then, each one directly saves $ on the CI jobs. Do you think I can put this into the actionable items?
and come back to it once we get some more significant improvements in. Or would you say this is one of the best improvements we can get now?
This is the best improvements we can have at our hands as of now. Expensify's codebase is growing continuously and if we don't enable ProGuard now, there's a chance that in future, enabling ProGuard might get out of hands as by that time we would have more dependencies in place which may or may not work with ProGuard right away. We could try to make them work by ProGuard rules but that's a different story.
Reducing around 10MBs is considered really significant in terms of APK size as it's relatively complex to reduce the size of APKs. We do have other plans to try and further reduce the APK Size like LTO or native libraries optimisations manually but that's something which is experimental and may or may not benefit us.
I would suggest we enable ProGuard and have a QA regression testing on the generated APK. If there are any crashes reported by QA, gather them in some place and work on them to resolve those on a lower priority OR at the end of the audit. But if we don't face any crash in the regression, we will be good to go to use it right away.
Should we treat this as an action item for the next phase?
Yes, we can take steps to remove unnecessary polyfills.
Mind sharing the reasoning behind [sticking with svg]
Yeah, basically I don't want to deal with the development overhead of different image asset types or worry about resolutions. svg works consistently and looks good on all devices and resolutions, and with the recent introduction of expo-image is a lot more performant than it was. We also have an image optimization bot configured in this repo that automatically optimizes svgs to make them smaller.
That's just my opinion though, if it really does make a big difference we could talk about it, but I doubt it does.
Short-term, have an action item for importing [date-fns/locales] separately. Shall I track this as one on the implementation phase?
Yep, sounds good. ππΌ
I think it makes sense to iteratively try to strip down the unused dependencies then, each one directly saves $ on the CI jobs. Do you think I can put this into the actionable items?
Yep, sounds good. if you find any dependencies that are truly unused and confirm that they're not used we can absolutely remove them ππΌ
I would suggest we enable ProGuard and have a QA regression testing on the generated APK
@hurali97, sounds good ππΌ
Feel free to open the PR and I can run an AdHoc build and have Applause run a full regression test suite on Android.
Actually, @adhorodyski and @hurali97, can we be sure to create separate issues for each of the action items? You can feel free to create them and link them here β they'll automatically be closed but I can reopen them for you.
Also, since we seem to be focusing on bundle size, should we make an issue to do a CI analysis on bundle size for every PR (excluding PRs that only affect GitHub Actions, HelpDot, or tests) that leaves a comment on the PR with the change in bundle size?
Also, since we seem to be focusing on bundle size, should we make an issue to do a CI analysis on bundle size for every PR (excluding PRs that only affect GitHub Actions, HelpDot, or tests) that leaves a comment on the PR with the change in bundle size?
Totally agree with this π We would need two separate issues for this, one for JS bundle size and second for App bundle size for android. Later, we can accommodate iOS and webpack bundles too.
Good idea to break this up to its own issues, I believe they wont autoclose either as you should be whitelisted
Also, since we seem to be focusing on bundle size, should we make an issue to do a CI analysis on bundle size for every PR (excluding PRs that only affect GitHub Actions, HelpDot, or tests) that leaves a comment on the PR with the change in bundle size?
Totally agree with this π We would need two separate issues for this, one for JS bundle size and second for App bundle size for android. Later, we can accommodate iOS and webpack bundles too.
1 message posted on a PR with these listed per platform, correct? Just want to get this right.
Yeah, just a single comment on each PR with the JS bundle size and native application size before and after the PR, along with the delta.
If new changes are pushed, we should cancel any analysis still in progress and kick off a new one. If a comment was already left, the previous comment should be hidden as outdated and then a new comment should be left (there are examples for how to do this in our CI already, see the comments left by testBuild.yml
)
Team, please see this linked issue for the Search screen load time metric to better separate our discussions.
Update: measurements & analysis have just landed ;)
Hello team! Please see the new issue for the Report screen load time. Measurements phase was just added
Hey @mountiny @roryabraham π
We would like to discuss a flow regarding measuring the App Startup. As of now, following is the flow that we are taking into account for gathering the measurements:
For each subsequent measurement, steps 1-3 are not followed. Which means we loop over from step 5-8 for the measurement cycle.
This way we get the app startup of the usual flow that a user faces after the fresh install, uses the app for a while and closes it. And then each time he'll launch the app he follows the same flow as we are following here.
As of now, we are conducting measurements based on this flow. We are not taking into account the fresh install app startup as it has TTI until the SignIn page shows after the splash. This TTI doesn't reflect what a user will face on a daily basis. Hence, we opted for going with the flow described above.
We would like to get insights from you and whether the flow we are using for conducting the measurements is what you expect or otherwise. π
As of now, we are conducting measurements based on this flow. We are not taking into account the fresh install app startup as it has TTI until the SignIn page shows after the splash. This TTI doesn't reflect what a user will face on a daily basis. Hence, we opted for going with the flow described above.
I think this makes sense, I think fresh install is an edge case we do not have to focus on in here
Team, please see the update under https://github.com/Expensify/App/issues/34494#issuecomment-1900552863 on how we decided to unify 2 metrics and just stick with the Load time in MS for opening up a report.
Hi team! I've added new issue for tracking Average FPS on LHN
-> #34986
Hi folks! π
Please take a look at the Send a message
metric tracker #35225. The measurements section has been updated and analysis is coming soon π π
Hey guys π
Here's the metric tracker for App Startup. We have updated the measurements. The analysis phase is in progress and will be updated in the next week. We have some amazing results from analysis phase, so stay tuned π
This issue has not been updated in over 15 days. @adhorodyski eroding to Monthly issue.
P.S. Is everyone reading this sure this is really a near-term priority? Be brave: if you disagree, go ahead and close it out. If someone disagrees, they'll reopen it, and if they don't: one less thing to do!
Audit still underway
Hey everyone!
As the last analysis has just landed under this issue targeting Report screen load time, we need some of your help with wrapping up the last action items for the next phase of Implementation (please see the linked issue).
This next phase is currently in the works and PRs are going to be shared in the next days as we're finishing up defining the backlog items based on the discussions in the respective threads.
Reviewing
label has been removed, please complete the "BugZero Checklist".
The solution for this issue has been :rocket: deployed to production :rocket: in version 1.4.45-6 and is now subject to a 7-day regression period :calendar:. Here is the list of pull requests that resolve this issue:
If no regressions arise, payment will be issued on 2024-03-07. :confetti_ball:
For reference, here are some details about the assignees on this issue:
Tracking issue for @adhorodyski and Callstack team performing performance audit of the app.
This is a performance audit of the Expensify app done by Callstack, following the DMAIC methodology. It will consist of 5 phases and result in a report summarising the whole audit.
In the first phase we have defined the following metrics: