Android Developer Roadmap: Xử lý Thay đổi Trạng thái và Điều hướng Backstack trong Android

Chào mừng các bạn quay trở lại với chuỗi bài viết Android Developer Roadmap – Lộ trình học Lập trình viên Android 2025! Sau khi đã tìm hiểu về môi trường phát triển, ngôn ngữ lập trình Kotlin, OOP, cấu trúc dữ liệu, cách sử dụng Gradle, tạo ứng dụng “Hello World” đầu tiên, và quan trọng nhất là vòng đời Activity, hôm nay chúng ta sẽ đi sâu vào hai khái niệm cực kỳ quan trọng khác: xử lý thay đổi trạng thái (State Changes) và điều hướng Backstack. Nắm vững hai kỹ năng này là yếu tố then chốt để xây dựng những ứng dụng Android mạnh mẽ, ổn định và mang lại trải nghiệm tốt cho người dùng, ngay cả khi có sự gián đoạn.

Tại sao Trạng thái và Backstack lại quan trọng?

Hãy tưởng tượng bạn đang sử dụng một ứng dụng, bạn điền thông tin vào một form, cuộn danh sách đến một vị trí nhất định, hoặc chọn một mục nào đó. Đột nhiên, có một cuộc gọi đến, bạn xoay màn hình, hoặc đơn giản là hệ thống Android cần thu hồi bộ nhớ và tạm dừng ứng dụng của bạn. Khi bạn quay lại ứng dụng, bạn kỳ vọng nó sẽ vẫn ở nguyên trạng thái bạn rời đi – form vẫn còn dữ liệu đã nhập, danh sách vẫn cuộn đến đúng chỗ, v.v.

Tuy nhiên, mặc định, khi Activity hoặc Fragment bị hủy (ví dụ: do xoay màn hình, hoặc hệ thống cần tài nguyên), trạng thái UI của chúng sẽ bị mất. Đây chính là lúc chúng ta cần đến việc xử lý thay đổi trạng thái một cách cẩn thận.

Song song đó, người dùng tương tác với ứng dụng bằng cách di chuyển giữa các màn hình (Activity hoặc Fragment). Hệ thống Android quản lý luồng di chuyển này thông qua một cấu trúc gọi là “Backstack”. Hiểu và kiểm soát Backstack cho phép bạn định nghĩa luồng người dùng một cách chính xác, đảm bảo nút Back hoạt động như mong đợi và người dùng có thể quay lại các màn hình trước đó một cách hợp lý.

Vì vậy, việc hiểu rõ và áp dụng đúng các kỹ thuật quản lý trạng thái và điều hướng Backstack là nền tảng để phát triển ứng dụng Android chuyên nghiệp.

Hiểu về Trạng thái (State) trong Android UI

Trong ngữ cảnh UI của Android (đặc biệt là với các View truyền thống dựa trên XML hoặc Jetpack Compose, dù bài viết này chủ yếu tập trung vào View truyền thống), trạng thái của một màn hình bao gồm:

  • Dữ liệu hiển thị trên các View (ví dụ: văn bản trong TextView, giá trị trong EditText, hình ảnh).
  • Trạng thái tương tác của View (ví dụ: Button có được enable hay không, CheckBox có được chọn hay không).
  • Trạng thái bố cục (Layout state) (ví dụ: vị trí cuộn của ScrollView hoặc RecyclerView, mục nào đang được chọn trong ListView).

Khi Activity hoặc Fragment bị hủy và tạo lại (đặc biệt trong các trường hợp thay đổi cấu hình như xoay màn hình), các View sẽ được tạo lại từ file layout XML và hầu hết trạng thái của chúng sẽ bị reset về giá trị mặc định trong layout. Dữ liệu động mà bạn đã cập nhật vào View sẽ bị mất.

Các Cách Xử lý Thay đổi Trạng thái

1. Sử dụng onSaveInstanceState()onRestoreInstanceState() (hoặc Bundle trong onCreate())

