Kotlin - Android Basics
Overview
Kotlin is the preferred language for Android development, offering concise syntax, null safety, and seamless Java interoperability. This tutorial covers Android app development with Kotlin, including Activities, Views, layouts, navigation, and modern Android development practices.
🎯 Learning Objectives:
- Understand Android app structure and Kotlin integration
- Learn Activities, Fragments, and lifecycle management
- Master Views, layouts, and UI development
- Implement navigation and data binding
- Apply modern Android development patterns
Android Project Setup
Project Configuration
// build.gradle (Module: app)
android {
compileSdk 34
defaultConfig {
applicationId "com.example.kotlinandroidapp"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding true
dataBinding true
}
}
dependencies {
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "com.google.android.material:material:1.11.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
// Navigation
implementation "androidx.navigation:navigation-fragment-ktx:2.7.6"
implementation "androidx.navigation:navigation-ui-ktx:2.7.6"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
// RecyclerView
implementation "androidx.recyclerview:recyclerview:1.3.2"
// Testing
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
}
Application Class
// MyApplication.kt
package com.example.kotlinandroidapp
import android.app.Application
import android.util.Log
class MyApplication : Application() {
companion object {
private const val TAG = "MyApplication"
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Application created")
// Initialize global components here
// e.g., Dependency injection, crash reporting, analytics
}
}
// AndroidManifest.xml
<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.KotlinAndroidApp">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
Activities and Lifecycle
MainActivity with Kotlin
// MainActivity.kt
package com.example.kotlinandroidapp
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import com.example.kotlinandroidapp.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
private const val KEY_COUNTER = "counter_key"
}
private lateinit var binding: ActivityMainBinding
private lateinit var viewModel: MainViewModel
private var counter = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate called")
// Initialize view binding
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Initialize ViewModel
viewModel = ViewModelProvider(this)[MainViewModel::class.java]
// Restore state
savedInstanceState?.let { bundle ->
counter = bundle.getInt(KEY_COUNTER, 0)
}
setupUI()
observeViewModel()
}
private fun setupUI() {
// Set up click listeners using view binding
binding.buttonIncrement.setOnClickListener {
counter++
updateCounterDisplay()
viewModel.incrementCounter()
}
binding.buttonDecrement.setOnClickListener {
counter--
updateCounterDisplay()
viewModel.decrementCounter()
}
binding.buttonShowToast.setOnClickListener {
showToast("Counter value: $counter")
}
binding.buttonOpenSecondActivity.setOnClickListener {
openSecondActivity()
}
updateCounterDisplay()
}
private fun observeViewModel() {
viewModel.counterValue.observe(this) { value ->
binding.textViewModelCounter.text = "ViewModel Counter: $value"
}
viewModel.message.observe(this) { message ->
if (message.isNotEmpty()) {
showToast(message)
viewModel.clearMessage()
}
}
}
private fun updateCounterDisplay() {
binding.textViewCounter.text = "Count: $counter"
}
private fun showToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
private fun openSecondActivity() {
val intent = Intent(this, SecondActivity::class.java).apply {
putExtra("counter_value", counter)
putExtra("message", "Hello from MainActivity")
}
startActivity(intent)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_COUNTER, counter)
Log.d(TAG, "State saved: counter = $counter")
}
override fun onStart() {
super.onStart()
Log.d(TAG, "onStart called")
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume called")
}
override fun onPause() {
super.onPause()
Log.d(TAG, "onPause called")
}
override fun onStop() {
super.onStop()
Log.d(TAG, "onStop called")
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy called")
}
}
ViewModel with LiveData
// MainViewModel.kt
package com.example.kotlinandroidapp
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel() {
private val _counterValue = MutableLiveData().apply { value = 0 }
val counterValue: LiveData = _counterValue
private val _message = MutableLiveData().apply { value = "" }
val message: LiveData = _message
fun incrementCounter() {
val currentValue = _counterValue.value ?: 0
_counterValue.value = currentValue + 1
if (currentValue + 1 == 10) {
_message.value = "Counter reached 10!"
}
}
fun decrementCounter() {
val currentValue = _counterValue.value ?: 0
_counterValue.value = currentValue - 1
if (currentValue - 1 == 0) {
_message.value = "Counter is back to zero!"
}
}
fun clearMessage() {
_message.value = ""
}
override fun onCleared() {
super.onCleared()
// Clean up resources here
}
}
Layouts and Views
XML Layout with Material Design
<!-- activity_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<TextView
android:id="@+id/textViewTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="24sp"
android:textStyle="bold"
android:gravity="center"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/textViewCounter" />
<TextView
android:id="@+id/textViewCounter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Count: 0"
android:textSize="20sp"
android:layout_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@+id/textViewTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textViewModelCounter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ViewModel Counter: 0"
android:textSize="16sp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@+id/textViewCounter"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<LinearLayout
android:id="@+id/buttonContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@+id/textViewModelCounter"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonDecrement"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="@string/decrement"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonIncrement"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/increment"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonShowToast"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/show_toast"
app:layout_constraintTop_toBottomOf="@+id/buttonContainer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
style="@style/Widget.MaterialComponents.Button.UnelevatedButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonOpenSecondActivity"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/open_second_activity"
app:layout_constraintTop_toBottomOf="@+id/buttonShowToast"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Custom Views and Components
// CustomCounterView.kt
package com.example.kotlinandroidapp.views
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.constraintlayout.widget.ConstraintLayout
import com.example.kotlinandroidapp.databinding.ViewCustomCounterBinding
class CustomCounterView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding: ViewCustomCounterBinding
private var counter = 0
private var onCounterChangedListener: ((Int) -> Unit)? = null
init {
binding = ViewCustomCounterBinding.inflate(LayoutInflater.from(context), this, true)
setupClickListeners()
updateDisplay()
}
private fun setupClickListeners() {
binding.buttonMinus.setOnClickListener {
counter--
updateDisplay()
onCounterChangedListener?.invoke(counter)
}
binding.buttonPlus.setOnClickListener {
counter++
updateDisplay()
onCounterChangedListener?.invoke(counter)
}
}
private fun updateDisplay() {
binding.textCounter.text = counter.toString()
}
fun setCounter(value: Int) {
counter = value
updateDisplay()
}
fun getCounter(): Int = counter
fun setOnCounterChangedListener(listener: (Int) -> Unit) {
onCounterChangedListener = listener
}
}
Fragments and Navigation
Fragment Implementation
// HomeFragment.kt
package com.example.kotlinandroidapp.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.example.kotlinandroidapp.R
import com.example.kotlinandroidapp.databinding.FragmentHomeBinding
import com.example.kotlinandroidapp.viewmodels.HomeViewModel
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private lateinit var viewModel: HomeViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this)[HomeViewModel::class.java]
setupUI()
observeViewModel()
}
private fun setupUI() {
binding.buttonNavigateToDetail.setOnClickListener {
val action = HomeFragmentDirections.actionHomeToDetail("Hello from Home")
findNavController().navigate(action)
}
binding.buttonShowDialog.setOnClickListener {
showCustomDialog()
}
}
private fun observeViewModel() {
viewModel.items.observe(viewLifecycleOwner) { items ->
// Update RecyclerView adapter
updateItemsList(items)
}
viewModel.loading.observe(viewLifecycleOwner) { isLoading ->
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
}
}
private fun updateItemsList(items: List) {
// Update RecyclerView with new items
binding.textViewItemCount.text = "Items: ${items.size}"
}
private fun showCustomDialog() {
val dialog = CustomDialogFragment.newInstance("Dialog Title", "Dialog message")
dialog.show(parentFragmentManager, "CustomDialog")
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Navigation Component Setup
<!-- navigation/nav_graph.xml -->
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.kotlinandroidapp.fragments.HomeFragment"
android:label="Home"
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_home_to_detail"
app:destination="@id/detailFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
</fragment>
<fragment
android:id="@+id/detailFragment"
android:name="com.example.kotlinandroidapp.fragments.DetailFragment"
android:label="Detail"
tools:layout="@layout/fragment_detail">
<argument
android:name="message"
app:argType="string"
android:defaultValue="Default message" />
</fragment>
</navigation>
RecyclerView and Adapters
RecyclerView Adapter with Kotlin
// UserAdapter.kt
package com.example.kotlinandroidapp.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.kotlinandroidapp.databinding.ItemUserBinding
import com.example.kotlinandroidapp.models.User
class UserAdapter(
private val onUserClick: (User) -> Unit
) : ListAdapter(UserDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val binding = ItemUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return UserViewHolder(binding, onUserClick)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
holder.bind(getItem(position))
}
class UserViewHolder(
private val binding: ItemUserBinding,
private val onUserClick: (User) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.apply {
textViewName.text = user.name
textViewEmail.text = user.email
textViewAge.text = "Age: ${user.age}"
// Set click listener
root.setOnClickListener {
onUserClick(user)
}
// Load user avatar (using placeholder)
imageViewAvatar.setImageResource(
if (user.isActive) android.R.drawable.presence_online
else android.R.drawable.presence_offline
)
}
}
}
class UserDiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
}
// Usage in Fragment
class UsersFragment : Fragment() {
private lateinit var adapter: UserAdapter
private fun setupRecyclerView() {
adapter = UserAdapter { user ->
// Handle user click
findNavController().navigate(
UsersFragmentDirections.actionUsersToUserDetail(user.id)
)
}
binding.recyclerViewUsers.adapter = adapter
// Observe users from ViewModel
viewModel.users.observe(viewLifecycleOwner) { users ->
adapter.submitList(users)
}
}
}
Data Binding and View Binding
Data Binding Example
<!-- fragment_user_profile.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="user"
type="com.example.kotlinandroidapp.models.User" />
<variable
name="viewModel"
type="com.example.kotlinandroidapp.viewmodels.UserProfileViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView
android:id="@+id/textViewName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{user.name}"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textViewEmail"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{user.email}"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/textViewName"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/buttonEdit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Edit Profile"
android:layout_marginTop="32dp"
android:onClick="@{() -> viewModel.editProfile()}"
app:layout_constraintTop_toBottomOf="@+id/textViewEmail"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
// UserProfileFragment.kt
class UserProfileFragment : Fragment() {
private lateinit var binding: FragmentUserProfileBinding
private lateinit var viewModel: UserProfileViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_user_profile,
container,
false
)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this)[UserProfileViewModel::class.java]
// Set up data binding
binding.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = [email protected]
}
// Observe user data
viewModel.user.observe(viewLifecycleOwner) { user ->
binding.user = user
}
}
}
Intent Handling and Data Passing
Activity Communication
// SecondActivity.kt
class SecondActivity : AppCompatActivity() {
companion object {
const val EXTRA_COUNTER_VALUE = "counter_value"
const val EXTRA_MESSAGE = "message"
const val RESULT_NEW_COUNTER = "new_counter"
fun createIntent(context: Context, counterValue: Int, message: String): Intent {
return Intent(context, SecondActivity::class.java).apply {
putExtra(EXTRA_COUNTER_VALUE, counterValue)
putExtra(EXTRA_MESSAGE, message)
}
}
}
private lateinit var binding: ActivitySecondBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySecondBinding.inflate(layoutInflater)
setContentView(binding.root)
// Retrieve data from intent
val counterValue = intent.getIntExtra(EXTRA_COUNTER_VALUE, 0)
val message = intent.getStringExtra(EXTRA_MESSAGE) ?: "No message"
setupUI(counterValue, message)
}
private fun setupUI(counterValue: Int, message: String) {
binding.apply {
textViewReceivedData.text = "Received: $message\nCounter: $counterValue"
buttonReturnResult.setOnClickListener {
val resultIntent = Intent().apply {
putExtra(RESULT_NEW_COUNTER, counterValue + 10)
putExtra("result_message", "Modified from SecondActivity")
}
setResult(RESULT_OK, resultIntent)
finish()
}
buttonFinish.setOnClickListener {
finish()
}
}
}
}
// In MainActivity, handle the result
class MainActivity : AppCompatActivity() {
private val secondActivityLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val newCounter = result.data?.getIntExtra(SecondActivity.RESULT_NEW_COUNTER, 0) ?: 0
val message = result.data?.getStringExtra("result_message") ?: ""
counter = newCounter
updateCounterDisplay()
showToast("Result: $message, New counter: $newCounter")
}
}
private fun openSecondActivity() {
val intent = SecondActivity.createIntent(this, counter, "Hello from MainActivity")
secondActivityLauncher.launch(intent)
}
}
Permissions and Runtime Requests
Permission Handling
// PermissionUtils.kt
package com.example.kotlinandroidapp.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
class PermissionHelper(private val fragment: Fragment) {
private var onPermissionResult: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher> =
fragment.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
onPermissionResult?.invoke(allGranted)
}
fun requestCameraPermission(onResult: (Boolean) -> Unit) {
onPermissionResult = onResult
when {
hasCameraPermission() -> {
onResult(true)
}
fragment.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
// Show rationale dialog
showPermissionRationale {
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA))
}
}
else -> {
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA))
}
}
}
fun requestStoragePermissions(onResult: (Boolean) -> Unit) {
onPermissionResult = onResult
val permissions = arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
if (hasStoragePermissions()) {
onResult(true)
} else {
permissionLauncher.launch(permissions)
}
}
private fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(
fragment.requireContext(),
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
}
private fun hasStoragePermissions(): Boolean {
val context = fragment.requireContext()
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
private fun showPermissionRationale(onAccepted: () -> Unit) {
// Show custom dialog explaining why permission is needed
// This is a simplified example
onAccepted()
}
}
// Usage in Fragment
class CameraFragment : Fragment() {
private lateinit var permissionHelper: PermissionHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
permissionHelper = PermissionHelper(this)
}
private fun openCamera() {
permissionHelper.requestCameraPermission { granted ->
if (granted) {
// Open camera
launchCamera()
} else {
// Show error message
showPermissionDeniedMessage()
}
}
}
}
Modern Android Patterns
Repository Pattern
// UserRepository.kt
package com.example.kotlinandroidapp.repository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.example.kotlinandroidapp.models.User
import kotlinx.coroutines.delay
class UserRepository {
private val _users = MutableLiveData>()
val users: LiveData> = _users
private val userList = mutableListOf()
init {
// Initialize with sample data
loadSampleUsers()
}
suspend fun loadUsers() {
// Simulate network call
delay(1000)
_users.postValue(userList)
}
suspend fun addUser(user: User) {
delay(500) // Simulate network call
userList.add(user.copy(id = generateId()))
_users.postValue(userList.toList())
}
suspend fun updateUser(updatedUser: User) {
delay(500)
val index = userList.indexOfFirst { it.id == updatedUser.id }
if (index != -1) {
userList[index] = updatedUser
_users.postValue(userList.toList())
}
}
suspend fun deleteUser(userId: Long) {
delay(500)
userList.removeAll { it.id == userId }
_users.postValue(userList.toList())
}
suspend fun getUserById(id: Long): User? {
delay(200)
return userList.find { it.id == id }
}
private fun loadSampleUsers() {
userList.addAll(
listOf(
User(1, "John Doe", "[email protected]", 30, true),
User(2, "Jane Smith", "[email protected]", 25, true),
User(3, "Bob Johnson", "[email protected]", 35, false)
)
)
_users.value = userList
}
private fun generateId(): Long = System.currentTimeMillis()
}
// UserViewModel with Repository
class UserViewModel(private val repository: UserRepository) : ViewModel() {
val users = repository.users
private val _loading = MutableLiveData()
val loading: LiveData = _loading
private val _error = MutableLiveData()
val error: LiveData = _error
fun loadUsers() {
viewModelScope.launch {
try {
_loading.value = true
repository.loadUsers()
} catch (e: Exception) {
_error.value = "Failed to load users: ${e.message}"
} finally {
_loading.value = false
}
}
}
fun addUser(name: String, email: String, age: Int) {
viewModelScope.launch {
try {
val user = User(0, name, email, age, true)
repository.addUser(user)
} catch (e: Exception) {
_error.value = "Failed to add user: ${e.message}"
}
}
}
}
Testing Android Components
Unit Testing ViewModels
// UserViewModelTest.kt
class UserViewModelTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var repository: UserRepository
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
repository = mockk()
viewModel = UserViewModel(repository)
}
@Test
fun `loadUsers should update loading state`() = runTest {
// Given
val users = listOf(
User(1, "John", "[email protected]", 30, true)
)
coEvery { repository.loadUsers() } returns Unit
every { repository.users } returns MutableLiveData(users)
// When
viewModel.loadUsers()
// Then
coVerify { repository.loadUsers() }
assertEquals(false, viewModel.loading.getOrAwaitValue())
}
@Test
fun `addUser should call repository addUser`() = runTest {
// Given
val name = "John Doe"
val email = "[email protected]"
val age = 30
coEvery { repository.addUser(any()) } returns Unit
// When
viewModel.addUser(name, email, age)
// Then
coVerify { repository.addUser(match { user ->
user.name == name && user.email == email && user.age == age
}) }
}
}
// Extension function for testing LiveData
fun LiveData.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer {
override fun onChanged(o: T) {
data = o
latch.countDown()
[email protected](this)
}
}
this.observeForever(observer)
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
Key Takeaways
- Kotlin provides concise, null-safe Android development with excellent Java interoperability
- Use ViewBinding and DataBinding for type-safe view access
- Implement MVVM architecture with ViewModels and LiveData
- Use Navigation Component for fragment navigation
- Handle permissions properly using ActivityResult APIs
- Follow Android architectural patterns like Repository pattern
- Write comprehensive tests for ViewModels and repositories
- Leverage Kotlin features like extension functions and coroutines
Practice Exercises
- Build a complete note-taking app with CRUD operations
- Implement a photo gallery app with camera integration
- Create a weather app using REST API and location services
- Build a chat application with real-time messaging
Quiz
- What are the advantages of using Kotlin for Android development?
- How do you handle configuration changes in Android with Kotlin?
- What's the difference between ViewBinding and DataBinding?
Show Answers
- Kotlin offers null safety, concise syntax, coroutines for async operations, excellent Java interoperability, and is Google's preferred language for Android.
- Use ViewModels to survive configuration changes, save state in onSaveInstanceState(), and use the saved state handle for fragment arguments.
- ViewBinding provides type-safe view references, while DataBinding additionally allows binding data directly to views in XML and supports two-way data binding.