LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

Why we rewrote an app with Flutter - The second attempt at recoding one of Japan's largest delivery apps

Hello, we're Jong Sic Kim and Sanghyuk Nam from LINE Plus ABC Studio, developing apps. Our team is currently working on the delivery service "Demae-can" in Japan. "Demae-can" is one of Japan's largest food delivery services, which started in 2000 and later formed a capital and business alliance with LY Corporation (formerly LINE) in 2020. ABC Studio has been involved in its development since spring of 2021.

Our team previously shared our experience of transitioning from "React Native" to "Kotlin Multiplatform Mobile" (KMM) in the article "Rebuilding one of Japan's largest delivery apps from the ground up - The Recode Project". The Recode project involves maintaining the same visible specs while changing the code and architecture. In this article, we want to share why we decided to Recode from KMM to Flutter and our experiences using Flutter.

First "Recode" story

In the first Recode, we reviewed various components of the Demae-can service from multiple angles, such as urgency, impact, stability, and business perspective, and decided to Recode the delivery app first. For more details on the Recode project, check out "Rebuilding one of Japan's largest delivery apps from the ground up - The Recode Project". The delivery app is relatively simple, mainly focusing on delivery functions. We chose KMM among various mobile multiplatform development technologies for the following reasons:

  • The features provided during the first Recode, called Delivery App 1.0, involved about 14 fragments, which wasn't a significant burden. Our team was open to trying new technologies, so we boldly attempted it.
  • Our team consisted of native developers, so the learning cost wasn't a burden. We decided it was better to respect UI/UX development suitable for each platform. Additionally, it allowed us to flexibly respond to situations requiring OS updates or changes to the latest SDK.
  • We aimed to develop a common business logic to increase code reusability and design a structure that all team members could understand with a single code base.
  • To write testable code, we attempted declarative UI writing and implemented the Model-View-StateModel (MVSM) concept to manage UI state and development speed simultaneously.

While conducting the Recode, we also intended to replicate existing requirements. We decided to replicate the specs at the code level while developing the product using new technology. We developed the app to display the UI identically by running the existing app and understanding the meaning of each function by examining the internal code. We found it faster and more efficient to redefine the latest specs at the code level rather than incurring communication costs to verify each spec, as the existing spec documents were sometimes outdated or unclear.

Reason for starting the second recode

After completing the first Recode, Demae-can started the "Delivery 3.0" project to address the inconveniences of the existing system for delivery workers. This project aimed to improve the matching algorithm between orders and delivery personnel, add multi-delivery features, and implement automation where needed. Naturally, we had to consider not only a complete redesign of the delivery app but also technical aspects. The multi-delivery feature required structural changes to more than a third of the app, and since the app had to provide both 1.0 and 3.0 modes, we reconsidered Recode.

Delivery 1.0Delivery 3.0

Looking back after operating for three months post-first Recode, we realized KMM still had shortcomings. As the application became more complex, the lack of integration with iOS increased debugging trial and error. No matter how great the architecture is, maturity is a separate issue.

- Reflection on the first Recode

After the first Recode, we expected Android developers to adapt more easily to KMM, but it wasn't as easy as we thought. The most challenging part was that it was difficult to operate KMM without a proper understanding of iOS. Since the code is converted from Kotlin to Objective-C, debugging wasn't easy when errors occurred, and the messages displayed in Crashlytics were also unhelpful. We hoped to share many parts of Android and iOS in KMM so that all team members could develop together, but in the end, each person only developed their assigned part. We felt that there was a lack of technological synergy at the team level, and growth was slow. We encountered problems whenever a new OS version was released, but the improvement speed of KMM by Jetbrain wasn't fast enough to account for this (alpha was released in August 2020, and beta was released in October 2022).

Why we chose Flutter

Since the Demae-can service was developed with a unique design, we considered technology that would allow all mobile developers to write common UI code. Among mobile multiplatform technologies, RN and Flutter are representative technologies that enable complete app development.

Google Trends - Flutter vs React Native (in Japan)

We chose Flutter as the technology for the second Recode for the following reasons:

  • Flutter has recently enabled not only mobile app development but also PC and web development, securing platform scalability. It has been rapidly evolving since its release, making it easier to obtain necessary information.
  • It allows for the composition of UI with common code and offers high flexibility. Especially since the satisfaction with Android Compose UI development was high during KMM development, it aligned with our goal to fully transition to a declarative UI paradigm.
  • The risk in terms of performance compared to native was low, and when developing native features, there were no significant constraints when using MethodChannel.
  • We could use Android Studio, which all team members were familiar with, as the integrated development environment (IDE) (reference).

Let's rebuild with Flutter 😊

