Skip to main content

55 posts tagged with "Kotlin"

View All Tags

The Senior Android Architect's New Job: Managing Junior AI Developers

Published: · 10 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

The day-to-day reality of a Senior Android Architect has changed more in the past 18 months than in the preceding decade.

You are no longer spending your afternoons handcrafting ViewModel boilerplate, writing repetitive Room entity mappers, or wiring navigation graphs. Instead, AI coding assistants - whether it is Gemini in Android Studio, GitHub Copilot, Cursor, or Claude Code - are acting as an army of hyper-productive, 24/7 Junior Developers embedded directly in your IDE.

They can generate hundreds of lines of code per minute. They never complain about merge conflicts. They know every syntax quirk of Kotlin 2.0 and Jetpack Compose.

And they have absolutely zero architectural conscience.

Modern LLMs behave like eager junior engineers: they memorized the documentation five minutes ago, but have never debugged a silent memory leak at 2 AM, never diagnosed an Application Not Responding (ANR) lock across fragmented OEM devices, and never had to justify a conversion drop to executive leadership.

When code generation velocity increases 10x, regression density increases with it. Your primary role is no longer just typing code. You are the Tech Lead, Code Reviewer, and Architectural Supervisor performing continuous business impact analysis on AI-generated software.

The 4 Cardinal Sins Junior AI Developers Commit in Android​

1. Swallowing CancellationException in Coroutines​

LLMs reflexively wrap asynchronous calls in catch-all try/catch blocks:

// ❌ AI-GENERATED: Silently breaks Coroutine cancellation!
viewModelScope.launch {
try {
val userProfile = repository.fetchUserProfile(userId)
_uiState.value = ProfileUiState.Success(userProfile)
} catch (e: Exception) {
// ⚠️ LANDMINE: Catches CancellationException!
_uiState.value = ProfileUiState.Error("Failed to load profile")
}
}
  • The Problem: In Kotlin Coroutines, cancellation is cooperative. When a user navigates away, the runtime injects CancellationException. Because CancellationException inherits from java.lang.Exception, the AI's catch block intercepts it, treats cancellation as a business error, and keeps the coroutine alive as a zombie job.
  • The Architect's Fix:
    // ✅ ARCHITECTURAL STANDARD: Preserve the cancellation contract
    catch (e: Exception) {
    if (e is CancellationException) throw e // Mandatory rethrow
    _uiState.value = ProfileUiState.Error(e.localizedMessage ?: "Unknown error")
    }

2. Triggering Recomposition Storms in Jetpack Compose​

AI defaults to standard collections, completely ignoring Compose compiler stability:

// ❌ AI-GENERATED: Unstable parameters ruin LazyColumn recycling
@Composable
fun ProductCatalog(
items: List<ProductItem>, // ⚠️ Unstable type!
onItemClick: (String) -> Unit
) {
LazyColumn {
items(items) { item -> // ⚠️ Missing explicit key!
ProductCard(item = item, onClick = { onItemClick(item.id) })
}
}
}
  • The Problem: Standard List<T> is an interface that can be backed by a mutable list, leading the Compose compiler to treat its contents as potentially dynamic. Although Kotlin 2.0.20+ enables Strong Skipping Mode by default to mitigate unnecessary recompositions, parameter stability still plays a critical role in runtime predictability, smart recomposition skipping, and state tree optimization during complex UI updates. Furthermore, missing explicit keys in lazy layouts forces node recreation instead of reuse during scrolling, causing frame drops.
  • The Architect's Fix: Use Kotlinx Immutable Collections and pass explicit identity keys:
    // ✅ ARCHITECTURAL STANDARD: Guaranteed stability & skippability
    import kotlinx.collections.immutable.ImmutableList

    @Composable
    fun ProductCatalog(
    items: ImmutableList<ProductItem>,
    onItemClick: (String) -> Unit,
    modifier: Modifier = Modifier
    ) {
    LazyColumn(modifier = modifier) {
    items(
    items = items,
    key = { it.id }, // Stable identity for node recycling
    contentType = { "product_card" }
    ) { item ->
    ProductCard(item = item, onClick = onItemClick)
    }
    }
    }

3. The Background Flow Lifecycle Leak​

When generating state collection, AI routinely defaults to collectAsState():

// ❌ AI-GENERATED: Leaks flow collection when app is backgrounded
@Composable
fun DashboardScreen(viewModel: DashboardViewModel) {
val state by viewModel.uiState.collectAsState()
DashboardContent(state = state)
}
  • The Problem: collectAsState() is bound strictly to the Composition, ignoring the Android Activity lifecycle. When the user backgrounds the app, the Flow continues pulling updates, reading database cursors, and wasting battery.
  • The Architect's Fix: Always enforce collectAsStateWithLifecycle():
    // ✅ ARCHITECTURAL STANDARD: Lifecycle-aware flow subscription
    import androidx.lifecycle.compose.collectAsStateWithLifecycle

    @Composable
    fun DashboardScreen(viewModel: DashboardViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    DashboardContent(state = state)
    }

4. The Out-of-Order Concurrency Race Condition​

For search or real-time filtering, AI frequently combines debounce() with sequential collect():

// ❌ AI-GENERATED: Out-of-order race condition
searchQuery
.debounce(300L)
.collect { query ->
val results = api.search(query) // Stale query can overwrite newer fast query!
_searchResults.value = results
}
  • The Problem: Because collect{} processes items sequentially, each network call blocks the flow collector until it completes. If a user rapidly types "pix" and then "pixel", the initial request for "pix" continues executing in full rather than being cancelled. This creates unneeded network overhead, delays the response time for the latest query ("pixel"), and wastes system resources on outdated requests.
  • The Architect's Fix: Use flatMapLatest to automatically cancel in-flight stale requests:
    // ✅ ARCHITECTURAL STANDARD: Auto-cancelling reactive pipeline
    val searchResults: StateFlow<SearchUiState> = searchQuery
    .debounce(300L)
    .distinctUntilChanged()
    .flatMapLatest { query ->
    if (query.isBlank()) flowOf(SearchUiState.Idle)
    else repository.search(query)
    }
    .stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5_000L),
    initialValue = SearchUiState.Idle
    )

The Senior Architect's Governance Playbook​

As an architect, your value is measured by the quality of the boundaries and guardrails you enforce.

1. Prompt Like an Architect, Not a Programmer​

Don't ask AI: "Write a repository for fetching transactions."

Specify architectural invariants up front:

"Implement TransactionRepository using Room and Retrofit. Enforce offline-first Single Source of Truth where Room emits the reactive Flow. Confine all disk/network IO to Dispatchers.IO. Ensure all Coroutine exceptions rethrow CancellationException. Use @Upsert for atomic SQLite caching."

2. Automate Architecture Guardrails in CI​

Catch AI bugs deterministically before code review:

  • Compose Compiler Metrics: Enable compiler stability flags (-Pplugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=...) to generate stability reports, then pair them with custom CI scripts or strict check tasks to track non-skippable composables across PRs.
  • Konsist / ArchUnit: Enforce architectural boundaries with unit tests:
@Test
fun `viewmodels should never expose mutable stateflow` () {
Konsist.scopeFromProject()
.classes()
.withNameEndingWith("ViewModel")
.properties()
.withoutModifier(KoModifier.PRIVATE)
.assertFalse { it.hasType("kotlinx.coroutines.flow.MutableStateFlow") }
}
  • Custom Lint Checks: Ban collectAsState() in favor of collectAsStateWithLifecycle().

3. Conduct Continuous Business Impact Analysis on AI-Generated Code​

AI code passes happy-path tests in an emulator, but production environments expose subtle concurrency contention, thread starvation, and non-fatal ANRs across thousands of physical device configurations.

When AI writes code at scale, engineering backlogs quickly flood with edge cases. You cannot fix everything at once. This is where Business Impact Analysis (BIA) becomes an architect's most strategic competency.

Traditional crash reporting tools excel at tracking unhandled fatal exceptions, but they often lack direct visibility into user journey friction. When AI code freezes the UI thread on an unindexed SQLite query or swallows an error into an infinite loading spinner, standard crash logs can report a misleading 99.9% crash-free rate despite users experiencing broken workflows.