Đây là cơ chế “truyền thống” mà hệ thống Android cung cấp để lưu trữ một lượng nhỏ dữ liệu trạng thái của UI khi Activity hoặc Fragment có khả năng bị hủy nhưng vẫn có thể quay trở lại sau đó (ví dụ: xoay màn hình, chuyển sang ứng dụng khác và bị giết do thiếu bộ nhớ). Hệ thống sẽ gọi phương thức onSaveInstanceState() ngay trước khi Activity bị hủy trong trường hợp này.

Cách sử dụng:

Ghi đè phương thức onSaveInstanceState() để lưu trữ dữ liệu vào đối tượng Bundle mà hệ thống cung cấp.

override fun onSaveInstanceState(outState: Bundle) {
    // Luôn gọi phương thức của lớp cha đầu tiên
    super.onSaveInstanceState(outState)

    // Lưu dữ liệu cần thiết vào Bundle
    val currentCount = textViewCounter.text.toString().toInt()
    outState.putInt("counter_value", currentCount)
    outState.putString("user_input", editTextName.text.toString())

    Log.d("StateDebug", "onSaveInstanceState: Lưu giá trị counter = $currentCount")
}

Dữ liệu này có thể được phục hồi trong phương thức onCreate() hoặc onRestoreInstanceState().

  • onCreate(savedInstanceState: Bundle?): Đối tượng Bundle được truyền vào onCreate chứa dữ liệu đã lưu từ lần trước. Đây là nơi phổ biến nhất để phục hồi trạng thái vì bạn cần dữ liệu này ngay khi Activity được tạo.
  • onRestoreInstanceState(savedInstanceState: Bundle?): Phương thức này được gọi sau onStart() và chỉ khi có dữ liệu trạng thái để phục hồi. Nó thường ít được sử dụng hơn onCreate cho mục đích này.

Ví dụ phục hồi trong onCreate():

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    // ... Ánh xạ View ...

    // Phục hồi trạng thái từ Bundle nếu có
    if (savedInstanceState != null) {
        val savedCounter = savedInstanceState.getInt("counter_value", 0) // Giá trị mặc định là 0 nếu không tìm thấy key
        textViewCounter.text = savedCounter.toString()

        val savedInput = savedInstanceState.getString("user_input", "")
        editTextName.setText(savedInput)

        Log.d("StateDebug", "onCreate: Phục hồi giá trị counter = $savedCounter")
    } else {
        Log.d("StateDebug", "onCreate: Không có savedInstanceState")
        // Khởi tạo trạng thái ban đầu
        textViewCounter.text = "0"
    }

    // ... Logic khác ...
}

Ưu điểm: Đơn giản cho dữ liệu nhỏ, tích hợp sẵn trong vòng đời Activity/Fragment.

Nhược điểm:

  • Chỉ dành cho dữ liệu nhỏ và có thể serialize được. Lưu trữ dữ liệu phức tạp hoặc lớn ở đây không hiệu quả và có thể gây lỗi TransactionTooLargeException.
  • Bundle được lưu vào đĩa, nên việc đọc/ghi phải nhanh.
  • Bundle không được lưu khi người dùng chủ động đóng ứng dụng (swipe từ Recent Apps) hoặc khi Activity bị kết thúc bằng finish().
  • Dữ liệu chỉ tồn tại miễn là process của ứng dụng còn sống *và* Activity có khả năng phục hồi.

2. Sử dụng ViewModel (Kiến trúc Component của Jetpack)

ViewModel là một phần của Android Architecture Components, được thiết kế đặc biệt để lưu trữ và quản lý dữ liệu UI theo một cách nhận biết vòng đời. Dữ liệu được lưu trữ trong ViewModel sẽ tồn tại qua các thay đổi cấu hình (như xoay màn hình).

Lý do ViewModel tốt hơn cho dữ liệu UI:

  • Nhận biết vòng đời: ViewModel tồn tại trong suốt vòng đời của Activity hoặc Fragment cho đến khi chúng bị finish() hoàn toàn (bởi người dùng hoặc hệ thống). Khi Activity/Fragment bị hủy do thay đổi cấu hình, ViewModel liên quan sẽ *không* bị hủy. Instance mới của Activity/Fragment sau khi được tạo lại sẽ nhận lại cùng một instance ViewModel ban đầu.
  • Tách biệt trách nhiệm: Giúp tách logic lấy dữ liệu và xử lý dữ liệu ra khỏi Activity/Fragment, giúp code sạch sẽ và dễ kiểm thử hơn.
  • Không lưu trữ View: ViewModel chỉ nên chứa dữ liệu, không nên chứa tham chiếu đến View, Context, hoặc bất kỳ đối tượng nào liên quan trực tiếp đến vòng đời của Activity/Fragment để tránh memory leaks.

