การแทรกทรัพยากร Dependency ด้วยตนเอง

สถาปัตยกรรมแอปที่แนะนำของ Android สนับสนุนให้แบ่งโค้ดออกเป็นคลาสต่างๆ เพื่อให้ได้รับประโยชน์จากการแยกความกังวล ซึ่งเป็นหลักการที่แต่ละคลาสของลำดับชั้นมีหน้าที่รับผิดชอบที่กำหนดไว้เพียงอย่างเดียว ซึ่งจะทำให้มีคลาสขนาดเล็กจำนวนมากขึ้น ที่ต้องเชื่อมต่อกันเพื่อตอบสนองการอ้างอิงของกันและกัน

โดยปกติแล้วแอป Android จะประกอบด้วยคลาสจำนวนมาก และบางคลาส
    จะขึ้นอยู่กับคลาสอื่นๆ
รูปที่ 1 โมเดลของกราฟแอปพลิเคชันของแอป Android

การขึ้นต่อกันระหว่างคลาสสามารถแสดงเป็นกราฟได้ โดยแต่ละคลาสจะเชื่อมต่อกับคลาสที่ขึ้นต่อกัน การแสดงคลาสทั้งหมดและการขึ้นต่อกันจะประกอบกันเป็นกราฟแอปพลิเคชัน ในรูปที่ 1 คุณจะเห็นการแยกส่วนของกราฟแอปพลิเคชัน เมื่อคลาส A (ViewModel) ขึ้นอยู่กับคลาส B (Repository) จะมีเส้นที่ชี้จาก A ไปยัง B ซึ่งแสดงถึงการอ้างอิงดังกล่าว

การแทรกการอ้างอิงช่วยสร้างการเชื่อมต่อเหล่านี้และช่วยให้คุณสลับการใช้งานสำหรับการทดสอบได้ เช่น เมื่อทดสอบ ViewModel ที่ ขึ้นอยู่กับที่เก็บ คุณจะส่งการติดตั้งใช้งาน Repository ที่แตกต่างกันด้วยการจำลองหรือการทดสอบเพื่อทดสอบกรณีต่างๆ ได้

พื้นฐานของการแทรกทรัพยากร Dependency ด้วยตนเอง

ส่วนนี้จะอธิบายวิธีใช้การแทรกทรัพยากร Dependency ด้วยตนเองในสถานการณ์แอป Android จริง โดยจะอธิบายแนวทางที่ทำซ้ำเกี่ยวกับวิธีที่คุณอาจเริ่มใช้การแทรกทรัพยากร Dependency ในแอป แนวทางนี้จะได้รับการปรับปรุงจนถึงจุดที่คล้ายกับสิ่งที่ Dagger จะสร้างให้คุณโดยอัตโนมัติเป็นอย่างมาก ดูข้อมูลเพิ่มเติมเกี่ยวกับ Dagger ได้ที่ข้อมูลเบื้องต้นเกี่ยวกับ Dagger

พิจารณาว่าโฟลว์คือกลุ่มหน้าจอในแอปที่สอดคล้องกับฟีเจอร์ การเข้าสู่ระบบ การลงทะเบียน และการชำระเงินเป็นตัวอย่างของโฟลว์

เมื่อครอบคลุมขั้นตอนการเข้าสู่ระบบสำหรับแอป Android ทั่วไป LoginActivity จะขึ้นอยู่กับ LoginViewModel ซึ่งขึ้นอยู่กับ UserRepository จากนั้น UserRepository จะขึ้นอยู่กับ UserLocalDataSource และ UserRemoteDataSource ซึ่งจะขึ้นอยู่กับบริการ Retrofit อีกที

LoginActivity เป็นจุดแรกเข้าของขั้นตอนการเข้าสู่ระบบ และผู้ใช้โต้ตอบ กับกิจกรรม ดังนั้น LoginActivity จึงต้องสร้าง LoginViewModel พร้อมกับการขึ้นต่อกันทั้งหมด

คลาส Repository และ DataSource ของโฟลว์มีลักษณะดังนี้

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