Performing automated Business Impact Analysis bridges technical health with financial realities:

  • Quantify Conversion Loss Over Bug Frequency: A background logging exception occurring 50,000 times typically has negligible commercial impact. For example, an intermittent 3,000 ms payment thread stall affecting 200 users puts thousands of dollars in potential conversion revenue at risk.
  • The Appxiom Quality Score (QS): Rather than relying solely on binary crash metrics, modern architects rely on Appxiom to provide a weighted 0 to 10 release health index. QS evaluates silent freezes, ANRs, and memory pressure warnings across staged rollouts to give a comprehensive, data-driven view of stability.
  • Goal Friction Impact (GFI): Appxiom's continuous business impact analysis automatically connects technical anomalies to specific business milestones (Complete Checkout, KYC Verification), quantifying the estimated revenue at risk from AI-generated bugs.
  • The Activity Trail: Traces the chronological sequence of user interaction events and network calls leading to a UI freeze or stall - pinpointing the exact AI-generated race condition in seconds without privacy compromises.

The Evolution of the Senior Android Architect​

DimensionThe Traditional Android LeadThe AI-Era Android Architect
Primary FocusHandcrafting Kotlin code & PR authoringArchitectural system prompts & boundary governance
Code ReviewNaming conventions, syntax, and logicAuditing concurrency, lifecycle contracts, and stability
Triage StrategyPrioritizing by crash count or developer gut feelingPrioritizing via Business Impact Analysis (BIA)
ToolingGradle scripts and IDE shortcutsKonsist rules, compiler metrics, and automated linters
Error HandlingNull safety and basic try/catch checksEnforcing cooperative cancellation & structured concurrency
Quality MetricThe 99.9% crash-free session rateAppxiom Quality Score (QS) & Goal Friction Impact

Conclusion: Lead the Machine​

AI coding assistants are not going to replace Senior Android Architects.

Instead, they make deep architectural judgment more valuable than ever. When anyone can produce hundreds of lines of code in seconds, the engineer who understands coroutine hierarchies, memory management, Compose stability contracts, and business impact analysis becomes the most critical asset in the organization.

Stop writing boilerplate. Start managing your junior AI developers like a seasoned engineering leader.

Further Reading & Resources​

Production Memory Diagnostics: Tracking Leaks, OOMs, and Jetsam in Mobile Apps

Published: · 13 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

In local development, diagnosing a memory leak is a solved problem.

You fire up Android Studio Memory Profiler or Xcode Instruments Allocations, attach a debugger, record a trace, inspect the object graph, or let LeakCanary dump a heap snapshot. Within minutes, you can pinpoint the exact retain cycle or static reference pinning an Activity or UIViewController in RAM.

In production, that luxury vanishes.

Running a heap dump in a live production app is an engineering non-starter. A standard Android heap dump (Debug.dumpHprofData()) freezes the main thread for 10 to 30 seconds, creates an uncompressed 500MB+ .hprof file on user storage, risks exfiltrating unredacted Personally Identifiable Information (PII), and frequently triggers operating system watchdogs. On iOS, you cannot capture memory graphs or attach Instruments to App Store binaries at all.

Even worse, Out-Of-Memory (OOM) events are the most silent killers in mobile engineering.

When an iOS device runs out of physical RAM, Apple's Jetsam kernel daemon terminates your process with a SIGKILL. On Android, the LowMemoryKiller daemon (lmkd) sends a silent kill to reclaim pages.

Neither operating system gives your application time to execute an uncaught exception handler. Traditional crash reporting SDKs register zero crash logs. To your backend and analytics dashboards, the user simply disappeared - or worse, the app restarts cleanly on next launch, disguising catastrophic memory exhaustion as ordinary user session churn.

Here is how modern engineering teams move beyond development-only profilers to implement Production Memory Diagnostics - tracking runaway allocations, memory leaks, and OS termination events without degrading app performance.

Production Memory Diagnostics is the practice of continuously monitoring runtime memory health (Resident Set Size, dirty physical footprint, system memory pressure warnings, and screen-by-screen memory deltas) on live user devices without capturing heavy heap dumps. By correlating memory growth with user journeys and lifecycle events, production diagnostics identify retain cycles, native cache bloat, and LowMemoryKiller (LMK)/Jetsam terminations before they cause silent churn.

The Anatomy of Silent Memory Kills​

To diagnose production memory issues, you must first understand how modern mobile operating systems enforce memory limits and terminate processes.

1. iOS: The Jetsam Mechanism (FOOM)​

iOS devices operate without a traditional disk swap file. To manage memory pressure, iOS relies on memory compression and aggressive process termination enforced by the kernel daemon memorystatus (commonly known as Jetsam).

When an app's dirty physical memory footprint crosses the system limit (which varies by device model and memory tier - often between 1.2GB and 2.2GB on modern iPhones, but as low as 350MB on older iPads or memory-constrained extensions), Jetsam sends an uncatchable SIGKILL.

The Virtual Memory Trap​

Many developers attempt to track memory in Swift by reading task_basic_info.virtual_size. This metric is virtually useless: virtual size includes clean mapped read-only files (such as .dylib frameworks and asset catalogs) that the OS can evict at zero cost.

Jetsam only cares about Physical Footprint - dirty pages that cannot be compressed or paged out.

Low-Overhead Physical Footprint Telemetry in Swift​

To capture actual memory pressure in production, you must query the Mach kernel's task_vm_info:

import Foundation
import MachO

public struct MemoryDiagnostics {

/// Returns the exact physical memory footprint (in Megabytes) tracked by Jetsam.
public static func getPhysicalMemoryFootprintMB() -> Double? {
var vmInfo = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)

let result = withUnsafeMutablePointer(to: &vmInfo) { ptr in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), intPtr, &count)
}
}

guard result == KERN_SUCCESS else { return nil }

// phys_footprint is the exact metric used by iOS Jetsam to trigger kills
let footprintBytes = Double(vmInfo.phys_footprint)
return footprintBytes / (1024.0 * 1024.0)
}

/// Queries remaining memory available to the process before system intervention
public static func getAvailableMemoryMB() -> Double {
return Double(os_proc_available_memory()) / (1024.0 * 1024.0)
}
}

By querying os_proc_available_memory() upon entering critical funnels, your app can dynamically evict caches before Jetsam strikes.

2. Android: The LowMemoryKiller (LMK) & The OOM Paradox​

On Android, memory management is split across two layers:

  1. The ART (Android Runtime) Java/Kotlin Virtual Heap: Subject to garbage collection with an upper limit defined by Runtime.getRuntime().maxMemory().
  2. The Linux Kernel & Native Allocations: Governed by the lmkd daemon, which monitors system-wide RAM and kills processes based on their oom_score_adj priority.

The "Straw That Broke the Camel's Back" Fallacy​

When an Android app crashes with a fatal java.lang.OutOfMemoryError, developers almost always misdiagnose the root cause by staring at the stack trace:

java.lang.OutOfMemoryError: Failed to allocate a 32784 byte allocation with 12582912 free bytes and 12MB until OOM
at java.lang.StringFactory.newStringFromChars(StringFactory.java:328)
at java.lang.StringBuilder.toString(StringBuilder.java:407)
at com.example.app.ui.cart.CartAdapter.onBindViewHolder(CartAdapter.kt:42)

The stack trace points to a 32KB string allocation inside CartAdapter. Developers spend days optimizing StringBuilder usages in the cart screen.

In reality, the cart adapter was blameless. Five minutes earlier, a background image pipeline leaked five 50MB uncompressed Bitmap instances because an Activity failed to unsubscribe from an EventBus. The heap was already at 99.8% capacity; the 32KB string just happened to be the unlucky allocation that tripped the wire.

Production Memory Telemetry in Kotlin​

To monitor real memory consumption and intercept OS warnings before LMK strikes:

package com.appxiom.diagnostics

import android.app.ActivityManager
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.os.Debug
import android.os.Process

class ProductionMemoryObserver(private val context: Context) : ComponentCallbacks2 {

fun captureMemorySnapshot(): MemorySnapshot {
val runtime = Runtime.getRuntime()
val javaHeapUsedMB = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
val javaHeapMaxMB = runtime.maxMemory() / (1024 * 1024)

// Native allocation tracking (Bitmaps, C++ libs, OpenGL/Vulkan buffers)
val nativeHeapAllocatedMB = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)

// PSS (Proportional Set Size): The most accurate representation of app footprint
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)

