MVI — ทำความเข้าใจรูปแบบ Model-View-Intent ในแอปมือถือ

ผู้แต่ง: IT Sectr เผยแพร่เมื่อ: 2026-02-16 เวลาอ่าน: 10 นาที

MVI (Model-View-Intent) เป็นรูปแบบสถาปัตยกรรมแบบรีแอกทีฟที่อิงตามการไหลของข้อมูลทางเดียวและสถานะที่ไม่สามารถเปลี่ยนแปลงได้ ต่างจาก MVVM ที่ ViewModel สามารถมี StateFlows ได้หลายตัว MVI กำหนดสถานะเดียว (State) ความตั้งใจที่ไม่สามารถเปลี่ยนแปลงได้ (Intent) และฟังก์ชันรีดิวเซอร์บริสุทธิ์ (Reducer) MVI รับประกันการคาดเดาได้ของสถานะหน้าจอในทุกช่วงเวลา รูปแบบนี้ได้รับความนิยมในชุมชน Android โดยไลบรารี Mosby และ Orbit เรียนรู้เพิ่มเติมใน MVIKotlin โดย Arkadii Ivanov

ประเด็นสำคัญ

  • MVI — Model (สถานะ), View (การแสดงผล), Intent (ความตั้งใจของผู้ใช้) — วงจรแบบรีแอกทีฟ
  • Unidirectional data flow — ข้อมูลไหลในทิศทางเดียว: Intent → Reducer → State → View
  • Immutable State — สถานะหน้าจอเป็นออบเจกต์ที่ไม่สามารถเปลี่ยนแปลงได้ ถูกสร้างใหม่ทุกครั้งที่มีการเปลี่ยนแปลง
  • Reducer — ฟังก์ชันบริสุทธิ์ที่รับสถานะปัจจุบันและ Intent แล้วคืนสถานะใหม่
  • Side effects — ผลข้างเคียง (เครือข่าย, DB) ถูกจัดการแยกจาก Reducer ผ่าน Middleware

MVI คืออะไร: แก่นของรูปแบบ Model-View-Intent

MVI (Model-View-Intent) เป็นรูปแบบสถาปัตยกรรมแบบรีแอกทีฟที่สร้างขึ้นบนหลักการของ Redux และ Cycle.js Model คือสถานะหน้าจอที่ไม่สามารถเปลี่ยนแปลงได้ Intent คือความตั้งใจของผู้ใช้หรือระบบ View สมัครสมาชิกสถานะและส่ง Intents ข้อมูลไหลในวงจร: ผู้ใช้โต้ตอบกับ View → View สร้าง Intent → Intent ถูกประมวลผลโดย Reducer → Reducer สร้างสถานะใหม่ → View รับสถานะใหม่และเรนเดอร์ใหม่

ความแตกต่างหลักระหว่าง MVI และ MVVM คือแหล่งความจริงเดียว (Single Source of Truth) ใน MVVM ViewModel สามารถมี LiveData/StateFlow หลายตัว (userState, loadingState, errorState) ซึ่งนำไปสู่ความไม่สอดคล้อง: loading=true และ user=null พร้อมกัน ใน MVI มี sealed class/interface State เพียงอันเดียวที่อธิบายสถานะหน้าจอทั้งหมด ในทุกช่วงเวลา สถานะหน้าจอถูกกำหนดอย่างไม่ซ้ำกัน — เป็นไปไม่ได้ที่จะมี loading=true เมื่อข้อมูลถูกโหลดแล้ว ที่ IT Sectr เราใช้ MVI สำหรับหน้าจอที่มีตรรกะซับซ้อน — แบบฟอร์มคำสั่งซื้อ การลงทะเบียนหลายขั้นตอน หน้าจอทางการเงิน — ซึ่งการคาดเดาได้ของสถานะเป็นสิ่งสำคัญ

ส่วนประกอบบทบาทใน MVIตัวอย่าง
Intentความตั้งใจของผู้ใช้หรือระบบLoadUser, Refresh, SubmitForm
Stateสถานะหน้าจอที่ไม่สามารถเปลี่ยนแปลงได้sealed class UserState
Reducerฟังก์ชันบริสุทธิ์: State + Intent → Statefun reduce(state, intent) -> state
Middlewareการจัดการผลข้างเคียงคำขอเครือข่าย, การเขียน DB

