DEV Community

Cover image for Modernized Android architecture - Hilt
Eddie Eddie
Eddie Eddie

Posted on • Edited on

Modernized Android architecture - Hilt

Cover Photo by Roman Koval from Pexels

แรกเริ่มเดิมทีว่าจะลองเขียน Blog เกี่ยวกับ JetPack Compose อย่างเดียว แต่ทำไปทำมาตอนลองทำ Sample Project แล้วมันดันมีจุดที่น่าสนใจหลายอย่างเลยคิดว่าถ้าเราเขียนเรื่อง JetPack Compose อย่างเดียวคงไม่จบ เลยอยากจะเขียนบทความนี้แยกเป็นหลายๆส่วน โดย Follow ตามตัวอย่าง code แบ่งตามเรื่องที่น่าสนใจดังนี้

  • Hilt
  • JetPack Compose (UI Component) & Unidirectional Data Flow
  • จัดการ ใน JetPack Compose ด้วย LiveData และ State
  • จัดการ ใน JetPack Compose ด้วย State/Share Flow

Hilt

Hilt คือ Library ที่ Google เพิ่งจะออกมาสดๆร้อนๆ โดยถูกสร้าง on-top ด้วย Dagger2 อีกที ใช้สำหรับการทำ Dependency Injection ใน Android ให้ง่ายขึ้น เรียกว่าเป็น Simplified version ของ Dagger Android ก็ว่าได้

ทำไมต้องทำ Dependency Injection

เพื่อให้เข้าใจภาพแบบง่ายขึ้น ยกตัวอย่าง สมมติเรามี class ที่ต้องทำหน้าที่ส่งข้อความโดยแรกเริ่มเดิมที่ ข้อความที่ส่งไปมันก็เป็น email ธรรมดา โดย function จะ return True หรือ False มาหากส่งข้อความสำเร็จหรือไม่สำเร็จ

class EmailSender {

fun sendEmail(message: String): Boolean {
 // TO-DO email sending implementation is processed here
   }
}
Enter fullscreen mode Exit fullscreen mode

โดยเจ้า Class นี้ก็จะถูกเรียกใช้งานในอีก Layer นึง

class MessageProcessor {

private val emailSender = EmailSender()

fun sendProcessedMessage() {
 val message = // doing many things to get message
 emailSender.sendEmail(message)
}
}
Enter fullscreen mode Exit fullscreen mode
class Main {

fun main() {
val processor = MessageProcessor()
processor.sendProcessedMessage()
}
}
Enter fullscreen mode Exit fullscreen mode

ถ้าดูจากการใช้งานแล้ว ในเคสนี้ก็ไม่น่ามีอะไร เจ้า MessageProcessor ก็ไป process ข้อความมาเพื่อจะส่ง message จากนั้นก็ส่งผ่าน EmailSender ก็จบ ดูแล้วก็ไม่น่าจะมีปัญหาตรงไหน 🤔

⚠️ ประเด็นอยู่ตรงที่ว่า ถ้าเราสังเกตุดีๆ เราจะเห็นว่า MessageProcessor นั้นมัน depends กับตัว EmailSender อยู่ ถ้าเราสร้าง class MessageProcessor มาเมื่อไหร่ ตัว EmailSender ก็จะถูกสร้างมาด้วยอัตโนมัติ ให้เราลองนึกถึงการเขียน Test เจ้าตัว MessageProcessor คงเป็นเรื่องยากพอสมควร
สมมติว่าเราสามารถเขียน Test มันได้ แล้ววันนึง Requirement มันเปลี่ยนแทนที่จะต้องส่ง email กลายเป็นว่าต้องส่ง sms แทนล่ะ เท่ากับว่า Test ที่เราเขียนไปอาจจะต้องแก้พอสมควรเลย แบบนี้ไม่ดีแน่ สิ่งที่เราควรทำคือ ลด dependency ระหว่าง class แต่ละ class ลงซะ เลยเป็นที่มาของการทำ Dependency Injection
ในเคสนี้จะยกตัวอย่างการทำ Constructor Injection เนื่องจากว่าเป็นวิธีที่ง่ายต่อความเข้าใจรวมถึงง่ายต่อการเทสกว่า มากๆ

สร้าง interface MessageSender