return MemorySnapshot(
javaHeapUsedMB = javaHeapUsedMB,
javaHeapMaxMB = javaHeapMaxMB,
nativeHeapAllocatedMB = nativeHeapAllocatedMB,
systemAvailableMemMB = memoryInfo.availMem / (1024 * 1024),
isSystemLowMemory = memoryInfo.lowMemory
)
}

override fun onTrimMemory(level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// The app is running, but device is critically low on memory.
// Evict non-essential bitmap caches immediately.
}
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
// UI moved to background. Free heavy UI resources to lower LMK kill priority.
}
}
}

override fun onConfigurationChanged(newConfig: Configuration) {}
override fun onLowMemory() {
// Legacy callback: system is running out of memory
}
}

data class MemorySnapshot(
val javaHeapUsedMB: Long,
val javaHeapMaxMB: Long,
val nativeHeapAllocatedMB: Long,
val systemAvailableMemMB: Long,
val isSystemLowMemory: Boolean
)

3. Flutter & React Native: The Split-Heap Blindspot​

Cross-platform frameworks introduce a dangerous architectural duality: The VM Heap vs. The Native Graphics Engine.

In Flutter, Dart manages object lifecycle on the Dart VM Heap. However, images, shaders, Skia/Impeller render pipelines, and texture backings live in native C++ memory.

// DANGEROUS: Decodes a 4032x3024 12MB camera photo into raw RGBA memory
// Dart heap allocates only a tiny metadata pointer (~120 bytes)
// Skia / Native heap allocates 4032 * 3024 * 4 bytes = ~48.7 MB of uncompressed bitmap!
Image.network(
"https://cdn.example.com/products/4k_photo.jpg",
width: 150,
height: 150,
)

// SAFE: Resizes bitmap during decode step, saving 95% native RAM
Image.network(
"https://cdn.example.com/products/4k_photo.jpg",
width: 150,
height: 150,
cacheWidth: 300, // 2x density for 150dp
cacheHeight: 300,
)

If a list view displays 30 unconstrained images, the Dart VM reports a healthy 45MB heap, while the native iOS/Android process footprint spikes past 1.4GB. Within seconds, Jetsam or LMK kills the app. A standard Dart crash handler will never capture a stack trace.

Why Traditional Monitoring Tools Fail in Production​

CapabilityLocal Dev Profilers (Xcode / Android Studio)LeakCanaryStandard Crash Reporters (Crashlytics, Sentry)Production Memory Diagnostics (Appxiom)
Run in Production App Store / Play Store❌ No (Debug builds only)❌ Dangerous (5-30s freeze, PII risk)✅ Yes✅ Yes
Zero UI Jitter / Overhead❌ High overhead❌ High pause time✅ Low✅ Zero-overhead passive telemetry
Capture Jetsam / FOOM Kills❌ N/A❌ N/A❌ Misses SIGKILL terminations✅ Correlates last known footprint
Capture Native vs Managed Heap✅ Yes❌ Java heap only❌ None✅ Complete physical footprint
Attribution to User Action⚠️ Manual profiling session❌ Static reference path only❌ Single stack frame at crash✅ Full historical Activity Trail

The Appxiom Solution: Memory Activity Trail​

Diagnosing production memory leaks does not require capturing every allocated byte in RAM. It requires understanding Memory Delta Attribution.

A leak is not defined by high memory usage; it is defined by memory that is allocated during a user journey and fails to reclaim after that journey terminates.

Correlating Memory Deltas with User Journeys​

The Appxiom SDK records lightweight memory snapshots attached to each discrete step in the Activity Trail. By comparing memory footprint across screen transitions, network invocations, and user interactions, Appxiom calculates the exact memory delta ($\Delta$) per step.

Activity Trail Trace: Diagnosing a Retain Cycle in Production​

Consider this telemetry log captured by Appxiom immediately before an iOS Jetsam event:

TimestampScreen / Lifecycle EventAction / PayloadPhysical RAMMemory Delta ($\Delta$)Status
14:22:01.100AppLaunchCold start from springboard98 MB+98 MBNormal
14:22:04.250HomeScreenFeed fetch (GET /v2/feed - 200)132 MB+34 MBNormal
14:22:15.800CatalogScreenGrid populated (48 products)168 MB+36 MBNormal
14:22:28.400ARProductViewerCamera initialized + 3D model load495 MB+327 MBHigh Allocation
14:22:42.100HomeScreenUser navigates BACK to Home490 MB-5 MB (LEAK ALERT)CRITICAL
14:22:49.000CartScreenUser taps Cart icon510 MB+20 MBWarning
14:22:53.600CheckoutScreenTap "Pay Now"542 MB+32 MBJETSAM KILL

The Root Cause Revealed​

Without Appxiom, your team would be looking at an unhandled session termination during checkout. You might assume the payment gateway SDK crashed.

With the Appxiom Activity Trail, the bug is obvious in five seconds:

  1. When the user entered ARProductViewer, RAM jumped from 168 MB to 495 MB (+327 MB).
  2. When the user navigated back to HomeScreen, memory should have dropped back toward 168 MB. Instead, memory remained pinned at 490 MB (only -5 MB reclaimed).
  3. The ARProductViewer controller or its Metal texture buffers had a strong retain cycle (e.g., an uncancelled closure capture or unreleased camera delegate), keeping 320MB of native resources permanently alive in background memory.
  4. When the user reached Checkout, the normal memory requirement of the payment sheet pushed the app over the device's Jetsam threshold.

Connecting Memory Diagnostics to Business Health​

Memory diagnostics is not merely an infrastructure concern; it directly impacts revenue and customer retention.

1. Goal Friction Impact (GFI)​

When an app terminates due to an OOM during an onboarding or checkout flow, users rarely re-enter payment credentials - they abandon the transaction. Appxiom's Goal Friction Impact (GFI) quantifies the precise financial loss attributable to memory degradation, allowing engineering leads to prioritize memory fixes with hard ROI data.

2. Quality Score (QS)​

Rather than relying solely on the deceptive crash-free rate, Appxiom calculates a holistic Quality Score (QS). Devices experiencing excessive memory pressure warnings (TRIM_MEMORY_RUNNING_CRITICAL or didReceiveMemoryWarningNotification) trigger a score penalty, giving you an early warning system days before crashes escalate.

3. Version Analytics​

During staged rollouts, Appxiom's Version Analytics compares memory distributions across app versions:

  • v2.4.0: 90th percentile physical footprint = 185 MB.
  • v2.5.0: 90th percentile physical footprint = 340 MB (+83% regression detected in first 2% rollout).

You can halt the release before the regression compromises millions of devices.

Actionable Production Memory Hardening Checklist​

Follow these engineering best practices to eliminate memory leaks and build resilience against LMK and Jetsam:

iOS (Swift / SwiftUI)​

  • Enforce Weak Capture in Async Closures: Audit all Task { [weak self] in ... } blocks and delegates to prevent retain cycles holding view controllers in memory.
  • Drain Autorelease Pools in Heavy Loops: Wrap batch data transforms or file processing in autoreleasepool { ... } so intermediate objects are reclaimed immediately.
  • Listen to Memory Pressure: Implement UIApplication.didReceiveMemoryWarningNotification to flush image memory caches and clear transient view states.
  • Audit Background Tasks: Ensure background tasks end promptly with UIApplication.shared.endBackgroundTask(). Pinned background allocations are the #1 trigger for Jetsam priority kills.

Android (Kotlin / Jetpack Compose)​

  • Observe ComponentCallbacks2: Evict Glide/Coil bitmap caches upon receiving TRIM_MEMORY_RUNNING_CRITICAL or TRIM_MEMORY_RUNNING_LOW.
  • Beware of Static View/Context References: Never store a Context or View in a companion object, singleton, or long-lived coroutine scope.
  • Bind Coroutines to Lifecycle: Use viewLifecycleOwner.lifecycleScope in Fragments and rememberCoroutineScope() in Compose to ensure allocations cancel with UI teardown.
  • Optimize Large Bitmaps: Always decode images using BitmapFactory.Options.inSampleSize or rely on modern vector drawables and hardware-accelerated rendering.