ใน Compose ComponentActivity คือจุดแรกเข้า การเชื่อมต่อ Dependency จะเกิดขึ้นครั้งเดียวใน onCreate และ UI จะอธิบายโดย Composable ที่เรียกจาก setContent

class ApiService {
    /* Your API implementation here */
}

class UserRepository(private val apiService: ApiService) {
    /* Your implementation here */
}

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Satisfy the dependencies of LoginViewModel recursively,
        // then pass what the UI needs into setContent.
        val apiService = ApiService()
        val userRepository = UserRepository(apiService)

        setContent {
            LoginScreen(userRepository)
        }
    }
}

@Composable
fun LoginScreen(userRepository: UserRepository) {
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(userRepository)
    )
    // ...
}

แนวทางนี้มีปัญหาดังนี้

  1. ต้องประกาศการขึ้นต่อกันตามลำดับ คุณต้องสร้างอินสแตนซ์ของ UserRepository ก่อน LoginViewModel จึงจะสร้างได้
  2. นำออบเจ็กต์กลับมาใช้ใหม่ได้ยาก หากต้องการนำ UserRepository ไปใช้ซ้ำในฟีเจอร์ต่างๆ คุณจะต้องทำให้เป็นไปตามรูปแบบ Singleton รูปแบบ Singleton ทำให้การทดสอบยากขึ้นเนื่องจาก การทดสอบทั้งหมดใช้ Singleton อินสแตนซ์เดียวกัน

การจัดการทรัพยากร Dependency ด้วยคอนเทนเนอร์

หากต้องการแก้ปัญหาการนำออบเจ็กต์กลับมาใช้ซ้ำ คุณสามารถสร้างคลาสคอนเทนเนอร์ทรัพยากร Dependencyของคุณเองเพื่อใช้รับทรัพยากร Dependency ได้ อินสแตนซ์ทั้งหมดที่คอนเทนเนอร์นี้จัดเตรียมให้สามารถเป็นแบบสาธารณะได้ ในตัวอย่างนี้ เนื่องจากคุณต้องการเพียงอินสแตนซ์ของ UserRepository คุณจึงทำให้การอ้างอิงเป็นแบบส่วนตัวได้โดยมีตัวเลือกในการทำให้เป็นแบบสาธารณะในอนาคตหากจำเป็นต้องระบุ

// Container of objects shared across the whole app
class AppContainer {

    // apiService and userRepository aren't private and will be exposed
    val apiService = ApiService()
    val userRepository = UserRepository(apiService)
}

เนื่องจากมีการใช้การขึ้นต่อกันเหล่านี้ในทั้งแอปพลิเคชัน จึงต้องวางไว้ในที่ที่กิจกรรมทั้งหมดใช้ร่วมกันได้ นั่นคือคลาส Application สร้างApplicationคลาสที่กำหนดเองซึ่งมีอินสแตนซ์ AppContainer

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

เมื่อใช้ Compose ระบบจะยังคงสร้าง AppContainer เดียวกันในคลาสย่อย Application คุณเข้าถึงได้ทั้งในกิจกรรม ก่อนเรียกใช้ setContent หรือ จากภายใน Composable โดยใช้ LocalContext ดังนี้

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer

        setContent {
            LoginScreen(appContainer.userRepository)
        }
    }
}

// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
    val context = LocalContext.current
    val appContainer = (context.applicationContext as MyApplication).appContainer
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(appContainer.userRepository)
    )
    // ...
}

เราขอแนะนำให้ส่งการอ้างอิงเป็นพารามิเตอร์ที่ใช้ร่วมกันได้แทนการเข้าถึง LocalContext จากส่วนลึกของทรี ซึ่งจะช่วยให้ทดสอบ Composable ได้และทำให้อินพุตของ Composable ชัดเจน แก้ไขคอนเทนเนอร์ครั้งเดียวที่รูทของหน้าจอและส่งต่อสิ่งที่จำเป็นลงไป