interface MessageSender {
 fun send(message: String): Boolean
}
Enter fullscreen mode Exit fullscreen mode

ให้ class EmailSender extends MessageSender

class EmailSender: MessageSender {
 override fun send(message: String): Boolean {
// TO-DO email sending implementation is processed here
 }
}
Enter fullscreen mode Exit fullscreen mode

ให้ class SmsSender extends MessageSender

class SmsSender: MessageSender {
 override fun send(message: String): Boolean {
// TO-DO sms sending implementation is processed here
 }
}
Enter fullscreen mode Exit fullscreen mode

inject MessageSender ไปที่ constructor ของ MessageProcessor

class MessageProcessor internal constructor(
private val msgSender: MessageSender
) {

fun sendProcessedMessage() {
 val message = // doing many things to get message
 msgSender.send(message)
}
}
Enter fullscreen mode Exit fullscreen mode
class Main {

fun main() {
val processor = MessageProcessor(SmsSender())
processor.sendProcessedMessage()
 }
}
Enter fullscreen mode Exit fullscreen mode

Unit Test ของ MessageProcessor

class MessageProcessorTest {

private val msgSender: MessageSender = mock()
private val processor = MessageProcessor(msgSender)

@Test
fun `test sendProcessedMessage(), msgSender send() is called`() {
  processor.sendProcessedMessage()
  Mockito.verify(msgSender.send(anyString()))
}
}
Enter fullscreen mode Exit fullscreen mode

จริงๆ หลักการของ Dependency Injection มีง่ายๆแค่นี้ Dagger2, DaggerAndroid และ Hilt แค่ช่วยให้เราสามารถทำงานได้ง่ายขึ้นบน Android เท่านั้นเอง

Setup Hilt

จริงๆ วิธีการ Setup นั้นไม่ยากเลย โดย Google ได้อธิบายเป็น Step เอาไว้ให้เราแล้ว สามารถเข้าไปทำตามได้ที่นี่ ทั้งนี้ทั้งนั้นเผื่อคนที่ไม่อยาก Switch ไปอีกหน้า ผมจะเขียนวิธีการตาม Google เอาไว้ในนี้เลยละกัน

  • ไปที่ file build.gradle ที่ Root ของ Project เราแล้วใส่
buildscript {
    ...
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}
Enter fullscreen mode Exit fullscreen mode
  • ไปที่ file build.gradle ของ module ที่ต้องการใช้ Hilt ในเคสนี้คือ app/build.gradle
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

dependencies {
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}
Enter fullscreen mode Exit fullscreen mode

Hilt ใช้ feature ของ Java8 จึงต้องประกาศด้วย

android {
  ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}
Enter fullscreen mode Exit fullscreen mode

Sync Project ให้เรียบร้อย หลังจากนั้นทำการสร้าง Application class ของเราขึ้นมาแล้วใส่ @HiltAndroidApp ให้เรียบร้อย

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class ComposePlaygroundApplication: Application()
Enter fullscreen mode Exit fullscreen mode

ใน Activity ของเราใส่ @AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity()
Enter fullscreen mode Exit fullscreen mode

เท่านี้เราก็พร้อมใช้งาน Hilt แล้ว

เทียบกับ Dagger

ใน Hilt เราเพียงใส่ @HiltAndroidApp ที่ Application Class ในขณะที่
Dagger2 เราอาจจะต้องสร้าง Component class ที่มี fun inject(activity: MainActivity) ข้างใน หรือถ้าใครใช้ Dagger-Android วิธีการอาจจะซับซ้อนกว่านี้พอควร ในขณะที่ Hilt เพียงแค่เราประกาศ annotation ก็เพียงพอแล้ว

Field Injection

ใน Hilt นั้น การ inject นั้นง่ายมากๆ ยกตัวอย่างให้เห็นภาพ เรามี Activity class แล้วต้องการจะ inject class นึงเข้าไปใน Activity สมมติง่ายๆว่าเป็น EmailSender class ละกัน
Activity ของเรา

@AndroidEntryPoint
class MainActivity : AppCompatActivity() { 
}
Enter fullscreen mode Exit fullscreen mode

class สำหรับส่ง email

class EmailSender {
  fun send(msg: String) {
   println("$msg is sent")
  }
}
Enter fullscreen mode Exit fullscreen mode