Cross-Platform (Flutter / React Native)​

  • Constrain Image Cache Dimensions: Never use unconstrained Image.network() without specifying cacheWidth and cacheHeight.
  • Dispose Controllers Explicitly: Always invoke .dispose() on AnimationController, TextEditingController, FocusNode, and ScrollController in the widget dispose() lifecycle.
  • Verify Native Bridge Cleanup: Ensure custom native platform channels (MethodChannels) release native listeners and texture handles when widgets unmount.

Eliminate Memory Blindspots with Appxiom​

You cannot fix what you cannot measure, and you cannot debug production memory with development-only profilers.

By combining low-overhead physical footprint telemetry with the Appxiom Activity Trail, your engineering team gains complete visibility into every memory spike, retain cycle, and silent OOM kill across iOS, Android, and Flutter.

Stop losing users to invisible memory kills. Explore Appxiom's Activity Trail or start monitoring your mobile applications today.

How to Implement the Decorator Pattern in Jetpack Compose

Published: · Last updated: · 3 min read
Don Peter
Cofounder and CTO, Appxiom

How to Implement the Decorator Pattern in Jetpack Compose

Jetpack Compose gives you an incredible amount of freedom when building Android UIs. You describe what the UI should look like, and Compose takes care of the rest. But even with this flexibility, there are moments where you want to add behavior or styling around a component - without rewriting it or making it harder to maintain.

That's where the Decorator Pattern fits in beautifully.

The decorator pattern allows you to wrap additional behavior or visual enhancements around an existing component without changing its core implementation. In Jetpack Compose, this aligns perfectly with composable functions and modifiers, letting you layer responsibilities in a clean, reusable, and scalable way.

How to Test Jetpack Compose UIs Using Espresso

Published: · Last updated: · 5 min read
Don Peter
Cofounder and CTO, Appxiom

UI bugs are sneaky. Everything looks fine on your device, animations feel smooth, and then - someone reports that a button doesn't respond, a screen doesn't load, or a critical flow breaks on a specific device. By the time you hear about it, the damage is already done.

This is where UI testing earns its keep.

With Jetpack Compose becoming the standard way to build Android UIs, testing strategies need to evolve as well. Espresso is still a powerful UI testing tool - but testing Compose-based UIs requires a slightly different mindset.

Let's walk through how to test Jetpack Compose UIs using Espresso, step by step, in a way that actually makes sense when you sit down to write tests.

Prerequisites​

Before jumping into writing tests, make sure you have the basics in place:

  • An Android project using Jetpack Compose
  • Android Studio Arctic Fox or newer
  • Basic familiarity with:
    • Jetpack Compose
    • Espresso
    • JUnit
  • UI tests enabled in your project (androidTest source set)

If you already have a Compose screen running, you're good to go.

Setting Up Espresso for a Compose Project​

Jetpack Compose doesn't replace Espresso - it complements it. Espresso still handles UI synchronization and assertions, while Compose provides its own testing APIs.

In your app module, make sure you have the required dependencies:

androidTestImplementation 'androidx.test.espresso:espresso-core:<version>'
androidTestImplementation 'androidx.test.ext:junit:<version>'

This setup allows Espresso and Compose Test APIs to work together seamlessly.

Writing Your First Espresso Test with Jetpack Compose​

Let's put theory into practice and write a simple UI test. The goal here isn't to be fancy - it's to understand how Espresso and Jetpack Compose work together in a real test scenario.

We'll create a test that checks whether a button is visible on the screen and then performs a click on it.

Step 1: Create a UI test class​

Start by creating a new Kotlin file inside your app's androidTest directory. You can name it something like ExampleEspressoTest.

This file will hold all your UI test logic.

Step 2: Import the required dependencies​

You'll need imports from both Jetpack Compose testing and Espresso:

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.*
import androidx.test.espresso.Espresso.*
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.Rule
import org.junit.Test

These give you access to Compose test rules, UI matchers, and Espresso actions.

Step 3: Set up the Compose test rule​

The test rule is what launches your Compose content in a controlled testing environment:

class ExampleEspressoTest {
@get:Rule
val composeTestRule = createComposeRule()
}

This rule tells the test runner how to render Compose UI before running assertions.

Step 4: Write your first test​

Now for the actual test. We'll render a simple button and verify two things:

  1. The button is visible
  2. The button can be clicked
@Test
fun testButtonVisibilityAndClick() {
// Launch the Compose screen/activity
composeTestRule.setContent {
// Compose UI code here
Button(
onClick = { /* Button click action */ }
) {
Text("Click Me")
}
}

// Check if the button is displayed
onView(withText("Click Me")).check(matches(isDisplayed()))

// Perform a click action on the button
onView(withText("Click Me")).perform(click())
}

What's happening here:

  • setContent renders a Compose UI just for this test
  • Espresso verifies the button exists on screen
  • Espresso simulates a real user click

This might look simple - and that's the point. UI tests should clearly describe user behavior, not hide it behind complexity.

Step 5: Run the test​

You can run the test directly from Android Studio or use the test runner to execute it as part of your test suite.

Once it passes, you've officially written and executed your first Espresso test for a Jetpack Compose UI.

From here, you can expand into testing state changes, navigation, error states, and full user flows.

Working with Matchers and Actions​

Even when you're testing Jetpack Compose UI, Espresso's core ideas - matchers and actions - still apply. The difference is what you're interacting with. Instead of traditional View objects, you're now targeting Compose-based UI elements.

Matchers help Espresso find the UI element you care about, while actions define what you want to do with it - just like a real user would.

Commonly Used Matchers​

Matchers are used to locate Compose components based on their properties:

  • withText("text") - Finds a composable that displays the given text.
  • isDisplayed() - Ensures the composable is currently visible on the screen.

These matchers make your tests readable and expressive, almost like describing what a user sees.

Commonly Used Actions​

Actions simulate user interactions:

  • click() - Performs a tap on the matched Compose component.

When combined, matchers and actions let you write tests that read like user behavior:

"Find this button, make sure it's visible, then tap it."

This approach keeps your tests focused on what the user does, not on internal implementation details - which is exactly how good UI tests should behave.

Testing Jetpack Compose Components​

When testing Compose components, you can use the onNode method to target specific components.

For example, to test a Button component:

onNode(hasText("Click Me")).performClick()

Verifying Assertions the Right Way​

Assertions tell you whether your UI behaves as expected. For example:

  • isDisplayed(): Checks if the Compose component is currently visible on the screen.
  • hasText("text"): Checks if the Compose component contains the specified text.

Conclusion​

Testing Jetpack Compose UI with Espresso isn't complicated - but it does require a shift in how you think about UI testing.

Compose simplifies UI structure.

Espresso ensures stability.

Assertions keep regressions in check.

Together, they help you ship UIs that behave correctly - not just in demos, but on real devices, under real conditions.

Because the best UI bug is the one your users never see.

Happy testing.

How to Use Vulkan for GPU Acceleration in Kotlin Android Apps

Published: · Last updated: · 5 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Modern Android applications are expected to deliver smooth animations, rich visuals, and real-time graphical effects. However, executing heavy graphical operations on the CPU can quickly lead to performance bottlenecks, increased battery consumption, and poor user experience. For a broader look at tools that can elevate your Android development workflow, check out our guide on 10 Android libraries you really need.

Earlier, Android developers relied on RenderScript for GPU-accelerated workloads. With RenderScript now deprecated, Vulkan has emerged as the most powerful and future-ready alternative for high-performance graphics and compute operations on Android.

In this blog, we'll explore how to utilize GPU capabilities using Vulkan in Kotlin-based Android apps to efficiently handle intensive graphical workloads and unlock next-level performance.

Avoid Android App Crashes: Kotlin Best Practices

Published: · Last updated: · 6 min read
Andrea Sunny
Marketing Associate, Appxiom

You know that moment when you're rushing to book a cab, the payment is about to go through, and suddenly the app freezes? For a few seconds, you're stuck - did the payment go through or not? Do you retry? Do you close the app? That tiny moment of uncertainty is enough to frustrate most users. And more often than not, they don't come back.

That's exactly how silent damage begins in mobile apps. Not with big disasters-but with small, unexpected failures in moments that matter most. On Android, even one crash in a critical flow like login, checkout, or onboarding can quietly push users away, hurt your ratings, and impact revenue. While no app can ever be completely crash-proof, Kotlin gives you a strong safety net to reduce these risks long before users feel them.

