Behind the scenes: Architecting robust flutter apps

BTLA Tech Team
6 min readApr 4, 2024

--

This is the second blog of this series of blog posts highlighting our experiences with flutter and what’s next for mobile apps.

Our Flutter app’s architecture is built on modular design, Atomic Design principles, state and dependency management. This approach ensures scalability, maintainability, and consistency across features while facilitating seamless management of requirements.

Building Blocks: Navigating the Architecture of Our Flutter App

Core architecture of flutter codebase

Dependency Injection

Dependency injection enhances modularity, testability, and maintainability by decoupling components and their dependencies. With Injectable and GetIt libraries, we seamlessly adapt to various requirements and use cases, reducing code redundancy and promoting efficiency.

For instance, consider a NetworkManager class responsible for handling API requests. This class abstracts away network details and holds base URLs for APIs. Depending on the environment, two implementations of this class can be created — one for Production (NetworkManagerProd) and one for Staging (NetworkManagerStaging). These implementations encapsulate environment-specific configurations, such as different base URLs.

abstract class NetworkManager {
String getBaseUrl();
// Other network methods
}

class NetworkManagerProd implements NetworkManager {
@override
String getBaseUrl() {
return "https://api.example.com/prod";
}
// Implement network methods for Production environment
}

class NetworkManagerStaging implements NetworkManager {
@override
String getBaseUrl() {
return "https://api.example.com/staging";
}
// Implement network methods for Staging environment
}
void main() {
Environment environment = determineEnvironment(); // Function to determine environment

NetworkManager networkManager;
if (environment == Environment.prod) {
networkManager = NetworkManagerProd();
} else {
networkManager = NetworkManagerStaging();
}

// Register networkManager as a dependency
DependencyRegistry.register<NetworkManager>(networkManager);

// Continue with app initialization
}

Feature Resolver — plugIn play

The FeatureResolver acts as a pivotal component in the application’s architecture, facilitating feature-specific configuration and also enables to achieve a plugin-play style architecture. Within each feature, Feature Resolver is implemented to encapsulate feature-specific modules, including RouterModule, DependencyModule, and ABExperimentModule.

  • RouterModule: Provides feature-specific routing configurations, defining navigation paths and screens within the feature.
  • DependencyModule: Offers a collection of dependencies tailored to the feature, enabling seamless integration of feature-specific services, utilities, and data sources.
  • ABExperimentModule: Facilitates feature-specific A/B testing capabilities, allowing for the experimentation and evaluation of alternative user experiences within the feature.

By leveraging the FeatureResolver, we have achieves modularity and maintainability and empowering individual features to encapsulate their unique configurations and dependencies while promoting scalability and flexibility across the entire codebase.

Feature Resolver Flow Chart
abstract class FeatureResolver {
const FeatureResolver();

RouterModule? get routerModule;

DependencyModule? get dependencyModule;

ABExperimentModule? get abExperimentModule;
}

class HomePageFeatureResolver extends FeatureResolver {
@override
RouterModule get routerModule => HomePageRouterModule();

@override
DependencyModule get dependencyModule => HomePageDependencyModule();

@override
ABExperimentModule get abExperimentModule =>HomePageABExperimentModule();
}

Atomic Architecture: The Cornerstone of Scalable UI

Interface Consistency: Creating Our Internal UI Design System Library

To ensure consistency and streamline UI development across our application, we’ve developed our own internal UI Design System library. This repository acts as a centralised UI component factory, housing a comprehensive collection of reusable components. This UI Design System library empower development team to build consistent UI efficiently.

Streamlining Dynamic Theming with ByjusTheme System

To accommodate the dynamic nature of the application, where users and cohorts experience different themes, we’ve developed the ByjusTheme system control from SDUI (Server Driven UI). This system provides a cohesive approach to manage cohort based themes across all platforms efficiently.

The ByjusTheme class serves as the backbone of the theming system, encapsulating essential design elements such as colors, dimensions and typography.