ถ้าเราต้องการ inject EmailSender เข้าไปใน Activity เราสามารถประกาศ @Inject ที่ Constructor ได้เลยตามนี้

class EmailSender 
+ @Inject constructor() {
  fun send(msg: String) {
   println("$msg is sent")
  }
}
Enter fullscreen mode Exit fullscreen mode

ภายใน Activity เราสามารถประกาศ

@AndroidEntryPoint
class MainActivity : AppCompatActivity() { 
+ @Inject
+ laterinit var emailSender: EmailSender
}
Enter fullscreen mode Exit fullscreen mode

เพียงเท่านี้ EmailSender ก็พร้อมใช้งานใน MainActivity ของเรา

Constructor Injection

จากตัวอย่างข้างบน สมมติเราต้องการจะ format message ก่อนจะส่งโดยเรียกใช้class MessageFormatter โดยเราต้องการ Inject MessageFormatter เข้าไปใน EmailSender ผ่านทาง Constructor เราก็สามารถทำได้ยกตัวอย่าง

class สำหรับ format ข้อความ

class MessageFormatter {
 fun formatMessages(msg: String): String {
  return "formatted $msg"
 }
}
Enter fullscreen mode Exit fullscreen mode

เพิ่ม @Inject constructor() ไปที่ MessageFormatter

class MessageFormatter 
+ @Inject constructor() {
 fun format(msg: String): String {
  return "formatted $msg"
 }
}
Enter fullscreen mode Exit fullscreen mode
class EmailSender 
@Inject constructor(
+ private val formatter: MessageFormatter
) {
  fun send(msg: String) {
   println("${formatter.format(msg)}is sent")
  }
}
Enter fullscreen mode Exit fullscreen mode

ปัญหา Injection กับ Interface

จากตัวอย่างข้างบนเราทำการ inject class ตรงๆ เข้าไปใน constructor ซึ่งสามารถทำได้ แต่ในหลายๆครั้ง เราต้องการให้ code ของเรามีความ abstract มากขึ้น เพื่อง่ายต่อการ Test วิธีการส่วนมากที่เราทำกันนั้นคือการสร้าง interface มาครอบ class ของเราอีกที ซึ่งใน Hilt ไม่สามารถทำได้ตรงๆตามวิธีการข้างบน
ยกตัวอย่าง:
เรามี Class ที่ชื่อ EmailSender สำหรับส่ง email อยู่แล้วโดยเราต้องการที่จะ Format ข้อความเราก่อนจะส่ง โดยในที่นี้เราจะสร้าง Interface มาเพื่อทำการ format ข้อความชื่อ MessageFormatter

สร้าง Interface ชื่อ MessageFormatter

interface MessageFormatter {
fun format(msg: String): String
}
Enter fullscreen mode Exit fullscreen mode

สร้าง class ชื่อ MessageFormatterV1Impl ที่สืบทอดมาจาก MessageFormatter

class MessageFormatterV1Impl 
@Inject constructor(): MessageFormatter {
 override fun format(msg: String): String {
  return "formatted $msg"
 }
}
Enter fullscreen mode Exit fullscreen mode

สร้าง class ชื่อ MessageFormatterV2Impl ที่สืบทอดมาจาก MessageFormatter

class MessageFormatterV2Impl 
@Inject constructor(): MessageFormatter {
 override fun format(msg: String): String {
  return "$msg is formatted"
 }
}
Enter fullscreen mode Exit fullscreen mode

เรามี 2 classes ที่ extend มาจาก MessageFormatter ชื่อ MessageFormatterV1Impl และ MessageFormatterV2Impl โดยทั้งสอง class ทำหน้าที่ format ข้อความเหมือนกัน แต่วิธีการ format ข้อความต่างกัน

inject MessageFormatter ที่เป็น Inteface เข้าไปใน constructor ของ EmailSender

class EmailSender 
@Inject constructor(
+ private val formatter: MessageFormatter
) {
  fun send(msg: String) {
   println("${formatter.format(msg)}is sent")
  }
}
Enter fullscreen mode Exit fullscreen mode

Error หลังจาก Build:

error: [Dagger/MissingBinding] MessageFormatter cannot be provided without an @Provides-annotated method.
  public abstract static class ApplicationC implements ComposePlaygroundApplication_GeneratedInjector,
                         ^