Cách sử dụng:

Tạo một class kế thừa từ ViewModel:

class CounterViewModel : ViewModel() {
    private val _counter = MutableLiveData<Int>(0)
    val counter: LiveData<Int> = _counter // Exposed as LiveData for observation

    private val _userName = MutableLiveData<String>("")
    val userName: LiveData<String> = _userName

    fun incrementCounter() {
        _counter.value = (_counter.value ?: 0) + 1
    }

    fun setUserName(name: String) {
        _userName.value = name
    }

    // Optional: override onCleared() if you need to release resources
    override fun onCleared() {
        super.onCleared()
        Log.d("ViewModelDebug", "ViewModel is being destroyed")
    }
}

Truy cập ViewModel từ Activity hoặc Fragment:

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: CounterViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Lấy ViewModel. Hệ thống sẽ tạo mới nếu chưa có, hoặc trả về instance cũ nếu Activity bị tạo lại
        viewModel = ViewModelProvider(this).get(CounterViewModel::class.java)

        // Quan sát dữ liệu từ ViewModel và cập nhật UI
        viewModel.counter.observe(this, Observer { count ->
            textViewCounter.text = count.toString()
            Log.d("StateDebug", "UI updated with counter: $count")
        })

        viewModel.userName.observe(this, Observer { name ->
             editTextName.setText(name)
             Log.d("StateDebug", "UI updated with user name: $name")
        })

        buttonIncrement.setOnClickListener {
            viewModel.incrementCounter()
        }

        editTextName.addTextChangedListener(object : TextWatcher {
            // ... (standard TextWatcher methods) ...
            override fun afterTextChanged(s: Editable?) {
                viewModel.setUserName(s.toString())
            }
        })
    }
}

Ưu điểm:

  • Giải pháp chính thức và được khuyến nghị bởi Google cho dữ liệu UI tồn tại qua thay đổi cấu hình.
  • Tách biệt code rõ ràng, dễ bảo trì và kiểm thử.
  • Có thể chứa dữ liệu phức tạp, không bị giới hạn bởi Bundle size.
  • Hoạt động tốt với LiveData hoặc Flow để cập nhật UI một cách phản ứng.

Nhược điểm:

  • Mặc định, ViewModel không tồn tại qua quá trình hệ thống giết process do thiếu bộ nhớ. Nếu ứng dụng bị giết và sau đó được phục hồi, ViewModel sẽ bị tạo lại từ đầu và dữ liệu sẽ bị mất.
  • Cần thư viện Jetpack ViewModel.

3. Sử dụng Saved State Module for ViewModel

Để khắc phục nhược điểm của ViewModel khi bị giết process, bạn có thể sử dụng Saved State Module. Module này cho phép ViewModel truy cập vào SavedStateHandle, một đối tượng key-value map có khả năng lưu trữ dữ liệu vào Bundle hệ thống và phục hồi lại khi process được tạo lại.

class CounterViewModel(private val state: SavedStateHandle) : ViewModel() {

    // Sử dụng SavedStateHandle để lưu trữ dữ liệu
    private val _counter = state.getLiveData<Int>("counter_value", 0)
    val counter: LiveData<Int> = _counter

    private val _userName = state.getLiveData<String>("user_input", "")
    val userName: LiveData<String> = _userName

    fun incrementCounter() {
        val current = _counter.value ?: 0
        state["counter_value"] = current + 1 // Lưu vào SavedStateHandle
    }

    fun setUserName(name: String) {
         state["user_input"] = name // Lưu vào SavedStateHandle
    }
}

Trong Activity/Fragment, bạn cần sử dụng ViewModelProvider.Factory phù hợp (thường là AbstractSavedStateViewModelFactory hoặc đơn giản hơn là dùng by viewModels() hoặc by activityViewModels() delegate của Kotlin nếu bạn đã thêm dependencies).