Flutter uses the Dart language. Therefore, you can't properly develop with Flutter without becoming familiar with Dart. For first-timers, referring to the Dart tour documentation is a good way to quickly grasp the language's features. Once you understand the broad concepts of Dart, reading the Flutter Codelab page can help you understand Flutter more easily.

The Recode started with learning Dart and getting familiar with Flutter, taking about three months from start to release. Since we had to provide both 1.0 and 3.0 modes within a limited period, we learned Dart and Flutter while simultaneously separating specs, view states, and logic, progressing with learning and development at the same time. At the start of the Recode, we used Flutter SDK 3.0.2, and by the time of release, we developed with Flutter SDK 3.0.5.

Since Flutter involves declarative UI development, the biggest concern is UI state management. Regarding state management, there are packages like provider, bloc, and getx. When we started the Recode, we proceeded with a direct implementation due to the learning cost of packages and the burden of package dependencies. We used ChangeNotifier for state changes, and state groups were managed with StateModel. We set a principle to implement directly unless it was a package essential for product development (for example, FlutterFire, google_maps_flutter, and so on).

Examples of higher implementation difficulty compared to KMM

While rebuilding with Flutter, there were parts where the implementation difficulty was higher compared to KMM. For scenarios like obtaining permissions or receiving push notifications, there are differences in the development guidelines suggested by each platform. During KMM, we implemented the native areas separately, so the code complexity wasn't high. However, when converting to Flutter, writing in a single code often resulted in branching code according to the platform, which hindered code readability. Below is the flow after converting to Flutter and running the Android and iOS apps.

  • Android app execution flow

  • iOS app execution flow

Examples where SDK or package operation differed by platform

There were cases where the SDK or package operation differed slightly from expectations by platform. For example, when implementing code to generate vibration effects within the app, it had to be implemented differently for each platform. On Android, using the interface provided by the Vibration package, and on iOS, using the interface provided by the Flutter SDK, worked best for the requirements. The spec defined a number of vibrations such as 300ms, 500ms, 1000ms, but there were differences in operation by platform, requiring branching during implementation.

de_theme_effect.dart
void _vibrate300() async {
    if (PlatformUtils.isAndroid) {
        if (await Vibration.hasVibrator() == true) {
            Vibration.vibrate(duration: 300);
        }
    } else if (Platform.isIOS) {
        HapticFeedback.lightImpact();
    }
}
 
void _vibrate500() {
    /// implements
}
 
void _vibrate1000() {
    /// implements
}
The code above is being gradually abstracted and refactored into platform-specific implementations with test code after the Recode release, making it more readable and maintainable. Below is the improved result of the code above.
de_theme_effect.dart
// The interface is reused.
void _vibrate300() => VibrationHelper.vibrate(VibrationEffect.effect300);

...

enum VibrationEffect { effect300, effect500, effect1000 }

class VibrationHelper {
	// vibration only support Android, iOS platform.
	static void vibrate(VibrationEffect effectType) {
		// Note) PlatformUtils also an abstract class of each platform implementations.
		if (PlatformUtils.isAndroid) {
			_vibrateAndroid(effectType);
	    } else if (PlatformUtils.isIOS) {
			_vibrateIOS(effectType);
    	} else {
			log('${effectType.name} vibrate fail, Unsupported platform type');
		}
	}

	static void _vibrateAndroid(VibrationEffect effectType) async {
    	// implementation as a vibration package.
		if (await Vibration.hasVibrator() == true) {
			switch (effectType) {
				case VibrationEffect.effect300:
					Vibration.vibrate(duration: 300);
				break;
				case VibrationEffect.effect500:
					Vibration.vibrate(duration: 500);
          		break;
		        case VibrationEffect.effect1000:
          			Vibration.vibrate(duration: 1000);
		        break;
			}
		}
	}

	static void _vibrateIOS(VibrationEffect effectType) {
		// implementation as a flutter(default) package.
		switch (effectType) {
			case VibrationEffect.effect300:
				HapticFeedback.lightImpact();
			break;
			case VibrationEffect.effect500:
				HapticFeedback.mediumImpact();
			break;
			case VibrationEffect.effect1000:
				HapticFeedback.heavyImpact();
			break;
		}
	}
}

Troubleshooting examples

I'll introduce the main troubleshooting examples we experienced during the second Recode process.

First, I'll introduce two issues related to Google Maps.

App performance degradation due to side effects after dependency update

In the delivery app, the delivery worker's location, store location, and customer location are displayed as markers on the map. The map uses the Google Maps package, and to use Google Maps, we added the dependency using the caret syntax in pubspec.yaml. At some point, a significant performance degradation issue occurred, and upon checking, we found that the version of google_map_flutter had been updated, updating the dependencies of each platform implementation. Below is a comparison of pubspec.lock before and after the issue occurred.