Enter fullscreen mode Exit fullscreen mode

จริงๆ Error นั้นเรียกว่า make sense มากๆ ตรงที่ code ของเราจะรู้ได้ยังไงว่าจะเอา instant ไหน inject เข้าไปใน Constructor กันล่ะในเมื่อมี 2 classes ที่ implement interface เดียวกัน ซึ่งวิธีการแก้ปัญหานั้นอาจจะต้องใช้ตัวช่วยสักหน่อย นั่นก็คือการสร้าง @Module

หมายเหตุ 💡 ข้อดีของการทำ Constructor injection คือเราไม่จำเป็นต้องทำอะไรเพิ่มเติมเลยหากเราต้องการจะเขียน UnitTest ไม่ใช่แค่สำหรับ Hilt แต่ Dagger ด้วยก็เหมือนกัน โดยเฉพาะอย่างยิ่งการทำ Constructor injection ด้วย Interface นั้นทำให้การ Mock เป็นเรื่องง่ายมากๆครับ

Component

ก่อนจะเริ่มสร้าง Module นั้น สิ่งที่เราต้องทำความเข้าใจก่อนคือ Component และ Component Scope ตัว Hilt เองนั้นจะมีการ pre-create Hilt Components มาให้เราใช้งานแล้วโดยที่เราไม่จำเป็นต้องไปสร้าง Component เองเหมือนที่เราทำใน Dagger โดยสามารถแบ่งตามนี้

Alt Text
ถึงตรงนี้หลายคนอาจจะงงว่า Component นั้นคืออะไร ถ้าพูดให้เข้าใจง่ายคือ เปรียบเสมือน Layer ที่เราอยากจะให้ instance ที่เราสร้างเข้าไปอยู่ ยกตัวอย่าง เราอยากจะให้ EmailSender class ของเราใช้ได้ Activity เราก็เพียงแค่ต้องมั่นใจว่า มันถูก "ใส่" เข้าไปใน (@InstallIn) ActivityComponent

แล้วเมื่อไหร่ที่ instance เราถูกสร้างและถูกทำลายล่ะ?
Alt Text
instance ที่ถูก bound เข้าไปจะถูกสร้างและทำลายตามแต่ละ Component ที่ตัวมันเองโดน installed ลงไป

Scope

โดยปกติแล้ว instance ที่ถูกสร้างขึ้นมาจะไม่มีการกำหนด scope ของการใช้งาน (unscoped) นั่นหมายความว่า ทุกๆครั้งที่มีการเรียกใช้ class นั้น instance นั้นจะถูกสร้างขึ้นมาใหม่ ยกตัวอย่าง:
เรามี class EmailSender

class EmailSender @Inject constructor() {
    fun send(msg: String){
        println(msg)
    }
}
Enter fullscreen mode Exit fullscreen mode

ทำการ inject EmailSender ในอีก class นึงผ่านทาง Constructor ชื่อว่า MessageHandler แล้วลอง Print ค่าออกมาดู

class MessageHandler @Inject constructor(
    private val sender: EmailSender
) {
    init {
        println("emailSender from ${this::class.java} $sender" )
    }
}
Enter fullscreen mode Exit fullscreen mode

ทำการ inject ทั้ง EmailSender และ MessageHandler ใน Activity

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var emailSender: EmailSender

    @Inject
    lateinit var messageHandler: MessageHandler

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        println("emailSender from ${this::class.java} $emailSender" )
    }
}
Enter fullscreen mode Exit fullscreen mode
  • เรา Inject MessageHandler เพื่อให้ Hilt สร้าง instance ของ MessageHandler เพื่อดูค่า EmailSender ที่เรา print ใน init ของ MessageHandler
  • เรา Inject EmailSender เพื่อให้ Hilt สร้าง instance ของ EmailSender เพื่อดูค่า EmailSender ที่เรา print ใน onCreate ของ Activity

ค่าที่ได้:

2021-01-04 16:13:03.060 7374-7374/? I/System.out: emailSender from class MessageHandler EmailSender@cacc883
2021-01-04 16:13:03.102 7374-7374/? I/System.out: emailSender from class MainActivity EmailSender@72ec68a
Enter fullscreen mode Exit fullscreen mode