วงจร MVI ประกอบด้วยห้าขั้นตอน: 1) View ส่ง Intent (เช่น LoadUser(42)); 2) Middleware (EffectHandler) ดำเนินการผลข้างเคียง — คำขอเครือข่าย; 3) ผลลัพธ์ถูกส่งกลับเป็น Intent ใหม่เข้าสู่ระบบ; 4) Reducer รับสถานะปัจจุบันและ Intent สร้างสถานะใหม่; 5) View รับสถานะใหม่และเรนเดอร์ใหม่ แต่ละขั้นตอนสามารถคาดเดาได้และทดสอบแยกกัน

MVI ใน Android: Intent, Reducer, State ใน Kotlin

MVI บน Android ถูกนำไปใช้โดยใช้ sealed classes สำหรับ Intent และ State, ViewModel ที่มีตรรกะ MVI และ Jetpack Compose สำหรับการเรนเดอร์แบบรีแอกทีฟ ViewModel รับ Intent จาก View มอบหมายผลข้างเคียงให้ Middleware เรียกใช้ Reducer และเผยแพร่สถานะใหม่ผ่าน StateFlow Jetpack Compose เรนเดอร์ UI ใหม่เมื่อสถานะเปลี่ยนแปลง — เหมาะสำหรับวงจร MVI

kotlin
// Intent — ความตั้งใจของผู้ใช้
sealed interface UserIntent {
    data class LoadUser(val userId: Int) : UserIntent
    data object Refresh : UserIntent
}

// State — สถานะหน้าจอเดียว
sealed interface UserState {
    data object Idle : UserState
    data object Loading : UserState
    data class Success(val user: User) : UserState
    data class Error(val message: String) : UserState
}

// Reducer — ฟังก์ชันบริสุทธิ์
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// ViewModel กับ MVI
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _state = MutableStateFlow<UserState>(UserState.Idle)
    val state: StateFlow<UserState> = _state.asStateFlow()

    fun process(intent: UserIntent) {
        val newState = UserReducer.reduce(_state.value, intent)
        _state.value = newState
        when (intent) {
            is UserIntent.LoadUser -> loadUser(intent.userId)
            is UserIntent.Refresh -> loadUser(/* ID ก่อนหน้า */)
        }
    }

    private fun loadUser(userId: Int) {
        viewModelScope.launch {
            repository.getUser(userId)
                .onSuccess { user ->
                    _state.value = UserState.Success(user)
                }
                .onFailure { e ->
                    _state.value = UserState.Error(e.message ?: "Error")
                }
        }
    }
}

// View ส่ง Intent
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.state.collectAsState()
    LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
    when (state) {
        is UserState.Loading -> CircularProgressIndicator()
        is UserState.Success -> UserCard((state as UserState.Success).user)
        is UserState.Error -> ErrorView((state as UserState.Error).message)
        is UserState.Idle -> Text("Press to load")
    }
}

Middleware และผลข้างเคียง — ใน MVI Reducer บริสุทธิ์ไม่สามารถดำเนินการคำขอเครือข่ายได้ Middleware (เรียกอีกอย่างว่า EffectHandler หรือ Bootstrapper) ประมวลผล Intent ดำเนินการผลข้างเคียง และส่ง Intent ใหม่กลับเข้าสู่วงจร ไลบรารี Orbit MVI และ MVIKotlin ให้การสนับสนุน Middleware ในตัวพร้อมเอฟเฟกต์ที่ทดสอบได้ หากไม่มี Middleware MVI จะเสื่อมสภาพเป็น MVVM ที่มีโครงสร้าง Intent และ State เพิ่มเติม

MVIKotlin โดย Arkadii Ivanov เป็นไลบรารี MVI ที่ได้รับความนิยมมากที่สุดสำหรับ Kotlin Multiplatform รองรับ Android, iOS, เว็บ และ JVM มีส่วนประกอบ: Store (ViewModel), Bootstrapper (เอฟเฟกต์เริ่มต้น), Reducer, Middleware ณ ตุลาคม 2025 ไลบรารีมีดาว 2.5K บน GitHub และถูกใช้ในโครงการเชิงพาณิชย์ รวมถึงแอปพลิเคชันของธนาคารใหญ่ในรัสเซีย ที่ IT Sectr เราใช้ MVIKotlin สำหรับโครงการ KMP ข้ามแพลตฟอร์มที่มีตรรกะทางธุรกิจร่วมกัน

MVI ใน iOS: การไหลทางเดียวใน Swift