pubspec.lock when working normally
google_maps_flutter:
  dependency: "direct main"
  description:
    name: google_maps_flutter
    url: "https://pub.dartlang.org"
  source: hosted
  version: "2.1.9"
google_maps_flutter_platform_interface:
  dependency: transitive
  description:
    name: google_maps_flutter_platform_interface
    url: "https://pub.dartlang.org"
  source: hosted
  version: "2.2.1"
pubspec.lock after performance issue occurred
google_maps_flutter:
  dependency: "direct main"
  description:
    name: google_maps_flutter
    url: "https://pub.dartlang.org"
  source: hosted
  version: "2.2.0"
google_maps_flutter_android:
  dependency: transitive
  description:
    name: google_maps_flutter_android
    url: "https://pub.dartlang.org"
  source: hosted
  version: "2.3.0"
google_maps_flutter_ios:
  dependency: transitive
  description:
    name: google_maps_flutter_ios
    url: "https://pub.dartlang.org"
  source: hosted
  version: "2.1.11"
google_maps_flutter_platform_interface:
  dependency: transitive
  description:
    name: google_maps_flutter_platform_interface
    url: "https://pub.dartlang.org"
  source: hosted
  version: "2.2.2"
The cause of the issue was a side effect that occurred when the version of google_map_flutter was updated to 2.2.0, updating the dependency of the google_map_flutter_android package. To resolve the issue, we modified the version of the google_maps_flutter package declared in pubspec.yaml to use a fixed version instead of the caret syntax.
pubspec.yaml
# AS-IS: Performance degradation due to version-up
dependencies:
	google_maps_flutter: ^2.1.3 // It means '>=2.1.3 <3.0.0'

# TO-BE: Specify as fixed version
dependencies:
	google_maps_flutter: 2.1.9

Crash occurring in the native area

Another issue related to Google Maps occurred. After release, the crash-free rate (the percentage of users who did not experience abnormal termination during a specific period) on the Android version was around 96-97%, indicating an unstable state. The Crashlytics log showed crashes occurring in the native area, such as ByteBuffer or Thread, making it difficult to pinpoint the exact cause. Analyzing the logs generated during crashes revealed that this situation occurred when a forced logout led to a transition to the login screen. The delivery app displays Google Maps on the screen upon successful login, and it was expected that crashes occurred when the screen displaying the map disappeared due to screen transitions.

This issue was known to be resolved by using the LATEST rendering option of Google MapView (reference). Referring to this, we modified the Android native source code to stabilize the crash-free rate to over 99%. From version 2.4.0 of the google_maps_flutter_android package, the LATEST rendering option is provided, allowing the rendering option to be modified in the Flutter source code as well. For more information, refer to the issue registered in google_maps_flutter (reference).

Gesture malfunction issue

The screen below is the order detail confirmation screen of the delivery app. On this screen, you can swipe left and right to close the page, swipe left and right within the PageView to move between tab screens, and perform pinch zoom gestures within Google Maps. However, an issue occurred where pinch zoom within Google Maps did not work on Android devices.

There were two issues related to gestures.

Google Maps gestures not working properly due to multiple gestures competing in Gesture Arena

In Gesture Arena, multiple gestures such as page swipe gestures, page back gestures, vertical scroll gestures, and map gestures competed, causing Google Maps gestures not to work properly. To receive all touch events immediately without competing in Gesture Arena, you need to add EagerGestureRecognizer to gestureRecognizers.

EagerGestureRecognizer is typically passed in AndroidView.gestureRecognizers and immediately sends all touch events within the view range to the built-in Android View, allowing all touches to be received without competing in Gesture Arena. It can be useful for views like Google Maps that need to receive all events immediately.

Multi-touch not working properly on Google Maps on the first page when moving PageView

This issue occurred because when viewportFraction is the default value of 1.0, it wasn't considered that the page had completely moved, so the focus wasn't fully transferred. It could be resolved by setting the allowImplicitScrolling property of PageView to true, allowing the focus to move when it is determined that the next screen element has been moved to.

Japanese Kanji displayed as Traditional Chinese

An issue occurred where some characters were displayed as Chinese (Traditional) instead of Japanese on certain iOS devices. For example, in the phrase "配達設定 (Delivery Settings)", the Japanese Kanji "設" was displayed as the Chinese Kanji "設". It was expected that this issue might occur depending on the font provided by the device or OS.

Delivery app settings screen > Chinese Kanji display issue