Build Better, Ship Faster: 10 Android Libraries You Really Need

Published: · Last updated: · 7 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Imagine building a house with your bare hands. Then, someone hands you a toolbox that automates half the work, ensures structural safety, and even paints the walls. That's what the right Android libraries feel like.

You don't just want to write code. You want to write clean, efficient, testable code that doesn't give you a migraine three months later. These 10 libraries? They're your survival kit.

Let's break them down together. I'll show you real examples, sprinkle in some numbers, and tell you exactly why each one deserves a spot in your next Android project. No fluff - just the stuff that actually helps.

How to Build an Offline-Capable Android App with Jetpack Compose and Kotlin

Published: · Last updated: · 6 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

The streak broke. So did the flow.

It wasn't that I forgot. I remembered, just a little too late.

Right before midnight, I opened the app to log my progress. But the screen just sat there, trying to connect. No internet. No log. No streak.

It sounds small, but if you've ever built a habit one day at a time, you know what a streak can mean. It's not just numbers. It's proof. And losing it? That stings.

That moment made one thing very clear: apps that help you grow should work with you, not against you, especially when the internet doesn't cooperate.

So let's build something better.

How to Avoid Memory Leaks in Jetpack Compose: Real Examples, Causes, and Fixes

Published: · Last updated: · 5 min read
Andrea Sunny
Marketing Associate, Appxiom

"Hey… why does this screen freeze every time I scroll too fast?"

That's what my QA pinged me at 11:30 AM on a perfectly normal Tuesday.

I brushed it off. "Probably a one-off," I thought.

But then the bug reports started trickling in:

  • "The app slows down after using it for a while."
  • "Navigation feels laggy."
  • "Sometimes it just… dies."

That's when the panic set in.

Building Offline-Capable Android Apps with Kotlin and Jetpack Compose

Published: · Last updated: · 5 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

In today's mobile-first world, users expect apps to work seamlessly, even when there's no internet connection. This blog post will guide you through the process of building an offline-capable Android app using Kotlin and Jetpack Compose. We'll use a ToDo app as our example to illustrate key concepts and best practices.

Architecture Overview​

Before diving into the code, let's outline the architecture we'll use:

  • UI Layer: Jetpack Compose for the user interface

  • ViewModel: To manage UI-related data and business logic

  • Repository: To abstract data sources and manage data flow

  • Local Database: Room for local data persistence

  • Remote Data Source: Retrofit for API calls (when online)

  • WorkManager: For background synchronization

Setting Up the Kotlin Project​

First, ensure you have the necessary dependencies in your build.gradle file:

dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.ui:ui:1.4.3")
implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
implementation("androidx.compose.material3:material3:1.1.1")

// Room
implementation("androidx.room:room-runtime:2.5.2")
implementation("androidx.room:room-ktx:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// WorkManager
implementation("androidx.work:work-runtime-ktx:2.8.1")
}

Implementing the Local Database​

We'll use Room to store ToDo items locally. First, define the entity:

@Entity(tableName = "todos")
data class ToDo(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val lastModified: Long = System.currentTimeMillis()
)

Next, create the DAO (Data Access Object):

@Dao
interface ToDoDao {
@Query("SELECT * FROM todos")
fun getAllToDos(): Flow<List<ToDo>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertToDo(todo: ToDo)

@Update
suspend fun updateToDo(todo: ToDo)

@Delete
suspend fun deleteToDo(todo: ToDo)
}

Finally, set up the Room database:

@Database(entities = [ToDo::class], version = 1)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun todoDao(): ToDoDao

companion object {
@Volatile
private var INSTANCE: ToDoDatabase? = null

fun getDatabase(context: Context): ToDoDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java,
"todo_database"
).build()
INSTANCE = instance
instance
}
}
}
}

Implementing the Repository​

The repository will manage data operations and decide whether to fetch from the local database or the remote API:

class ToDoRepository(
private val todoDao: ToDoDao,
private val apiService: ApiService
) {

val allToDos: Flow<List<ToDo>> = todoDao.getAllToDos()

suspend fun refreshToDos() {
try {
val remoteToDos = apiService.getToDos()
todoDao.insertAll(remoteToDos)
} catch (e: Exception) {
// Handle network errors
}
}

suspend fun addToDo(todo: ToDo) {
todoDao.insertToDo(todo)
try {
apiService.addToDo(todo)
} catch (e: Exception) {
// Handle network errors, maybe queue for later sync
}
}

// Implement other CRUD operations similarly
}

Setting Up the ViewModel​

The ViewModel will handle the UI logic and interact with the repository:

class ToDoViewModel(private val repository: ToDoRepository) : ViewModel() {
val todos = repository.allToDos.asLiveData()

fun addToDo(title: String, description: String) {
viewModelScope.launch {
val todo = ToDo(title = title, description = description)
repository.addToDo(todo)
}
}

fun refreshToDos() {
viewModelScope.launch {
repository.refreshToDos()
}
}
// Implement other operations
}

Creating the UI with Jetpack Compose​

Now, let's create the UI for our ToDo app:

@Composable
fun ToDoScreen(viewModel: ToDoViewModel) {
val todos by viewModel.todos.collectAsState(initial = emptyList())
LazyColumn {
items(todos) { todo ->
ToDoItem(todo)
}
item {
AddToDoButton(viewModel)
}
}
}

@Composable
fun ToDoItem(todo: ToDo) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { /* Update todo */ }
)
Column(modifier = Modifier.weight(1f)) {
Text(text = todo.title, fontWeight = FontWeight.Bold)
Text(text = todo.description)
}
}
}
}

@Composable
fun AddToDoButton(viewModel: ToDoViewModel) {
var showDialog by remember { mutableStateOf(false) }
Button(onClick = { showDialog = true }) {
Text("Add ToDo")
}
if (showDialog) {
AddToDoDialog(
onDismiss = { showDialog = false },
onConfirm = { title, description ->
viewModel.addToDo(title, description)
showDialog = false
}
)
}
}

@Composable
fun AddToDoDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
// Implement dialog UI here
}

Implementing Background Sync with WorkManager​

To ensure our app stays up-to-date even when it's not actively running, we can use WorkManager for background synchronization:

class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
private val repository = ToDoRepository(
ToDoDatabase.getDatabase(context).todoDao(),
ApiService.create()
)
override suspend fun doWork(): Result {
return try {
repository.refreshToDos()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}

Schedule the work in your application class or main activity:

class ToDoApplication : Application() {
override fun onCreate() {
super.onCreate()
setupPeriodicSync()
}

private fun setupPeriodicSync() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.build()

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"ToDo_Sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
}

Handling Conflicts​

When working offline, conflicts may arise when syncing data. Implement a conflict resolution strategy:

suspend fun syncToDo(todo: ToDo) {
try {
val remoteToDo = apiService.getToDo(todo.id)
if (remoteToDo.lastModified > todo.lastModified) {
// Remote version is newer, update local
todoDao.insertToDo(remoteToDo)
} else {
// Local version is newer, update remote
apiService.updateToDo(todo)
}
} catch (e: Exception) {
// Handle network errors
}
}

Testing Offline Functionality​

To ensure your app works offline:

  • Implement a network utility class to check connectivity.

  • Use this utility in your repository to decide whether to fetch from local or remote.

  • Write unit tests for your repository and ViewModel.

  • Perform UI tests with network on and off to verify behavior.

Conclusion​

Building an offline-capable Android app requires careful consideration of data flow, synchronization, and conflict resolution. By using Room for local storage, Retrofit for API calls, and WorkManager for background sync, you can create a robust offline experience for your users.

Remember to handle edge cases, such as first-time app usage without internet, and always provide clear feedback to users about the sync status of their data.

Would you like me to explain or break down any part of this code?

Gradle Flavors: Building Multiple Android App Variants with Single Codebase

Published: · Last updated: · 4 min read
Don Peter
Cofounder and CTO, Appxiom

Gradle Flavors, also known as product flavors, allow developers to create multiple variants of their app within a single codebase. Each flavor can have its own unique configuration, resources, and dependencies, enabling customization based on factors such as branding, feature sets, or target audiences.

If you're developing free and paid versions of your app, adapting it for different languages or regions, or incorporating variations for testing purposes, Gradle Flavors offer unparalleled flexibility.

Why Use Gradle Flavors?​

  • Customization: With Gradle Flavors, developers can easily tailor their app to suit specific user segments or market requirements. This level of customization fosters better user engagement and satisfaction.

  • Efficiency: Rather than maintaining separate codebases for different app variants, Gradle Flavors streamline the development process by centralizing code while allowing for variant-specific configurations. This results in reduced complexity and faster iteration cycles.

  • Consistency: By defining variant-specific resources and dependencies within the Gradle build script, developers ensure consistency across different app versions while minimizing the risk of errors or inconsistencies.

  • Market Segmentation: For businesses targeting diverse demographics or regions, Gradle Flavors facilitate the creation of specialized versions of the app tailored to each market segment's preferences and needs.

Free and Pro Versions​

Let's illustrate the power of Gradle Flavors with the above scenario – creating free and pro versions of an app. Suppose you have an app called "WeatherApp" and want to offer both a free version with basic features and a paid pro version with additional functionalities.

android {
...
productFlavors {
free {
dimension "tier"
applicationId "com.example.weather.free"
versionCode 1
versionName "1.0"
// Define flavor-specific configurations
buildConfigField "boolean", "IS_PRO_VERSION", "false"
}
pro {
dimension "tier"
applicationId "com.example.weather.pro"
versionCode 1
versionName "1.0"
// Define flavor-specific configurations
buildConfigField "boolean", "IS_PRO_VERSION", "true"
}
}

buildTypes {
debug {
// Debug-specific configurations
...
}
release {
// Release-specific configurations
...
}
}
...
}

In this example, we define two product flavors: 'free' and 'pro', each with its own unique applicationID. We can then customize the behavior, features, and resources specific to each flavor, such as limiting certain features to the pro version or displaying different branding elements.

In the above example, we define two build types. "debug" and "release," each with its own configurations. These configurations might include signing configurations, proguard rules, or other build-specific settings.

With the flavors and build variants defined, Gradle will generate the following build variants:

  • freeDebug

  • freeRelease

  • proDebug

  • proRelease

Developers can then use these variants to build and test different versions of the app. For instance, they can build the "pro" release variant to generate a signed APK for distribution to users who have purchased the pro version of the app. Similarly, they can build the "free" debug variant to test new features or changes specific to the free version of the app.

Android Project Structure with Gradle Flavors​

In the above example, the folder structure for the Android project would typically look like this:

- app
- src
- free
- java
- com
- example
- weather
- MainActivity.java
- ...
- res
- layout
- drawable
- values
- ...
- pro
- java
- com
- example
- weather
- MainActivity.java
- ...
- res
- layout
- drawable
- values
- ...
- main
- java
- com
- example
- weather
- MainActivity.java
- ...
- res
- layout
- drawable
- values
- ...

Here's a breakdown of the folder structure:

  • app: This is the main module of the Android project.

  • src: This directory contains the source code and resources for different build variants.

  • free: This directory contains the source code and resources specific to the "free" flavor.

java: Java source code files for the "free" flavor.

  • com.example.weather: Package directory.

  • MainActivity.java: Example activity class.

  • Other Java files specific to the "free" flavor.

  • res: Resource directory for the "free" flavor.

  • layout: XML layout files.

  • drawable: Image resources.

  • values: Resource files such as strings, colors, dimensions, etc.

  • Other resource directories specific to the "free" flavor.

  • pro: This directory contains the source code and resources specific to the "pro" flavor. The structure is similar to the "free" flavor but with resources and code specific to the "pro" variant.

  • main: This directory contains the main source code and resources shared among all flavors and build types. It serves as the base for all variants and contains code and resources common to both "free" and "pro" flavors.

By organizing the source code and resources in this way, Gradle can easily build different variants of the app by combining the contents of the "main" directory with the specific contents of each flavor directory (free and pro).

Leveraging Gradle flavors and build variants empowers Android developers to efficiently manage and customize multiple versions of their apps, catering to diverse user preferences and market requirements while maintaining codebase integrity and flexibility.

Data Structures in Kotlin

Published: · Last updated: · 7 min read
Don Peter
Cofounder and CTO, Appxiom

Data structures play a crucial role in software development by enabling efficient storage and manipulation of data. Kotlin, a modern and expressive programming language, offers a variety of data structures to cater to different needs.

In this tutorial, we'll delve into the common data structures available in Kotlin, along with their use cases and implementation examples.

Major Data Structures in Kotlin are​

  • Arrays

  • Lists

Immutable List

  • MutableList

  • ArrayList

  • LinkedList

  • Sets

  • Maps

  • Stacks and Queues

  • Trees (Binary Trees)

  • Graphs

Arrays​

Arrays in Kotlin are collections of elements with a fixed size. While most languages allow only the same type of data to be stored in an array, Kotlin arrays can hold elements of any type.

// Creating an array of integers
val numbers = arrayOf(1, 2, 3, 4, 5)

// Accessing elements
val firstElement = numbers[0]

// Modifying elements
numbers[2] = 10

// Iterating through an array
for (number in numbers) {
println(number)
}

Arrays are suitable for scenarios requiring a fixed-size collection of elements accessed by index.

Lists​

Lists in Kotlin are ordered collections that can grow or shrink in size dynamically.

Kotlin provides several types of lists, including List, MutableList, and specialized implementations like ArrayList and LinkedList.

Immutable List (List):​

An immutable list cannot be modified after it is created. You can use the listOf() function to create an immutable list.

// Creating an immutable list of strings
val names: List<String> = listOf("Alice", "Bob", "Charlie")

// Accessing elements
println(names[0]) // Output: Alice

// Iterating through the list
for (name in names) {
println(name)
}

Mutable List​

A mutable list allows you to add, remove, and modify elements after it is created. You can use the mutableListOf() function to create a mutable list.

// Creating a mutable list of integers
val numbers: MutableList<Int> = mutableListOf(1, 2, 3, 4, 5)

// Adding elements
numbers.add(6)

// Removing elements
numbers.removeAt(2)

// Modifying elements
numbers[0] = 10

// Iterating through the list
for (number in numbers) {
println(number)
}

ArrayList​

An ArrayList is a resizable array implementation of the MutableList interface. It provides dynamic resizing and efficient random access. ArrayList in Kotlin is same as Java.

public actual typealias ArrayList<E> = java.util.ArrayList<E>
// Creating an ArrayList of doubles
val prices: ArrayList<Double> = arrayListOf(10.5, 20.3, 15.8)

// Adding elements
prices.add(25.0)

// Removing elements
prices.removeAt(1)

// Iterating through the ArrayList
for (price in prices) {
println(price)
}

LinkedList​

A LinkedList is a doubly linked list implementation of the MutableList interface. It provides efficient insertions and deletions at both ends of the list.

// Creating a LinkedList of characters
val letters: LinkedList<Char> = LinkedList()
letters.add('A')
letters.add('B')
letters.add('C')

// Adding elements at the beginning and end
letters.addFirst('Z')
letters.addLast('D')

// Removing elements
letters.removeFirst()
letters.removeLast()

// Iterating through the LinkedList
for (letter in letters) {
println(letter)
}

These examples demonstrate different types of lists available in Kotlin and how to use them for various purposes. Depending on your requirements, you can choose the appropriate list implementation that suits your needs for immutability, mutability, random access, or efficient insertions and deletions.

Sets

Sets in Kotlin are collections of unique elements with no duplicates.

// Creating a set of integers
val uniqueNumbers = setOf(1, 2, 3, 3, 4, 5)

// Adding and removing elements
val mutableSet = mutableSetOf<Int>()
mutableSet.add(6)
mutableSet.remove(3)

// Checking membership
val containsFive = 5 in mutableSet

Sets are useful when you need to ensure uniqueness or perform set operations like union, intersection, and difference.

Maps​

Maps in Kotlin are collections of key-value pairs where each key is unique.

// Creating a map of student grades
val studentGrades = mapOf("Alice" to 90, "Bob" to 85, "Charlie" to 92)

// Accessing values
val aliceGrade = studentGrades["Alice"]

// Modifying values (Not allowed for immutable maps)
val mutableGrades = studentGrades.toMutableMap()
mutableGrades["David"] = 88

// Iterating through a map
for ((name, grade) in studentGrades) {
println("$name: $grade")
}

Maps are ideal for situations requiring key-value associations and efficient lookups based on keys.

Stack and Queues​

Stacks follow the Last-In-First-Out (LIFO) principle and support push and pop operations where as Queues adhere to the First-In-First-Out (FIFO) principle and support enqueue and dequeue operations.

ArrayDeque in Kotlin, introduced in version 1.4, is a mutable collection that functions as both a queue and a stack. It is provides efficient operations for adding and removing elements from both ends of the deque.

import kotlin.collections.ArrayDeque

val deque = ArrayDeque<Int>()

// Adding elements to the deque
deque.addFirst(1)
deque.addLast(2)

// Removing elements from the deque
val firstElement = deque.removeFirst()
val lastElement = deque.removeLast()

println("First Element: $firstElement") // Output: First Element: 1
println("Last Element: $lastElement") // Output: Last Element: 2

Trees (Binary Trees)​

Binary trees consist of nodes where each node has at most two child nodes.

// Node structure for a binary tree
class BinaryTreeNode<T>(val value: T) {
var left: BinaryTreeNode<T>? = null
var right: BinaryTreeNode<T>? = null
}

// Creating a binary tree
val root = BinaryTreeNode(1)
root.left = BinaryTreeNode(2)
root.right = BinaryTreeNode(3)

//Reading the tree nodes.
fun <T> inOrderTraversal(node: BinaryTreeNode<T>?) {
if (node == null) return
inOrderTraversal(node.left)
println(node.value)
inOrderTraversal(node.right)
}

println("In-order traversal:")
inOrderTraversal(root)

Output:
In-order traversal:
2
1
3

Here we excute an In-order traversal. In-order traversal visits the left subtree, then the root, and finally the right subtree. Binary trees are useful for hierarchical data representation, searching, and sorting algorithms like binary search.

Graphs​

Graphs are complex structures consisting of nodes and edges.

Graphs are essential for modelling relationships and solving problems like route finding and network analysis.

// Implementing an adjacency list for a graph
class Graph {
val adjacencyList = mutableMapOf<Int, MutableList<Int>>()

fun addEdge(from: Int, to: Int) {
adjacencyList.getOrPut(from) { mutableListOf() }.add(to)
}
}

// Creating a graph
val graph = Graph()
graph.addEdge(1, 2)
graph.addEdge(1, 3)
graph.addEdge(2, 4)

// Accessing adjacency list
val adjacencyList = graph.adjacencyList

// Reading all vertices and their adjacent vertices
for ((vertex, adjacentVertices) in adjacencyList) {
println("Vertex $vertex is connected to: $adjacentVertices")
}

// Reading adjacent vertices of a specific vertex
val vertexAdjacent = adjacencyList[1]
println("Adjacent vertices of vertex 1: $vertexAdjacent")

Output
Vertex 1 is connected to: [2, 3]
Vertex 2 is connected to: [4]
Adjacent vertices of vertex 1: [2, 3]

The Graph class represents a graph data structure using an adjacency list. In this implementation, the adjacency list is stored as a mutable map where the keys represent the vertices (nodes) of the graph, and the corresponding values are lists containing the adjacent vertices.

adjacencyList:​

It's a mutable map where the keys are integers representing the vertices, and the values are mutable lists containing adjacent vertices.

addEdge:​

The addEdge function adds an edge between two vertices in the graph. It takes two parameters: from (the source vertex) and to (the destination vertex).

If the from vertex already exists in the adjacency list, getOrPut retrieves its associated mutable list of adjacent vertices. If it doesn't exist, a new entry is created and to vertex is then added to the list of adjacent vertices for the from vertex.

In the above code snippet, the for-in loop prints each vertex along with its adjacent vertices.

The last println statement prints the adjacent vertices of vertex 1, which are [2, 3].

Conclusion​

This blog post aims to provides a comprehensive overview of various data structures available in Kotlin, along with their use cases and implementation examples. Experiment with these data structures to enhance your understanding and proficiency in Kotlin programming.

Integrating Coil in Kotlin Based Android Apps

Published: · Last updated: · 3 min read
Don Peter
Cofounder and CTO, Appxiom

Picture this: You are developing a slick new Kotlin-based Android app. The UI is coming together nicely, but those image thumbnails just won't agree to load as quickly as you would like them to. How to solve this?

Enter Coil, a high-performance library for image loading.

We will jump the gun here to tell you how to go about integrating Coil into your Kotlin-based Apps.

Integrating Coil in Android Kotlin Apps​

Integrating Coil onto your Android project using Maven-based dependency management is as easy as 1-2-3. Coil is available by calling the function mavenCentral(). Add the Coil Dependency to your build.gradle file. Open your module-level build.gradle and add the Coil dependency.

dependencies {
implementation(“io.coil-kt:coil:x.y.z”) // Use the latest version
}

Jetpack Compose on Coil​

Using Jetpack Compose for building your UI and integrating with Coil for image loading comes with its advantages. This modern Android UI toolkit is designed to simplify and accelerate UI development on Android. Simply, Import the Jetpack Compose extension library and use the following code: 

implementation("io.coil-kt:coil-compose:x.y.z")

And later to  use the AsyncImage composable which comes as a part of coil-compose, to load an image, use the:

AsyncImage(
model = "https://example.com/image.jpg",
contentDescription = null
)

Why use Coil in Android Apps?​

Now that we have spoken in detail and how easily you can integrate Coil, let’s also understand why you as an Android App Developer should use Coil. We will fast forward to the functionalities of Coil and how features like memory and disk caching alongside customisations help achieve minimal boilerplate. 

  • Fast Image Loading: Coil helps avoid the complexities of handling image loading manually by focusing on efficiently loading and caching images from various sources, such as URLs, local files etc. This simplistic feature avoids verbose code or any complex configurations. 
// Example of loading an image with Coil
val imageView: ImageView = findViewById(R.id.imageView)
val imageUrl = "https://example.com/image.jpg"
imageView.load(imageUrl)
  • Built-in Transformation Support: Coil allows developers to apply various modifications to images, such as resizing, cropping, or applying filters. This reduces the need for additional code when manipulating images. 
// Example with image transformations
imageView.load(imageUrl) {
transformations(CircleCropTransformation())
}
  • Disk Caching: Caching reduces the need to repeatedly download or load images, enhancing the overall responsiveness of the app.

  • Automatic Request Management: Coil handles the retrieval, decoding, and displaying of the image without requiring extensive manual intervention.

  • ImageView Integration: With Coil, you can easily load an image into an ImageView using Coil's API, making it straightforward to display images in UI components.

// Example of loading an image with Coil
val imageView: ImageView = findViewById(R.id.imageView)
val imageUrl = "https://example.com/image.jpg"
imageView.load(imageUrl)
  • Customisation & Configuration: Developers can configure options such as placeholder images, error images, and image transformations to meet specific requirements.
// Example with placeholder and error handling
imageView.load(imageUrl) {
placeholder(R.drawable.placeholder)
error(R.drawable.error)
}
  • Small Library Size: Coil is designed to be lightweight, making it beneficial for projects where minimising the app's size is a priority. It also makes use of modern libraries including Coroutines, OkHttp, Okio, and AndroidX Lifecycles.

  • Kotlin-Friendly API: Coil is written in Kotlin and offers a Kotlin-friendly API, making it particularly well-suited for projects developed in Kotlin.

For more info and use cases that would make your life easier as a developer, do check out this link. This has an entire repository on how to seamlessly integrate Coil into your Apps. Happy Coding!

LiveData: An Observable Data Holder for Android Apps

Published: · Last updated: · 5 min read
Don Peter
Cofounder and CTO, Appxiom

LiveData is an observable data holder class in Android as part of androidx package. It notifies the UI about data changes, but unlike its regular counterparts, it's lifecycle-aware. This means that it adheres to the lifecycles of activities, fragments, and other app components, ensuring that the data updates only reach the active observers, thus preventing memory leaks.

Consider a use-case where the app displays a screen with the latest blog posts. With LiveData, you,

  • Fetch the blog posts data from database or API call and set it to a MutableLiveData instance.

  • Create an Observer object in your activity/fragment to update the UI.

  • Observe the LiveData object using observe().

Whenever the blog post data changes (due to network calls, etc.), only the active activity/fragment receives the update. If it's in the background, it'll automatically receive the latest data when it resumes. No more manual lifecycle handling or data redundancy.

Working with LiveData​

Following steps will help you get started with implementing a LiveData instance in a Kotlin based android app using MVVM architecture. Here we are using LiveData to update the UI when the data changes at the source.

  • Create a LiveData instance: In MVVM architecture, data is fetched in a ViewModel class. Create a LiveData instance in your ViewModel class.

  • Create an Observer object: Define the observer, listen to LiveData and update the UI based on data changes.

  • Attach the Observer: Use observe() with the relevant LifecycleOwner to connect the Observer to the LiveData object.

Defining LiveData in a ViewModel class (MVVM architecture)​

class UserViewModel : ViewModel() 
// MutableLiveData to hold post data
private val _posts = MutableLiveData<List<Post>>()
val posts: LiveData<List<Post>>
get() = _posts
// Fetch posts from a repository (replace with your implementation)
fun fetchPosts() {
//This will be a function call to repository class to fetch data
val dummyPosts = listOf(
Post(1, "Title 1", "Content 1"),
Post(2, "Title 2", "Content 2")
)
_posts.postValue(dummyPosts) // Update LiveData with fetched posts from a worker thread.

}

}
  • We define a MutableLiveData called _posts to hold the list of Post objects.

  • We expose a public posts LiveData that other components can observe.

  • The fetchPosts() method simulates fetching posts from a repository and updates the _posts value using postValue().