ด้วยวิธีนี้ คุณจึงไม่มีซิงเกิลตัน UserRepository แต่คุณมี AppContainerที่แชร์ในกิจกรรมทั้งหมดซึ่งมีออบเจ็กต์จากกราฟ และสร้างอินสแตนซ์ของออบเจ็กต์เหล่านั้นที่คลาสอื่นๆ ใช้ได้

หากต้องการใช้ LoginViewModel ในที่อื่นๆ ในแอปพลิเคชัน การมี ตำแหน่งส่วนกลางที่คุณสร้างอินสแตนซ์ของ LoginViewModel ก็เป็นเรื่องสมเหตุสมผล คุณย้ายการสร้าง LoginViewModel ไปยังคอนเทนเนอร์และระบุออบเจ็กต์ใหม่ ของประเภทนั้นด้วย Factory ได้ โค้ดสำหรับ LoginViewModelFactory มีลักษณะดังนี้

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory<LoginViewModel> {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

เมื่อใช้ Compose การอัปเดต AppContainer ยังคงแสดง Factory จากนั้น viewModel จะใช้ Factory เพื่อให้ ViewModel มีขอบเขตเป็น ViewModelStoreOwner ที่ใกล้ที่สุด (โดยปกติคือกิจกรรมโฮสต์ หรือหากใช้ Navigation Compose ก็จะเป็นรายการนำทาง)

// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
    // ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)
    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val appContainer = (application as MyApplication).appContainer
        setContent {
            LoginScreen(appContainer.loginViewModelFactory)
        }
    }
}

@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
    val viewModel: LoginViewModel = viewModel(factory = factory)
    // ...
}

วิธีนี้ดีกว่าวิธีเดิม แต่ก็ยังมีสิ่งที่ควรพิจารณาอยู่บ้าง

  1. คุณต้องจัดการ AppContainer ด้วยตนเองโดยสร้างอินสแตนซ์สำหรับ การขึ้นต่อกันทั้งหมดด้วยตนเอง

  2. ยังมีโค้ด Boilerplate จำนวนมาก คุณต้องสร้าง Factory หรือ พารามิเตอร์ด้วยตนเอง ทั้งนี้ขึ้นอยู่กับว่าต้องการนำออบเจ็กต์กลับมาใช้ซ้ำหรือไม่

การจัดการทรัพยากร Dependency ในโฟลว์ของแอปพลิเคชัน

AppContainer จะซับซ้อนขึ้นเมื่อคุณต้องการรวมฟังก์ชันการทำงานเพิ่มเติมใน โปรเจ็กต์ เมื่อแอปมีขนาดใหญ่ขึ้นและคุณเริ่มนำเสนอโฟลว์ฟีเจอร์ที่แตกต่างกัน ปัญหาที่เกิดขึ้นก็จะยิ่งมากขึ้น

  1. เมื่อมีโฟลว์ที่แตกต่างกัน คุณอาจต้องการให้ออบเจ็กต์อยู่ในขอบเขตของโฟลว์นั้นๆ เท่านั้น เช่น เมื่อสร้าง LoginUserData (ซึ่งอาจประกอบด้วยชื่อผู้ใช้และรหัสผ่านที่ใช้เฉพาะในขั้นตอนการเข้าสู่ระบบ) คุณไม่ต้องการเก็บข้อมูลจากขั้นตอนการเข้าสู่ระบบแบบเดิมจากผู้ใช้รายอื่น คุณต้องการ อินสแตนซ์ใหม่สำหรับทุกโฟลว์ใหม่ คุณทำได้โดยสร้างออบเจ็กต์ FlowContainer ภายใน AppContainer ดังที่แสดงในตัวอย่างโค้ดถัดไป

  2. การเพิ่มประสิทธิภาพกราฟแอปพลิเคชันและคอนเทนเนอร์โฟลว์ก็อาจเป็นเรื่องยากเช่นกัน คุณต้องอย่าลืมลบอินสแตนซ์ที่ไม่ต้องการออก ทั้งนี้ขึ้นอยู่กับ ขั้นตอนที่คุณกำลังดำเนินการอยู่