MVI บน iOS ถูกนำไปใช้โดยไม่มี Combine-ViewModel ผ่านวงจร Intent → State View ส่ง Intent ผ่าน closure, Reducer เป็นฟังก์ชันบริสุทธิ์, และ State เป็น struct ที่มีฟิลด์ที่ไม่สามารถเปลี่ยนแปลงได้ SwiftUI เรนเดอร์ View ใหม่เมื่อ State เปลี่ยนแปลง ซึ่งเข้ากับวงจร MVI ได้อย่างสมบูรณ์แบบโดยไม่มีคุณสมบัติ @Published เพิ่มเติม MVI บน iOS ได้รับความนิยมเป็นพิเศษในหมู่นักพัฒนา SwiftUI ที่ย้ายมาจาก Redux (JavaScript)

swift
// State — โครงสร้างที่ไม่สามารถเปลี่ยนแปลงได้
struct UserState: Equatable {
    var user: User?
    var isLoading = false
    var errorMessage: String?
}

// Intent — enum ที่มีความตั้งใจ
enum UserIntent {
    case loadUser(id: Int)
    case refresh
    case userLoaded(User)
    case loadFailed(Error)
}

// Reducer — ฟังก์ชันบริสุทธิ์
func userReducer(state: UserState, intent: UserIntent) -> UserState {
    var newState = state
    switch intent {
    case .loadUser, .refresh:
        newState.isLoading = true
        newState.errorMessage = nil
    case .userLoaded(let user):
        newState.isLoading = false
        newState.user = user
    case .loadFailed(let error):
        newState.isLoading = false
        newState.errorMessage = error.localizedDescription
    }
    return newState
}

// Store — เป็นเจ้าของสถานะและจัดการเอฟเฟกต์
final class UserStore: ObservableObject {
    @Published private(set) var state = UserState()
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func dispatch(_ intent: UserIntent) {
        // 1. Reducer อัปเดตสถานะ
        state = userReducer(state: state, intent: intent)
        // 2. Side effects (หากจำเป็น)
        switch intent {
        case .loadUser(let id), .refresh:
            service.fetchUser(id: id) { [weak self] result in
                switch result {
                case .success(let user):
                    self?.dispatch(.userLoaded(user))
                case .failure(let error):
                    self?.dispatch(.loadFailed(error))
                }
            }
        default: break
        }
    }
}

TCA (The Composable Architecture) เป็นการนำ MVI ไปใช้ที่ได้รับความนิยมมากที่สุดสำหรับ iOS โดย Point-Free สร้างขึ้นบน SwiftUI และ Combine TCA มี Store, Reducer, Effect และ Environment ณ ตุลาคม 2025 ดาวบน GitHub เกิน 13K — เป็นมาตรฐานโดยพฤตินัยสำหรับ MVI บน iOS TCA ถูกใช้ในแอป Starbucks, Airbnb (บางส่วน) และโครงการอิสระจำนวนมาก ต่างจาก MVI ที่กำหนดเอง TCA จัดการการทดสอบ การนำทาง และผลข้างเคียงได้ทันที

MVI เทียบกับ MVVM บน iOS — TCA/MVI ให้การคาดเดาได้ของสถานะแต่ต้องใช้โค้ดเทมเพลตมากขึ้น (Reducer, State, Action) MVVM ที่มี @Published ง่ายกว่าสำหรับหน้าจอพื้นฐาน ที่ IT Sectr เราใช้ MVVM สำหรับ 80% ของหน้าจอ และ MVI (TCA) สำหรับ 20% ที่ซับซ้อน — ธุรกรรมทางการเงิน ฟอร์มหลายขั้นตอน อินเทอร์เฟซลากแล้ววาง — ที่ข้อผิดพลาดของสถานะอาจทำให้ผู้ใช้เสียเงิน

เปรียบเทียบ MVI กับ MVVM: เมื่อใดควรเลือก MVI

MVI และ MVVM แก้ปัญหาเดียวกัน — การจัดระเบียบเลเยอร์การนำเสนอ — แต่ด้วยวิธีการจัดการสถานะที่แตกต่างกัน MVVM อนุญาตแหล่งรีแอกทีฟหลายแหล่ง (LiveData, @Published) ซึ่งอาจนำไปสู่ความไม่สอดคล้อง MVI รับประกันสถานะเดียวในทุกช่วงเวลา ทำให้เข้มงวดและคาดเดาได้มากขึ้น แต่เพิ่มปริมาณโค้ด