จะเห็นว่า Instance นั้นเป็นคนละตัวกัน

ลองกำหนด Scope ลงไปใน EmailSender ในเคสนี้จะลองกำหนด @ActivityScoped

+ @ActivityScoped
class EmailSender @Inject constructor() {
    fun send(msg: String){
        println(msg)
    }
}
Enter fullscreen mode Exit fullscreen mode

ค่าที่ได้:

2021-01-04 16:16:06.611 7714-7714/? I/System.out: emailSender from MessageHandler EmailSender@b6f3903
2021-01-04 16:16:06.634 7714-7714/? I/System.out: emailSender from class MainActivity EmailSender@b6f3903
Enter fullscreen mode Exit fullscreen mode

Scope เปรียบเสมือนขอบเขตของการใช้งานว่าเราอยากจะให้ Instance นั้นถูกใช้ได้ในขอบเขตไหนลงไปเป็นลำดับขั้น โดย Hilt เอง ก็ได้ทำการ Predefined เอาไว้แล้วตามนี้
Alt Text

ยกตัวอย่าง:
ตัวอย่างที่ 1 ต้องการให้ class EmailSender ใช้งานได้ใน Activity และให้ class EmailSender และมีขอบเขตการใช้งานภายใน Activity ลงไป
เทียบตาราง:
Alt Text

  • ต้องให้ class EmailSender ถูก Inject ใน Activity
  • ต้องให้ class EmailSender มี Scope เป็น ActivityScope

ตัวอย่าง Code:
ประกาศ @ActivityScoped

@ActivityScoped
class EmailSender @Inject constructor() {
    fun send(msg: String) {
        println("msg is sent")
    }
}
Enter fullscreen mode Exit fullscreen mode

ใช้งาน EmailSender ใน Activity

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var emailSender: EmailSender
}
Enter fullscreen mode Exit fullscreen mode

ตัวอย่างที่ 2 ต้องการให้ class EmailSender ใช้งานได้ใน Fragment และให้ class EmailSender และมีขอบเขตการใช้งานภายใน Fragment ลงไป
เทียบตาราง:
Alt Text

  • ต้องให้ class EmailSender ถูก Inject ใน Fragment
  • ต้องให้ class EmailSender มี Scope เป็น FragmentScope

ตัวอย่าง Code:
ประกาศ @FragmentScoped

@FragmentScoped
class EmailSender @Inject constructor() {
    fun send(msg: String) {
        println("msg is sent")
    }
}
Enter fullscreen mode Exit fullscreen mode

ใช้งาน EmailSender ใน Fragment

@AndroidEntryPoint
class MainFragment : Fragment() {

    @Inject
    lateinit var emailSender: EmailSender
}
Enter fullscreen mode Exit fullscreen mode

ตัวอย่างที่ 3 ต้องการให้ class EmailSender ใช้งานได้ใน Fragment แต่ยังให้ class EmailSender และมีขอบเขตการใช้งานภายใน Activity ลงไป
เทียบตาราง:
Alt Text

  • ต้องให้ class EmailSender ถูก Inject ใน Fragment
  • ต้องให้ class EmailSender มี Scope เป็น ActivityScope

ตัวอย่าง Code:
ประกาศ @ActivityScoped

@ActivityScoped
class EmailSender @Inject constructor() {
    fun send(msg: String) {
        println("msg is sent")
    }
}
Enter fullscreen mode Exit fullscreen mode

ใช้งาน EmailSender ใน Fragment

@AndroidEntryPoint
class MyFragment : Fragment() {

    @Inject
    lateinit var emailSender: EmailSender
}
Enter fullscreen mode Exit fullscreen mode

เนื่องจากว่า ขอบเขตการใช้งานของ EmailSender อยู่ใน Activity แต่การใช้งานเรานำไปใช้ใน Fragment ซึ่งอยู่ภายใต้ Activity อีกที ในกรณีนี้นั้นสามารถทำได้

ข้อควรระวัง Scope กับ Component นั้นมีความสัมพันธ์กันเป็นลำดับชั้น ถ้าหากเราประกาศ Scope กับ Component ไม่ถูกต้องเราอาจจะเจอ Error ได้ยกตัวอย่าง:

  • ให้ class EmailSender ถูกใช้งานใน Activity
  • แต่ให้ class EmailSender มี Scope เป็น FragmentScope เพราะต้องการให้ Class EmailSender มีขอบเขตการใช้งานแค่ภายใน Fragment