มาเพิ่ม LoginContainer ในโค้ดตัวอย่างกัน คุณต้องการสร้างอินสแตนซ์หลายรายการของ LoginContainer ในแอป ดังนั้นแทนที่จะสร้างเป็น Singleton ให้สร้างเป็นคลาสที่มีทรัพยากร Dependency ที่โฟลว์การเข้าสู่ระบบต้องการจาก AppContainer

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

ใน Compose อายุการใช้งานของคอนเทนเนอร์โฟลว์จะเชื่อมโยงกับการจัดองค์ประกอบแทนที่จะเป็นโฮสต์ Activity คุณไม่จำเป็นต้องเปลี่ยนแปลง AppContainer.loginContainer ที่แชร์เนื่องจาก Composable จะรับทรัพยากร Dependency เป็นพารามิเตอร์หรืออ่านจาก ViewModel ที่ยกขึ้น โดยมี 2 ตัวเลือกดังนี้

  1. กราฟที่ซ้อนกันของ Navigation Compose (แนะนำสำหรับโฟลว์แบบหลายหน้าจอ) วางหน้าจอทั้งหมดในโฟลว์การเข้าสู่ระบบภายใต้กราฟการนำทางที่ซ้อนกัน และกำหนดขอบเขตของ คอนเทนเนอร์ไปยัง NavBackStackEntry ของกราฟนั้น ระบบจะสร้างคอนเทนเนอร์เมื่อ ผู้ใช้เข้าสู่โฟลว์ และล้างคอนเทนเนอร์เมื่อมีการป๊อปรายการใน Back Stack โดยไม่ต้องเรียกวงจรด้วยตนเอง ดูข้อมูลเพิ่มเติมได้ที่ออกแบบ กราฟการนำทาง
  2. remember ที่รูทของหน้าจอ (สำหรับโฟลว์แบบหน้าจอเดียวหรือเมื่อคุณไม่ได้ ใช้ Navigation Compose) สร้างคอนเทนเนอร์ภายใน remember เพื่อให้ระบบสร้างคอนเทนเนอร์ 1 ครั้งต่อรายการที่ป้อนลงในการจัดองค์ประกอบ และรวบรวมขยะเมื่อ Composable ออกจากองค์ประกอบ
@Composable
fun LoginFlow(appContainer: AppContainer) {
    val loginContainer = remember(appContainer) {
        LoginContainer(appContainer.userRepository)
    }
    val viewModel: LoginViewModel = viewModel(
        factory = loginContainer.loginViewModelFactory
    )
    // Render the login flow using loginContainer.loginData and viewModel.
}

บทสรุป

การฉีดทรัพยากร Dependency เป็นเทคนิคที่ดีในการสร้างแอป Android ที่รองรับการปรับขนาดและทดสอบได้ ใช้คอนเทนเนอร์เป็นวิธีแชร์อินสแตนซ์ของคลาสในส่วนต่างๆ ของแอป และเป็นที่ตั้งส่วนกลางในการสร้างอินสแตนซ์ของคลาสโดยใช้ Factory

เมื่อแอปพลิเคชันมีขนาดใหญ่ขึ้น คุณจะเริ่มเห็นว่าคุณเขียนโค้ด Boilerplate (เช่น Factory) จำนวนมาก ซึ่งอาจเกิดข้อผิดพลาดได้ นอกจากนี้ คุณยังต้อง จัดการขอบเขตและวงจรของคอนเทนเนอร์ด้วยตนเอง รวมถึงเพิ่มประสิทธิภาพและ ทิ้งคอนเทนเนอร์ที่ไม่จำเป็นอีกต่อไปเพื่อเพิ่มหน่วยความจำ การทำเช่นนี้อย่างไม่ถูกต้องอาจทำให้เกิดข้อบกพร่องเล็กๆ น้อยๆ และหน่วยความจำรั่วในแอป

ในส่วน Dagger คุณจะได้เรียนรู้วิธีใช้ Dagger เพื่อทำให้กระบวนการนี้เป็นอัตโนมัติ และสร้างโค้ดเดียวกันกับที่คุณเขียนด้วยตนเอง

แหล่งข้อมูลเพิ่มเติม

ดูเนื้อหา