เกณฑ์MVVMMVI
สถานะLiveData/StateFlow หลายตัวSealed class State เดียว
การไหลของข้อมูลสองทาง (View → ViewModel, LiveData → View)ทางเดียว (Intent → Reducer → State → View)
ผลข้างเคียงโดยตรงใน ViewModelผ่าน Middleware/EffectHandler
การทดสอบการทดสอบหน่วยของ ViewModelการทดสอบหน่วยของ Reducer + Middleware
โค้ดเทมเพลตน้อยที่สุดReducer + State + Intent + Middleware

เมื่อใดควรเลือก MVI — หน้าจอที่สถานะต้องกำหนดได้อย่างเคร่งครัด: ธุรกรรมทางการเงิน ตะกร้าสินค้า ฟอร์มหลายขั้นตอนที่มีการตรวจสอบในแต่ละขั้นตอน ในสถานการณ์เหล่านี้ ต้นทุนของข้อผิดพลาดของสถานะ (เช่น การแสดงยอดรวมตะกร้าที่ขาดสินค้าหนึ่งรายการเนื่องจากสภาวะการแข่งขันระหว่าง LiveData สองตัว) สูงกว่าต้นทุนของโค้ดเพิ่มเติม ใน MVVM คุณพึ่งพาวินัยของทีม ใน MVI คุณพึ่งพาสถาปัตยกรรม

เมื่อใด MVVM เพียงพอ — 80% ของหน้าจอมาตรฐาน: รายชื่อผู้ใช้ โปรไฟล์ การตั้งค่า ฟีดข่าว ที่นี่สถานะเดี่ยวนั้นมากเกินไป และโครงสร้าง MVI เพิ่มเติมจะทำให้การพัฒนาช้าลง ที่ IT Sectr กฎคือ: หากหน้าจอมี 3+ สถานะที่เป็นไปได้พร้อมการเปลี่ยนสถานะ (โหลด → ข้อมูล → ข้อผิดพลาด → ลองใหม่ → โหลด → ข้อมูล) — ใช้ MVI หากหน้าจอมีการดำเนินการแบบอะซิงโครนัส 1-2 รายการ — ใช้ MVVM

แนวปฏิบัติที่ดีที่สุดของ MVI และข้อผิดพลาดทั่วไป

Sealed State — แนวปฏิบัติที่ดีที่สุด ใน MVI สถานะถูกกำหนดเป็น sealed class/interface ที่มีตัวแปร: Loading, Success(data), Error(message) สิ่งนี้รับประกันว่า View จะไม่จบลงในสถานะที่ไม่สอดคล้องกัน — ไม่สามารถแสดงข้อมูลเมื่อ loading=true เพราะ Loading และ Success เป็นคลาสที่แตกต่างกัน ข้อมูลทั้งหมดที่เกี่ยวข้องกับสถานะอยู่ภายใน sealed variant: Success มีผู้ใช้, Error มีข้อความข้อผิดพลาด

Reducer ต้องคงเป็นฟังก์ชันบริสุทธิ์ — โดยไม่เรียก API, DB หรือ SharedPreferences ฟังก์ชันบริสุทธิ์รับ State และ Intent และคืน State ผลข้างเคียง (เครือข่าย, DB, การนำทาง, แจ้งเตือน) ถูกจัดการใน Middleware หรือใน Store.dispatch หลังจากเรียก Reducer หาก Reducer ปนเปื้อนด้วยผลข้างเคียง MVI จะสูญเสียความสามารถในการทดสอบและการคาดเดา — คุณจะได้ MVVM ที่มีโครงสร้างเพิ่มเติมโดยไม่มีประโยชน์

ข้อผิดพลาดทั่วไป — การประกาศ State เป็น data class ที่มีฟิลด์ nullable แทน sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?) ซึ่งเทียบเท่ากับ MVVM ไม่ใช่ MVI — View ต้องตรวจสอบชุดค่าผสมของฟิลด์เพื่อความถูกต้อง ในแนวทาง sealed ชุดค่าผสมที่ไม่ถูกต้อง (isLoading=true และ user!=null) เป็นไปไม่ได้ในระดับชนิด ข้อผิดพลาดที่สองคือการวางตรรกะทางธุรกิจใน Intent (Intent.LoadUserBeforeXHours) แทนที่จะสร้าง Intent คำสั่งง่ายๆ (Intent.LoadUser) และวางตรรกะทางธุรกิจใน Middleware

คำถามที่พบบ่อย

ความแตกต่างหลักระหว่าง MVI และ MVVM คืออะไร?