Observing LiveData​

class UserPostsActivity : AppCompatActivity() {
private val model: UserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Update your UI with the list of posts
val postObserver = Observer<List<Post>> { posts ->
recyclerView.adapter = PostAdapter(posts) // Update your UI with the list of posts
}

// Observe the posts LiveData with UserPostActivity as the lifecycleowner.
model.posts.observe(this, postObserver)

//fetch the post data.
model.fetchPosts()
}
}
  • We get a reference to the UserViewModel.

  • We define an Observer that receives the list of Post objects when the posts LiveData changes.

  • In onCreate(), we observe the posts LiveData using observe() and the current LifecycleOwner (UserViewModel).

  • When new data arrives, the postObserver updates the UI, e.g., by setting the adapter for a RecyclerView with the list of posts.

This is a basic example that demonstrates how LiveData can simplify data management and improve responsiveness of the UI in a Kotlin based Android app. Remember to adapt it to your specific data and UI needs.

*Update LiveData objects using ****setValue() ****from the main thread and postValue() from a worker thread. *

Benefits of using LiveData in MVVM​

  • Separation of concerns: ViewModel manages data and updates LiveData, while the activity/fragment handles UI.

  • Lifecycle awareness: Only active components receive data updates, preventing crashes and unnecessary calculations.

  • Single source of truth: posts LiveData acts as the central source for post data, ensuring consistency across the UI.

  • Simplified UI updates: Observer handles data changes and updates the UI automatically.