To resolve this issue, we set the locale property in the TextStyle to match each language, but it didn't resolve the issue. We determined that there might be cases where the default iOS font does not properly support Japanese Kanji, and we resolved it by setting the fontFamilyFallBack property to ['.AppleSystemUIFont', 'Hiragino Sans']. Below is the result of the code execution.

Display difference of "配達設定 (Delivery Settings)" phrase by font
ChineseJapanese
...
Column(
	mainAxisAlignment: MainAxisAlignment.start,
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
        Text(
			'配達設定 (Delivery Settings)',
			style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)
		),
		Text(
            '配達設定 (Delivery Settings)',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)?.merge(TextStyle(
               fontFamilyFallback: Platform.isIOS ? const ['.AppleSystemUIFont', 'Hiragino Sans'] : null,
               leadingDistribution: TextLeadingDistribution.even,
            )
		),
	),
])

Screen not displaying properly when text size is set to minimum or maximum

iOS and Android apps allow you to change the display and text size in the device settings. An issue occurred where the screen did not display properly when this option was set to the minimum or maximum in the device settings. To fix this, we discussed with the design team and used the MediaQuery widget to limit the textScaleFactor to 0.7 to 1.4, and created widgets with fixed text sizes as needed.

DeWidgetLimitedScaleText
class DeWidgetLimitedScaleText extends StatelessWidget {
  final bool isScalable;
  final Widget child;

  const DeWidgetLimitTextScale({Key? key, this.isScalable = true, required this.child}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    return MediaQuery(
      data: mediaQuery.copyWith(textScaleFactor: isScalable ? min(1.4, max(0.7, mediaQuery.textScaleFactor)) : 1.0),
      child: child,
    );
  }
}

Using the firebase_messaging package allows FCM push reception processing when the app is in the background (reference). However, when accessing SharedPreference at this time, an error occurs with the message "When received, an isolation is spawned. Since the handler runs in its own isolate outside your application's context, it is not possible to update application state or execute any UI impacting logic."

Dart apps are single-threaded frameworks and operate in isolates instead of threads. Isolates can be created and communicate with each other, but each isolate is allocated its own memory heap, so there is no shared memory. Since the push callback in the background is delivered in a new isolate, the memory data of the main isolate cannot be used, resulting in the above error. This issue was resolved by creating a utility that can store and manage data in JSON format files under the app directory, and using this utility when local data storage is needed upon FCM reception.

Strengthened teamwork through technology change

During the second Recode process, we learned new technology while simultaneously creating a product using that technology. Although there were various concerns due to not fully understanding Flutter technology, there was a lot of positive feedback in the end. In the reflection on the second Recode project, the most agreed-upon point was that it became possible to discuss and debate with the same source code after transitioning from KMM to Flutter.

When developing with KMM, developers focused only on their respective areas of work in Android, iOS, and KMM, concentrating only on their areas of interest. This ultimately left something to be desired in terms of collaboration and technical growth from a long-term perspective. By transitioning to Flutter, we had the opportunity to think, learn, and reflect on that content in the actual product using the technology of Flutter and the language of Dart. The urgent situations that arose during that process were also reflected more positively than expected. We felt that when a small team (3-4 people) develops an app, working together in the same language can provide greater motivation in terms of growth and collaboration.

KMMFlutter

The consensus has broadened from the perspective of the entire team. Not only the delivery app but also the franchise app and retail app in the Demae-can service are using Flutter, so technical exchange has started at the team level, and it has become possible to develop common modules used across each app, improving collaboration. We can now share IDE, CI/CD environments, or the architecture of each project, and we can think and share refactoring or test code together, which greatly helps both individuals and the entire team grow together. For more information on creating and utilizing common modules, refer to "Refactoring Common Modules with Flutter Package (article in Korean)". 😊

In conclusion

If you're considering Flutter as a mobile app development technology, I recommend it for the following reasons:

  • You can develop multiplatform with limited resources. You can develop not only for Android and iOS but also for PC and web, allowing you to verify products in various forms and configure QA environments advantageously.
  • Multiplatform technology for app developers is a significant challenge for native developers to take the next step, so I think it's a great opportunity to build a career and increase competitiveness in the job market.
  • If you're considering an environment for both individuals and teams to think and grow, I think it's a good choice.

After the Recode, the delivery app is focusing on modularizing with Flutter and writing test code to provide better value to users in the new version. We're considering better state management methods, efficient dependency package version management methods, faster build and deployment automation configuration methods, and ways to create new products better with Flutter developers (I think most developers can relate to these parts 😊). I conclude this article by saying that I patiently anticipate, with a little bit of excitement, a situation where we need to conduct a third Recode to provide better service to our users.