Fedi — Flutter open-source social network client. Part 1. Architecture.
Architecture and Patterns used in Flutter open-source social network client: Provider, Repository, StreamBuilder, BLoCs, Dependency Injection, and more.
Fedi is an open-source client for Pleroma and Mastodon social networks written using Flutter available on iOS(Beta) and Android(Beta)
Pleroma and Mastodon are parts of Fediverse(decentralized social network). Nobody owns Fediverse. Anybody can run their own server instance and use it to communicate with other people.
So Fedi is an open-source mobile client for social networks and has features similar to Twitter.
Series:
- Part 1. Architecture.
- Part 2. Code.
- Part 3. Build & Config.
- Part 4. Used packages.
- Part 5. Android Studio Plugins & Feature Plans.
Series content:
- Project structure & patterns;
- List of used 3rd parties libraries;
- Build tricks;
- Useful tools for Flutter development.
There are a lot of code Gists, links to code in the repository, and links to other articles so you will see how things actually work.
There are a lot of issues and features in Fedi backlog. And some code pieces require refactoring and improvements. So Fedi is not an ideal example of the Flutter app, it is a real-world project which actually works. So leaving a comment and issue or even contributing is welcome.
I think this articles will be useful for:
- developers who have experience with other technologies(especially iOS & Android) and want to try Flutter. You will see how things you know work in Flutter
- developers who have only started learning to program and want to explore how middle-size projects work
- developers who want to join an open-source project
- people who are interested in Fediverse(Pleroma, Mastodon etc) and want to help develop Fedi
In this part
Dart/Flutter community devs have different backgrounds. So you can find a lot of different approaches which actually work but look very different.
There is no silver bullet that you should use. I suggest explore them all and choose which works better for you. Some of the useful links:
- Flutter: MVVM Architecture
- Layered Architecture to Advanced Flutter Apps
- bloc
- flutter_architecture_samples
- flutter_redux
- flutter_clean_architecture
- flutter-template
- and much more
I used to be Android Developer so almost all ideas come from there.
Overview:
- Dependency Injection via Provider
- Using BLoCs for business logic and view state
- Don’t use StatefullWidgets. Updating UI via StreamBuilders. Using Bloc classes injected by Provider
- Having own implementation of BLoC(don’t use bloc package)
- Composition over inheritance
- No Singletons. Injecting global things on app-level
- Re-run app if need
- Interfaces. A lot of Interfaces
- Divide Widgets into smallest Widgets if possible with const constructor
- Using Repository pattern to achieved data consistency
- Immutable Data classes
Note: There are a lot of TODO and several not yet refactored code pieces in Fedi right now, so not all things follow described approach.
· Dependency injection via Provider
∘ AppContextScope & UserContextScope
∘ DisposableProvider & DisposableProxyProvider for BLoCs
· Data classes
∘ Immutable
∘ equals() & hashcode & toString()
∘ Provide
· No singletons
· BLoCs
∘ Is BLoC a ViewModel?
∘ Async init
· Isolates
· Re-run app
∘ Splash
∘ Login screen
∘ Logged screen
∘ Several accounts
· Fedi doesn’t use StatefullWidget
· Streams
∘ Map streams
∘ Twice build with StreamBuilder
· Data consistency & Offline mode
· Prefer Interfaces
· Composition over inheritance
· Local preferences
∘ Own BLoC for each key
∘ Objects scheme version
· Performance
· Testing
Dependency injection via Provider
Provider gives a possibility to inject any classes in widget tree and fetch it later from BuildContext
AppContextScope & UserContextScope
Fedi defines several context scopes:
- AppContextScope — contains global things, like database instance
- UserContextScope — contains user-related things. Like REST wrapper with auth token
Usage in Fedi(should be refactored, perhaps with Service Locator pattern):
DisposableProvider & DisposableProxyProvider for BLoCs
Fedi also uses easy_dispose for easier resources disposal of provided blocs
Data classes
Immutable
All fields in Data classes should be final. If you want to update an object you should create a new object or implement copyWith() method (can be generated with Dart Data Class plugin).
Immutable classes guarantee that everybody will know about updates via BehaviourSubject or StreamController logic.
equals() & hashcode & toString()
In addition to copyWith() don’t forget to override hashcode and equals methods on all data classes otherwise objects comparison will not be efficient
Overriding toString() is optional and should be used only for more informative logging messages
You can generate all things via Code->Generate option in Android Studio
Provide
Data classes are provided via Provider.value or StreamProvider. Since Fedi prefers const Widget constructors for performance it is not possible to pass data classes to widgets as fields.
No singletons
Since we can provide something globally one-time on AppContextScope we don’t need singletons(with static instance).
Benefits:
- Better dependencies between features;
- Easy to replace implementation;
- Easy to test & mock.
BLoCs
Example of BLoCs used in Fedi
Is BLoC a ViewModel?
Some BLoCs in Fedi are actually ViewModels(naming should be refactored):
- Have UI controllers as field: TextEditingController, TabController, ScrollController, etc;
- can have links to other BLoC/ViewModel objects;
- may not have any logic, just listen to UI controllers and call other BLoC methods.
Async init
Sometimes, you need to execute something async Future on object init.
For this case Fedi uses special AsyncInit classes and UI which handle object state:
Isolates
Fedi uses compute for:
- parsing json in the custom API wrapper. Will be changed with dio and retrofit in the future;
- Comparing big collections to find actually new items.
Actually manual(with some specialized package which may use compute() inside) calling compute() is not necessary in almost all cases.
Don’t forget that spawning isolate has its own overhead and in some cases compute() may slow down your app. Why, When, and How you should use isolates ?— Why should you use isolates in Flutter?
Re-run app
Fedi calls runApp() several times. It is useful in some cases.
Splash
Show the first Splash page as soon as possible before all AppScope context is initialized.
Login screen
Check AppScrope and if the user has not logged in Fedi will runApp() with LoginScreen home page
Logged screen
Check AppScrope and if the user has already logged in Fedi will runApp() with SplashScreen home page to initialize UserScope and move to LoggedScreen once initialization is finished.
Several accounts
You can login to several accounts with Fedi at the same time. runApp() is used to switch between accounts.
Fedi doesn’t use StatefullWidget
Fedi uses StatefullWidget only in 2 cases:
- not refactored yet classes
- to use TickerProviderStateMixin
All logic is moved to BLoC(BLoCs in Fedi can be named as ViewModel in some cases):
- TextEditingController, TabController, ScrollController etc
- Initial actions and events subscription in BLOC constructor instead of createState method
- Usually, BLoC for StatelessWidget is created and provided just by the above widget in the hierarchy, so it has the same lifecycle as StatefulWidget. BLoC may be provided on Page or even AppContextScope, so BLoC will maintain state even if the widget is destroyed/recreated.
Streams
Streams are used everywhere in Fedi:
- one BLoC listens to another BLoC/Servies streams events;
- UI updates only by StreamBuilder on streams;
- Moor SQLite ORM provides API to watch(have Stream) for any selected statements;
- much more
Map streams
Fedi often converts Parent Stream type to Child Stream type. It is useful for encapsulation when only Child type is actually needed and Parent is unnecessary
Distinct streams
Stream has a useful distinct() method to avoid emitting value if previous value is the same (don’t forget to override equals & hashcode)
It is useful for performance: you can avoid widgets rebuild via StreamBuilder if it is not necessary
Twice build with StreamBuilder
Fedi uses StreamBuilder a lot and one thing looks weird to me. Build method is called twice on first init. A possible workaround is to Provide.value stream value with const Widget to avoid nested widgets twice rebuilding.
Useful links
- Reactive Programming — Streams — BLoC
- Understanding rxjs BehaviorSubject, ReplaySubject and AsyncSubject (yeah it is for JS but rxdart is similar)
Data consistency & Offline mode
Fedi has an offline mode:
- Caching almost all network data in SQLite database;
- Some data is cached only in memory, but it is acceptable only for small collections to avoid large memory usage;
- Offline mode makes it possible to get access to cached data without a network;
- It improves UX because the user shouldn’t wait for the network. Fedi displays cached data ASAP and updates from the network in the background;
- UI always displays data from Repository classes which holds cached and network data. It is important to have one place where all data is consistent and synchronized. In Fedi all data is synchronized by Moor SQLite ORM. Repository classes just update the database and UI always display the latest available data from the database.
Prefer Interfaces
Prefer interfaces for Bussines Logic and Services
- Simple append I to implementation class name. AccountFollowerAccountCachedListBloc implementation and IAccountFollowerAccountCachedListBloc interface
- Code readability: you can see a small list of public methods/fields in the interface file instead of exploring long files with implementations
- It is useful to implement extensions for interfaces not for implementations
- It is useful to extend several interfaces in one child to separate logic
- It is useful to create tests and mocks
Composition over inheritance
During Fedi development some inheritance became too complex and hard to maintain. Inheritance works cool for interfaces but not for complex implementations.
Useful article: Prefer Composition Over Inheritance
Note: Mixins is composition because you can compose classes with several mixins
Local preferences
Fedi stores simple data (without complex relationships) in simple local preference storage by using hive package
For example UI preferences, Language, appOpenedCount to showRateMe dialog etc
Own BLoC for each key
Since fedi uses a feature-based folder structure, having one file with methods like getUISettings
, getCacgeSettings
etc is not good.
So instead of getUISettings
method Fedi has UiSettingsLocalPreferencesBloc
class.
Usage in Fedi: local_preference_bloc_impl.dart
Objects scheme version
In addition to owning a class for every preference, I also recommend to append schema version to the key.
user.settings.UI -> user.settings.UI.1
This will help in the future when you will change UI settings object structure and want to migrate data from an old version.
Performance
How Fedi improves performance:
- Database: batch transactions & indexes
- Widget with const constructor
- Divides big widgets into small widgets
- calls distinct() on streams for StreamBuilder
- initializes BLoCs asynchronously and displays loading UI
- doesn’t use blur and transparency if possible
- uses vector icons font instead of simple icon files
- always overrides equals & hashcode for data classes
- uses isolates for json parsing & comparing big collections
Don’t optimize too early. Before making any improvements profile your app (Run on Profile mode on real device, not emulator) and check the performance overview. You should have a stable frame chart near 60 fps. In most cases, you will see that existing code works well without optimizations.
Useful article: Performance best practices
Testing
Fedi is covered with unit-tests(unfortunately it is not 100% coverage yet):
- makes app more stable
- helps write cleaner code. It is very hard to write tests for spaghetti code
- helps to be confident during refactoring, you can always check that nothing has been broken
- mockito — cool package to mock dependencies and test things independently. It is much easier to mock interfaces
Fedi doesn’t have UI, integration and functional tests:
- Flutter UI tests don’t look too helpful for me. Testing complex things requires a lot of time. It is good to have but too time costly.
- Integration and functional tests look much more effective for me. Especially for Fedi, which can connect to different Mastodon & Pleroma servers with different versions.
Final Words
Feel free to comment and fill issue if you don’t agree with something or have suggestions.
Next part — Part 2. Code.
Start using Pleroma and Mastodon with Fedi if you still not in Fediverse: iOS(Beta) and Android(Beta). Any feedback is welcome.
If you are interested in Fedi and want to help to develop it you can start from Readme.