Using MediatorLiveData​

MediatorLiveData is a special type of LiveData in Android, combining data from multiple LiveData sources and creating a single unified information. 

It's useful when you need to:

  • Merge data from multiple sources: Imagine displaying comments and reactions on a post. You'd have separate LiveData objects for both, and MediatorLiveData can combine them into a single "post details" feed.

  • Respond to complex data conditions: You can define custom logic based on values from different sources. For example, show a "New" badge on a post only if both comments and reactions have updates.

  • Simplify data access: Instead of observing multiple sources, you just observe the MediatorLiveData, making your work flow cleaner and more centralized.

Common Scenarios to use LiveData​

  • Use LiveData with Room's observable queries to keep your UI in sync with the database.

  • Combine LiveData with Kotlin coroutines for asynchronous data handling especially when making API calls.

  • Leverage MediatorLiveData to merge multiple LiveData sources for unified data access.

LiveData is a powerful tool that simplifies data management, improves UI responsiveness and help avoid memory related issues like leaks.

Understanding Flow in Kotlin

Published: · Last updated: · 4 min read
Don Peter
Cofounder and CTO, Appxiom

Kotlin, a statically-typed, modern programming language, has introduced a powerful asynchronous programming concept called Flow. Flow simplifies asynchronous programming in Kotlin, making it more efficient and expressive.

In this blog post, we'll delve into what Flow is, how to use it, and provide practical examples in the context of Android app development. We'll explore how to send internal notifications to different modules within an Android application using Kotlin Flow.

What is Flow in Kotlin?​

Flow is a new asynchronous stream processing library introduced in Kotlin, specifically designed to handle streams of data asynchronously and in a non-blocking way. It's built on top of Kotlin's coroutines and allows you to work with sequences of values as they become available.

Key features of Flow:​

  • Asynchronous: Flow is designed for asynchronous operations and is perfect for use cases where you want to handle data asynchronously without blocking the main thread.

  • Non-blocking: Flow works seamlessly with Kotlin's coroutines, which means it is non-blocking and doesn't freeze your app while processing data.

  • Backpressure: Flow can handle backpressure, allowing you to control the flow of data between the producer and consumer, preventing overloading of resources.

  • Composability: Flow can be easily combined, transformed, and modified, making it a versatile tool for stream processing.

How to use Flow in Kotlin?​

To use Flow in your Kotlin application, follow these steps:

1. Import the necessary dependencies:​

To use Flow in an Android app, you need to include the Kotlin coroutines library in your project.

You can add it to your app-level build.gradle file:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x" // Use the latest version

2. Create a Flow:​

You can create a Flow using the following code, providing the values you want to emit.

For example:

fun fetchUserData(): Flow<User> = flow {
// Fetch user data asynchronously
val user = api.fetchUser()
emit(user)
}

3. Collect data from a Flow:​

To consume data from a Flow, you can use the collect extension function. This allows you to receive emitted values and handle them:

viewModelScope.launch {
fetchUserData().collect { user ->
// Handle the user data
}
}

4. Transform and combine Flows:​

You can use various operators on Flow to transform, filter, and combine multiple Flows. Some common operators include map, filter, zip, and merge.

val transformedFlow = fetchUserData()
.map { user -> user.name }

Now that you understand the basics of using Flow, let's explore a practical example in an Android app.

Sending Internal Notifications in an Android App​

In Android app development, you often need to communicate between different modules or components within your app. Using Flow, you can send internal notifications from one module to another without tightly coupling them. Here's how you can achieve this,

Create a Notification Flow:​

Define a Flow that represents the notifications you want to send. For example, let's create a simple Flow for sending notifications of new messages:

object MessageNotificationManager {
private val notificationFlow = MutableSharedFlow<Message>()

fun sendNotification(message: Message) {
viewModelScope.launch {
notificationFlow.emit(message)
}
}

fun getNotificationFlow(): Flow<Message> = notificationFlow
}

In this example, MessageNotificationManager provides methods to send notifications and get the Flow to receive notifications.

Subscribe to the Notification Flow:​

In the module that needs to receive notifications, subscribe to the Flow.

class ChatFragment : Fragment() {
// ...

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

MessageNotificationManager.getNotificationFlow()
.onEach { message ->
// Handle the new message notification
}
.launchIn(viewLifecycleOwner.lifecycleScope)
}
}

In this example, ChatFragment listens to the notification Flow using onEach and processes incoming messages.

Sending Notifications:​

In the module where you want to send notifications, call the sendNotification method.

val newMessage = Message("Hello, world!")
MessageNotificationManager.sendNotification(newMessage)

By using Flow to send and receive internal notifications, you decouple different parts of your Android app, making it more modular and maintainable.

Use Cases for Flow in Android Apps​

Flow is a powerful tool for handling asynchronous and non-blocking operations in Android apps. Here are some common use cases for Flow:

  • Data fetching and processing: Use Flow to fetch data from network requests, databases, or other sources asynchronously.

  • Real-time updates: Implement real-time features in your app, such as chat applications or live notifications, using Flow to handle data updates.

  • User interface updates: Update UI components when data changes, ensuring a smooth and responsive user experience.

  • Event handling: Manage and process events, such as button clicks, gestures, or sensor data, using Flow to handle events as they occur.

​

Conclusion​

In Android app development, Flow allows for efficient communication between different modules and components, making your code more modular and maintainable. By following the steps outlined in this blog post and considering the practical examples, you can effectively incorporate Flow into your Kotlin-based Android applications.