class ByjusTheme extends InheritedWidget {
final ByjusColors colors;
final ByjusDimens dimens;
final ByjusMonotones monotones;
final ByjusTypography typography;
final ByjusDefaults defaults;

ByjusTheme({
Key? key,
required this.colors,
required this.dimens,
required this.monotones,
required this.typography,
required this.defaults,
required Widget child,
}) : super(key: key, child: child);

@override
bool updateShouldNotify(covariant ByjusTheme oldWidget) {
return listEquals(props, oldWidget.props);
}

static ByjusTheme of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ByjusTheme>()!;
}

List<Object?> get props => [colors, dimens, monotones, typography, defaults];
}

With the ByjusTheme system in place, applying themes to UI elements becomes straightforward. Here’s an example of how to use the ByjusTheme to style a Text widget:

Text(
"Shorts Content",
style: ByjusTheme.of(context).typography.sectionHeader.copyWith(
color: ByjusTheme.of(context).monotones.c60,
),
textAlign: TextAlign.left,
),

By leveraging the ByjusTheme system, our application effortlessly adapts to various themes, ensuring a consistent and delightful user experience across different cohorts and user groups.

UI Library

At the heart of our UI Core library lies a collection of common components, meticulously crafted to adhere to the Atomic Design principles. Let’s take a look at an examples:

import 'package:flutter/material.dart';

class RoundedButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;

const RoundedButton({required this.text, required this.onPressed});

@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 5,
offset: Offset(0, 3),
),
],
gradient: LinearGradient(
colors: [Colors.purple.shade600, Colors.purple.shade800],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20.0),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 20),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}
}

Here we have created a button component which we can now use freely across our application wherever required.

import 'package:flutter/material.dart';
// Import the rounded button component
import 'package:byjus_ui_core/rounded_button.dart';

class YourScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Dummy Screen'),
),
body: Center(
child: RoundedButton(
text: 'Submit',
onPressed: () {
// Add your onPressed logic here
print('Button Pressed!');
},
),
),
);
}
}

As we can see that the actual code for the screen is effortlessly optimized and allows us to write complex yet readable code which is not only easy to scale but also reduces development time significantly.

Benefits of Our Atomic Design Library

  • Consistency: By adhering to Atomic Design principles, our UI components maintain a consistent look and feel across the application.
  • Scalability: The modular nature of Atomic Design allows for easy scalability as our application grows, facilitating the addition of new components and patterns.
  • Code Reusability: Components in our Atomic Design library are designed for reuse, reducing redundancy and promoting efficient development practices.

This approach ensures that our UI development process is not only efficient but also conducive to building robust, scalable applications that resonate with our users across different products and offerings.

Tech Stack and Frameworks

1. Flutter Bloc

We chose the BLoC (Business Logic Component) pattern for state management, providing robust separation of concerns and reactive UIs.

Benefits and Use Cases:

  • Allows tracking of application state at any given moment.
  • Facilitates recording user interactions for data-driven decisions.
  • Speeds up development with a reactive UI experience.

Alternative: Provider

While Provider is popular, we found BLoC more suitable due to its clear separation of concerns.

2. Database: Isar

We opted for Isar, a lightweight NoSQL database, for persistence, offering efficient solutions and reactive UI updates.

Benefits and Use Cases:

  • Ensures application responsiveness even under poor network conditions.
  • Provides efficient methods integrating seamlessly with our reactive UI system.

Alternative: Hive

While Hive is popular, we found Isar better suited for its superior performance and built-in support for reactive UI updates.

3. Dependency Injection: Injectable and GetIt

  • We leverage dependency injection for managing instances across the application, ensuring flexibility and maintainability.

Benefits and Use Cases:

  • Simplifies management of instances.
  • Enables seamless reuse without explicit implementation definitions.

Alternative: Singleton

  • While singletons could address managing instances, they fall short when dealing with multiple implementations for different requirements. We opted for Injectable and GetIt for a more scalable solution.

--

--

Responses (1)