Ưu điểm: Kết hợp ưu điểm của ViewModel (nhận biết vòng đời, tách biệt code) và onSaveInstanceState (khả năng phục hồi sau khi bị giết process).

Nhược điểm: Dữ liệu lưu vào SavedStateHandle vẫn bị giới hạn về loại và kích thước như Bundle thông thường.

Đây là bảng so sánh tóm tắt:

Cơ chế Phạm vi/Tuổi thọ Tồn tại qua thay đổi cấu hình? Tồn tại qua process bị giết? Loại dữ liệu Độ phức tạp cho dữ liệu lớn/phức tạp
onSaveInstanceState() / Bundle Activity/Fragment instance Có (đối với một số trường hợp phục hồi tự động) Các kiểu nguyên thủy, chuỗi, Parcelable, Serializable (kích thước nhỏ) Cao (phải serialize thủ công, dễ gặp lỗi kích thước)
ViewModel Scope của ViewModelProvider (Activity, Fragment, Navigation Graph) Không (mặc định) Mọi loại dữ liệu (không nên chứa View/Context) Thấp (chỉ cần giữ reference)
SavedStateHandle (trong ViewModel) Scope của ViewModelProvider + kết nối với Bundle hệ thống Các kiểu nguyên thủy, chuỗi, Parcelable, Serializable (kích thước nhỏ) Trung bình (phải lưu/đọc từ SavedStateHandle)

Điều hướng và Quản lý Backstack

Backstack là một cấu trúc dữ liệu dạng Stack (vào sau ra trước – LIFO) chứa các Activity hoặc Fragment (tùy thuộc vào cách bạn điều hướng).

  • Khi bạn mở một Activity mới bằng startActivity(intent), Activity mới được đẩy lên đỉnh Stack.
  • Khi bạn nhấn nút Back hoặc gọi finish(), Activity ở đỉnh Stack bị Pop ra khỏi Stack và bị hủy. Activity ngay bên dưới trở lại màn hình.

Đối với Fragment, bạn có một Backstack riêng biệt *trong* Activity nếu bạn sử dụng addToBackStack() khi thực hiện Fragment Transaction.

Điều hướng Activity và Intent Flags

Mặc định, mỗi lần startActivity sẽ tạo một instance mới của Activity và đẩy nó lên đỉnh Stack. Tuy nhiên, bạn có thể thay đổi hành vi này bằng cách sử dụng các cờ (Flags) trong Intent.

Một số cờ phổ biến ảnh hưởng đến Backstack:

  • FLAG_ACTIVITY_NEW_TASK: Bắt đầu Activity trong một task mới. Nếu task cho Activity đó đã tồn tại, nó sẽ được đưa lên foreground và Intent sẽ được gửi đến phương thức onNewIntent() của instance Activity hiện có (thay vì tạo instance mới). Thường dùng khi bắt đầu Activity từ bên ngoài ứng dụng thông thường (ví dụ: Service, Notification).
  • FLAG_ACTIVITY_CLEAR_TOP: Nếu Activity đang được khởi chạy đã tồn tại trong task hiện tại, thay vì khởi chạy một instance mới, tất cả các Activity nằm trên đỉnh của nó trong stack sẽ bị hủy, và Intent sẽ được gửi đến instance Activity đã tồn tại (thông qua onNewIntent()). Thường dùng kết hợp với FLAG_ACTIVITY_NEW_TASK để trở về một “điểm neo” trong luồng ứng dụng và xóa các màn hình trung gian.
  • FLAG_ACTIVITY_SINGLE_TOP: Nếu Activity đang khởi chạy đã nằm ở đỉnh của stack, instance hiện tại sẽ nhận Intent thông qua onNewIntent() thay vì tạo một instance mới. Nếu nó không ở đỉnh, instance mới vẫn được tạo và đẩy lên stack như bình thường.

Ví dụ sử dụng Flags:

fun navigateToHomeAndClearStack() {
    val intent = Intent(this, HomeActivity::class.java)
    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
    startActivity(intent)
    finish() // Tùy chọn: kết thúc Activity hiện tại để không thể quay lại nó
}