Compile Error ที่เกิดขึ้น:

error: [Dagger/IncompatiblyScopedBindings] ComposePlaygroundApplication_HiltComponents.ActivityC scoped with @dagger.hilt.android.scopes.ActivityScoped may not reference bindings with different scopes:
  public abstract static class ApplicationC implements ...,
Enter fullscreen mode Exit fullscreen mode

แก้ไขปัญหา Constructor Injection กับ Interface

จากที่บอกไปด้านบนคือ วิธีการแก้ไขนั้นทำได้ไม่ยากโดยการใช้ Module ซึ่งวิธีการใช้งาน Module ของ Hilt นั้นมี 2 วิธีหลักๆ คือการ @Binds และ @Provides

Binds module

วิธีการ bind instance ด้วยการใช้ @Binds ค่อนข้างเรียบง่ายและเหมาะกับการแก้ปัญหา constructor injection ด้วย interface ในกรณีที่เรามี class ที่เราต้องการ bind instance เข้าไป ยกตัวอย่าง:

เรามี MessageFormatter ที่เป็น interface และมี class MessageFormatterImpl ที่เป็น class

interface MessageFormatter {
fun format(msg: String): String
}

class MessageFormatterImpl: MessageFormatter {
fun format(msg: String): String {
 return "Formatted $msg"
}
}
Enter fullscreen mode Exit fullscreen mode

ต้องการ inject เข้าไปที่ constructor ของ EmailSender class

class EmailSender
@Inject constructor(
private val formatter: MessageFormatter
)
Enter fullscreen mode Exit fullscreen mode

เราต้องการ Inject EmailSender เข้าไปที่ Activity โดยที่ Compile ผ่าน

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var emailSender: EmailSender
}
Enter fullscreen mode Exit fullscreen mode

เริ่มทำการสร้าง Module ในที่นี้จะให้ชื่อว่า MessageSendingModule

@Module
abstract class MessageSendingModule {

}
Enter fullscreen mode Exit fullscreen mode

ทำการประกาศว่าจะทำ Module ไปใช้ที่ไหน ในที่นี้ เราต้องการใช้ EmailSender ที่ Activity เราจึงต้องประกาศ @InstallIn(ActivityComponent::class)

+ @InstallIn(ActivityComponent::class)
@Module
abstract class MessageSendingModule {

}
Enter fullscreen mode Exit fullscreen mode

ทำการ Bind Instance ของ MessageFormatter

@InstallIn(ActivityComponent::class)
@Module
abstract class MessageSendingModule {

+ @Binds
+ fun bindMessageFormatter(impl: MessageFormatterImpl): MessageFormatter
}
Enter fullscreen mode Exit fullscreen mode

จุดสังเกตุ ⚠️⚠️ เราใส่ MessageFormatterImpl ที่เป็น class ลงไปเป็น parameter ของ bindMessageFormatter และให้ function return MessageFormatter ที่เป็น Interface
ทำการประกาศ Scope ของ MessageFormatter เคสนี้ เราต้องการให้มันมีขอบเขตการใช้งานภายใน Activity ลงไป ดังนั้นต้องใช้ @ActivityScope

@InstallIn(ActivityComponent::class)
@Module
abstract class MessageSendingModule {

@Binds
+ @ActivityScope
fun bindMessageFormatter(impl: MessageFormatterImpl): MessageFormatter
}
Enter fullscreen mode Exit fullscreen mode

เพียงเท่านี้เราก็สามารถ compile ได้ผ่านฉลุย 🚀🚀🚀

Provide module

Provide module มักใช้ในกรณีที่เราต้องการสร้าง Instance ของ Class ด้วยเราเอง ยกตัวอย่าง หากเราต้องการ Inject Gson instance การใช้ @Binds ตามตัวอย่างข้างบนไม่สามารถทำได้ วิธีการใช้ Provide module จึงเหมาะสมมากกว่า
ตัวอย่าง:

  • ต้องการให้ Gson เข้าถูกใช้งานที่ใดก็ได้ใน Application
  • ต้องการให้ Gson มีขอบเขตการใช้งานภายใน Application