MVI ใช้คลาส sealed State ที่ไม่สามารถเปลี่ยนแปลงได้เพียงคลาสเดียวและการไหลข้อมูลทางเดียวผ่าน Reducer MVVM อนุญาต LiveData/StateFlow หลายตัวพร้อมการผูกสองทาง MVI รับประกันความสอดคล้องของสถานะในระดับชนิด — เป็นไปไม่ได้ที่จะมี loading=true และ user=null พร้อมกัน MVVM ขึ้นอยู่กับวินัยของนักพัฒนา

มีไลบรารี MVI อะไรบ้างสำหรับ Android?

หลักๆ: MVIKotlin (Arkadii Ivanov, 2.5K ดาว, Kotlin Multiplatform), Orbit MVI (BabyJ, 1.3K ดาว), Mobius (Spotify, Kotlin/Java) MVIKotlin ได้รับความนิยมมากที่สุดสำหรับ Kotlin, Orbit เรียนรู้ง่ายที่สุด ทั้งสามรองรับ Reducer และ Middleware ที่ทดสอบได้ สำหรับ Jetpack Compose คุณสามารถเขียน MVI ง่ายๆ โดยไม่ต้องใช้ไลบรารีด้วย sealed State + Reducer

จำเป็นต้องมีไลบรารีแยกต่างหากสำหรับ MVI หรือไม่?

ไม่ — sealed Intent + sealed State + ViewModel + StateFlow ให้ MVI ที่ทำงานได้โดยไม่มีการพึ่งพา ไลบรารี (MVIKotlin, Orbit, TCA) เพิ่ม Middleware การทดสอบผลข้างเคียง และการรวม DI สำหรับโครงการง่ายๆ น้ำหนักของไลบรารีไม่สมเหตุสมผล สำหรับโครงการที่ซับซ้อนที่มี 20+ หน้าจอ ไลบรารีคุ้มค่าด้วยการจัดการเอฟเฟกต์ที่มีโครงสร้าง

MVI เหมาะสำหรับ iOS หรือเป็นรูปแบบเฉพาะของ Android?

MVI ทำงานได้ดีสำหรับ iOS ผ่าน TCA (The Composable Architecture) — สถาปัตยกรรมที่ได้รับความนิยมมากที่สุดในชุมชน SwiftUI TCA โดยพื้นฐานคือ MVI + Redux + Combine บน iOS คุณสามารถนำ MVI ไปใช้โดยไม่ต้องใช้ TCA ด้วย ObservableObject และฟังก์ชัน reducer บริสุทธิ์ SwiftUI ที่มี State ที่ไม่สามารถเปลี่ยนแปลงได้เข้ากับวงจร MVI ได้อย่างสมบูรณ์แบบ

จะทดสอบ MVI ได้อย่างไร?

Reducer ถูกทดสอบด้วยการทดสอบหน่วยในฐานะฟังก์ชันบริสุทธิ์: ตั้งค่า State เริ่มต้น ส่ง Intent ตรวจสอบ State ผลลัพธ์ Middleware ถูกทดสอบด้วยพื้นที่เก็บข้อมูลจำลอง: ยืนยันว่า getUser ถูกเรียกหลัง LoadUser การทดสอบ ViewModel: ส่ง Intent ตรวจสอบ StateFlow MVI ทดสอบได้ง่ายกว่า MVVM เพราะ Reducer เป็นฟังก์ชันบริสุทธิ์ที่ไม่มีการพึ่งพาที่ซ่อนอยู่

สรุป

  • MVI (Model-View-Intent) — รูปแบบรีแอกทีฟที่มีการไหลทางเดียวและสถานะเดียว
  • Sealed State — รับประกันความสอดคล้องในระดับชนิด กำจัดชุดค่าผสมที่ไม่ถูกต้อง
  • Reducer — ฟังก์ชันบริสุทธิ์ State + Intent → State ทดสอบได้โดยไม่มีออบเจกต์จำลอง
  • Middleware — ชั้นแยกต่างหากสำหรับผลข้างเคียง (เครือข่าย, DB, การนำทาง)
  • MVI vs MVVM — MVI เข้มงวดกว่าและคาดเดาได้กว่า MVVM ง่ายกว่าและเร็วกว่า
  • Android — MVIKotlin หรือ Orbit สำหรับหน้าจอซับซ้อน; MVVM สำหรับหน้าจอง่าย
  • iOS — TCA (The Composable Architecture) เป็นมาตรฐาน MVI บน SwiftUI

เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร

IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ

ปรึกษาโครงการ

อ่านเพิ่มเติม