fun navigateToProfileIfSingleTop() {
    val intent = Intent(this, ProfileActivity::class.java)
    intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
    startActivity(intent)
}

Điều hướng Fragment và Quản lý Fragment Backstack

Khi làm việc với Fragment bên trong một Activity, bạn sử dụng FragmentManagerFragmentTransaction để thêm, thay thế, hoặc xóa Fragment. Mặc định, việc thay thế (replace) hoặc thêm (add) Fragment không ảnh hưởng đến Backstack *của Activity*. Nút Back của hệ thống sẽ chỉ tác động đến Activity.

Để nút Back của hệ thống điều hướng giữa các Fragment đã thay thế/thêm vào, bạn cần sử dụng addToBackStack() trong Fragment Transaction.

Ví dụ sử dụng addToBackStack():

fun showDetailFragment() {
    supportFragmentManager.beginTransaction()
        .replace(R.id.fragment_container, DetailFragment()) // Thay thế Fragment hiện tại
        .addToBackStack(null) // Thêm transaction này vào Fragment Backstack
        .commit()
}

fun showSettingsFragment() {
    supportFragmentManager.beginTransaction()
        .replace(R.id.fragment_container, SettingsFragment())
        .addToBackStack("settings_tag") // Có thể đặt tên cho stack entry
        .commit()
}

Khi người dùng nhấn nút Back:

  • Nếu Fragment Backstack không rỗng, transaction ở đỉnh sẽ bị Pop, đưa Fragment trước đó lên màn hình.
  • Nếu Fragment Backstack rỗng, nút Back sẽ tác động đến Activity (Pop Activity ra khỏi Activity Stack).

Bạn cũng có thể tự quản lý Fragment Backstack bằng popBackStack():

// Pop transaction ở đỉnh của stack
supportFragmentManager.popBackStack()

// Pop đến một entry cụ thể (dùng tên hoặc ID)
supportFragmentManager.popBackStack("settings_tag", FragmentManager.POP_BACK_STACK_INCLUSIVE) // POP_BACK_STACK_INCLUSIVE: bao gồm cả entry có tên đó

Sử dụng Android Navigation Component (Được khuyến nghị)

Navigation Component là một phần khác của Android Architecture Components, cung cấp một framework cấu trúc để xây dựng điều hướng trong ứng dụng Android. Nó đơn giản hóa việc triển khai điều hướng, đặc biệt là khi làm việc với Fragment.

Navigation Component sử dụng một đồ thị điều hướng (Navigation Graph) được định nghĩa bằng XML (hoặc Code/DSL trong Compose), mô tả các đích đến (destinations – thường là Fragment, Activity, hoặc custom destinations) và các hành động (actions) để đi từ đích này sang đích khác.

Lợi ích của Navigation Component:

  • Quản lý Backstack tự động: Nó xử lý việc thêm/xóa các Fragment vào Fragment Backstack một cách tự động và chính xác khi bạn di chuyển giữa các đích.
  • Xử lý truyền đối số an toàn: Sử dụng Safe Args Gradle Plugin để tạo code giúp truyền dữ liệu giữa các đích một cách an toàn về kiểu dữ liệu.
  • Hỗ trợ Deeplinks: Dễ dàng định nghĩa và xử lý deeplinks.
  • Tích hợp UI: Có thể dễ dàng tích hợp với BottomNavigationView, NavigationView (cho Drawer) để cập nhật UI tự động khi đích đến thay đổi.
  • Testing: Dễ dàng kiểm thử luồng điều hướng.

Thay vì tự viết FragmentTransaction và quản lý addToBackStack() thủ công, bạn chỉ cần xác định các đích đến và hành động trong Navigation Graph và sử dụng NavController để thực hiện hành động đó.

// Ví dụ sử dụng NavController để điều hướng
findNavController().navigate(R.id.action_fragmentA_to_fragmentB)

// Điều hướng với đối số (sử dụng Safe Args)
val action = FragmentADirections.actionFragmentAToFragmentB(userId = 123)
findNavController().navigate(action)