สร้าง module ในที่นี้จะให้ชื่อว่า GsonModule

@Module
class GsonModule
Enter fullscreen mode Exit fullscreen mode

จุดสังเกตุ ⚠️ class GsonModule ไม่ใช่ abstract class เหมือนในกรณี @Binds

ประกาศ @InstallIn(ApplicationComponent::class) เนื่องจากว่าเราต้องการให้ Gson ถูกใช้งานที่ Application level ในกรณีนี้ instance ของ Gson จะถูกนำไปใช้งานได้ทุกที่ของ App (หาก inject Gson ด้วย Hilt)

+ @InstallIn(ApplicationComponent::class)
@Module
class GsonModule
Enter fullscreen mode Exit fullscreen mode

สร้าง Function ที่ทำหน้าที่ Provide Gson

@InstallIn(ApplicationComponent::class)
@Module
class GsonModule {
+ fun provideGson(): Gson = GsonBuilder().create()
}
Enter fullscreen mode Exit fullscreen mode

ประกาศ @Singleton เพื่อให้ Gson มี มีขอบเขตการใช้งานภายที่ Application ลงไป

จากนั้นประกาศ @Provides

@InstallIn(ApplicationComponent::class)
@Module
class GsonModule {
+ @Singleton
+ @Provides
fun provideGson(): Gson = GsonBuilder().create()
}
Enter fullscreen mode Exit fullscreen mode

เพียงเท่านี้เราก็สามารถ Inject Gson ไปที่ไหนก็ได้ใน App เพราะ Gson จะมีขอบเขตการใช้งานที่ Application layer ลงไป

หมายเหตุ 💡 วิธีการสร้าง Module จำเป็นต้องให้ component ที่เราจะระบุใน @InstallIn ตรงกันกับ Scope รวมถึงการนำไปใช้งาน (inject) ด้วย ตามตาราง:
Alt Text
เช่น:
ต้องการ EmailSender class ใช้งานใน Fragment ลงไป (Fragment, View, Service) เราสามารถระบุ

  • @InstallIn ApplicationComonent หรือ AcivityComponent หรือ FragmentComponent ได้
  • ระบุ Scope เป็น @Singleton หรือ @ActivityScope หรือ @FragmentScope โดยต้องตรงกับ Component ที่ InstallIn
  • สามารถ inject EmailSender ได้ใน Fragment, View และ Service

Monolithic components

อีกเรื่องที่สำคัญสำหรับ Hilt คือ การที่ Hilt นั้นมีโครงสร้าง แบบ Monolithic components นั่นหมายความว่า ของที่อยู่ใน Components จะถูกใช้ได้ในทุกที่ใน Layer เดียวกัน
ยกตัวอย่าง:

  • Application เรามี 3 Activities ชื่อ MainActivity, SecondActivity, ThirdActivity โดยให้ทั้งสาม Activities สามารถใช้งาน Hilt ได้โดยการระบุ @AndroidEntryPoint บน Activities ทั้ง 3 ตัว
@AndroidEntryPoint
class ThirdActivity  : AppCompatActivity()

@AndroidEntryPoint
class SecondActivity  : AppCompatActivity()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() 
Enter fullscreen mode Exit fullscreen mode
  • ให้ EmailSender มี Scope เป็น ActivityScoped
@ActivityScoped
class EmailSender @Inject constructor() {
    fun send(msg: String){
        println(msg)
    }
}
Enter fullscreen mode Exit fullscreen mode

inject EmailSender ในทั้ง 3 Activities

@AndroidEntryPoint
class ThirdActivity  : AppCompatActivity() {
    @Inject
    lateinit var emailSender: EmailSender
}

@AndroidEntryPoint
class SecondActivity  : AppCompatActivity() {
    @Inject
    lateinit var emailSender: EmailSender
}

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var emailSender: EmailSender
}
Enter fullscreen mode Exit fullscreen mode

ทั้งนี้ทั้งนั้น ถึงแม้ว่าเราจะ inject มันได้ ทั้ง 3 ที่ แต่ instance ก็ยังคงเป็นคนละตัวกัน

ทดสอบด้วยการ Print ออกมาใน onCreate ของแต่ละ Activity:

println("emailSender from ${this::class.java} $emailSender" )
Enter fullscreen mode Exit fullscreen mode

ผลที่ได้

2021-01-04 14:51:01.868 5198-5198/? I/System.out: emailSender from class MainActivity EmailSender@245bf98
2021-01-04 14:51:02.346 5198-5198/? I/System.out: emailSender from class SecondActivity EmailSender@50720c3
2021-01-04 14:51:02.675 5198-5198/? I/System.out: emailSender from class ThirdActivity EmailSender@2026848
Enter fullscreen mode Exit fullscreen mode

ลองเปลี่ยนให้ Scope เป็น @Singleton แทน

- @ActivityScoped
+ @Singleton
class EmailSender @Inject constructor() {
    fun send(msg: String){
        println(msg)
    }
}
Enter fullscreen mode Exit fullscreen mode

ผลที่ได้

2021-01-04 14:51:01.868 5198-5198/? I/System.out: emailSender from class MainActivity EmailSender@245bf98
2021-01-04 14:51:02.346 5198-5198/? I/System.out: emailSender from class SecondActivity EmailSender@245bf98
2021-01-04 14:51:02.675 5198-5198/? I/System.out: emailSender from class ThirdActivity EmailSender@245bf98
Enter fullscreen mode Exit fullscreen mode

ทีนี้เรามาลองอะไรเล่นๆดูบ้าง จากตัวอย่าง code ด้านบน ให้ scope เป็น @ActivityRetainedScoped ดู

@ActivityRetainedScoped
class EmailSender @Inject constructor() {
    fun send(msg: String){
        println(msg)
    }
}
Enter fullscreen mode Exit fullscreen mode

ผลที่ได้

2021-01-04 14:51:01.868 5198-5198/? I/System.out: emailSender from class MainActivity EmailSender@3ade714
2021-01-04 14:51:02.346 5198-5198/? I/System.out: emailSender from class SecondActivity EmailSender@5776b47
2021-01-04 14:51:02.675 5198-5198/? I/System.out: emailSender from class ThirdActivity EmailSender@8b35fe6
Enter fullscreen mode Exit fullscreen mode

จะเห็นว่า Instance นั้นยังตงเป็นคนละตัวกัน เนื่องจากว่า เรามี @AndroidEntryPoint อยู่สามที่นั่นทำให้ Hilt ทำการสร้าง Instance ของ emailSender แยกกัน

ลองใช้ @ActivityRetainedScoped เหมือนเดิม แต่เปลี่ยนให้มี @AndroidEntryPoint เพียงที่เดียวบ้าง

@ActivityRetainedScoped
class EmailSender @Inject constructor() {
    fun send(msg: String){
        println(msg)
    }
}
Enter fullscreen mode Exit fullscreen mode

สร้าง ClassA และ ClassB โดย inject emailSender ไปที่ constructor

class ClassA @Inject constructor(
    private val emailSender: EmailSender
) {
    init {
        println("From $this EmailSender is $emailSender")
    }
}

class ClassB @Inject constructor(
    private val emailSender: EmailSender
) {
    init {
        println("From $this EmailSender is $emailSender")
    }
}
Enter fullscreen mode Exit fullscreen mode

Print ค่าใน Activity:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var emailSender: EmailSender

    @Inject
    lateinit var classA: ClassA

    @Inject
    lateinit var classB: ClassB

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
        println("From $this EmailSender is $emailSender")
    }
}
Enter fullscreen mode Exit fullscreen mode

ผลที่ได้

2021-01-05 10:33:21.296 13089-13089 From ClassA@5d9e5b2 EmailSender is EmailSender@b6f3903
2021-01-05 10:33:21.296 13089-13089 From ClassB@d0c6080 EmailSender is EmailSender@b6f3903
2021-01-05 10:33:21.362 13089-13089 From MainActivity@fa313f1  EmailSender is EmailSender@b6f3903
Enter fullscreen mode Exit fullscreen mode

จบไปคร่าวๆ สำหรับเรื่อง Hiltเบื้องต้น หวังว่าจะนำไปใช้งานกันได้ไม่ยาก ส่วน Part หน้าจะมาพูดถึง JetPack Compose บ้าง ว่ามัน Revolutionize วิธีการเขียน UI ของ Android App เราอย่างไร

References:

Top comments (0)