Navigation Component là cách hiện đại và được khuyến nghị để quản lý điều hướng và Backstack Fragment. Mặc dù bạn vẫn cần hiểu cơ chế dưới hood (FragmentTransaction, Bundle) để debug hoặc làm việc với code cũ, nhưng nên ưu tiên sử dụng Nav Component cho các dự án mới hoặc khi có thể.

Kết nối giữa State và Backstack

Việc xử lý trạng thái và quản lý Backstack có mối liên hệ chặt chẽ. Khi người dùng nhấn nút Back để quay lại một màn hình trước đó, Activity/Fragment đó có thể đã bị hủy và cần được tạo lại. Lúc này, hệ thống sẽ cố gắng phục hồi trạng thái của nó từ Bundle được lưu bởi onSaveInstanceState() (nếu có) hoặc instance ViewModel cũ (nếu chỉ là thay đổi cấu hình).

Nếu bạn lưu trữ trạng thái quan trọng trong onSaveInstanceState() hoặc sử dụng SavedStateHandle trong ViewModel, màn hình sẽ hiển thị lại gần giống với trạng thái người dùng đã rời đi, mang lại trải nghiệm liền mạch hơn.

Lời khuyên và Thực hành

  • Luôn giả định process có thể bị giết bất cứ lúc nào: Đừng chỉ test bằng cách xoay màn hình. Bật tùy chọn “Don’t keep activities” trong Developer Options trên thiết bị/emulator để mô phỏng tình huống process bị giết do thiếu bộ nhớ. Test xem ứng dụng của bạn có phục hồi trạng thái đúng cách sau khi quay lại không.
  • Sử dụng ViewModel cho dữ liệu UI: Đối với dữ liệu mà UI cần hiển thị và thao tác, hãy đưa nó vào ViewModel. Điều này giúp code sạch sẽ và xử lý tốt thay đổi cấu hình.
  • Sử dụng SavedStateHandle cho trạng thái quan trọng sau khi bị giết process: Đối với những mẩu dữ liệu nhỏ, quan trọng (như ID của mục đang hiển thị, tab đang chọn, truy vấn tìm kiếm) mà bạn *muốn* phục hồi sau khi bị giết process, hãy lưu trữ chúng trong SavedStateHandle của ViewModel.
  • Hiểu rõ Intent Flags: Biết khi nào cần sử dụng FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_CLEAR_TOP, FLAG_ACTIVITY_SINGLE_TOP để kiểm soát luồng Activity chính xác.
  • Ưu tiên Navigation Component: Đối với điều hướng giữa Fragment trong một Activity, hãy học và sử dụng Navigation Component. Nó sẽ giúp bạn tiết kiệm rất nhiều công sức và tránh được các lỗi phức tạp khi tự quản lý Fragment Backstack thủ công.
  • Vẽ sơ đồ: Khi luồng điều hướng phức tạp, hãy vẽ sơ đồ các màn hình và cách chúng liên kết với nhau, bao gồm cả cách bạn muốn nút Back hoạt động.

Kết luận

Xử lý thay đổi trạng thái và quản lý Backstack là hai kỹ năng nền tảng mà bất kỳ nhà phát triển Android nào cũng cần thành thạo. Bằng cách hiểu cách Android quản lý vòng đời của các component và áp dụng đúng các công cụ như onSaveInstanceState(), ViewModel (với SavedStateHandle), Intent Flags và đặc biệt là Navigation Component, bạn có thể xây dựng các ứng dụng mạnh mẽ, đáng tin cậy và cung cấp trải nghiệm người dùng tuyệt vời.

Đây là những kiến thức cốt lõi để bạn tiến xa hơn trên Lộ trình học Lập trình viên Android 2025. Hãy dành thời gian thực hành với các ví dụ nhỏ, thử nghiệm các tình huống khác nhau (xoay màn hình, chuyển đổi ứng dụng, bật “Don’t keep activities”) để củng cố kiến thức.

Trong bài viết tiếp theo, chúng ta sẽ chuyển sang một chủ đề không kém phần quan trọng: Xây dựng giao diện người dùng với các Layout cơ bản và View Groups. Hẹn gặp lại!

Chỉ mục