ถ้าพูดถึง Dependency Injection บนแอนดรอยด์ก็จะนึกถึง Dagger 2 เป็นอย่างแรก เพราะว่าเป็น Dependency Injection Framework ตัวแรกๆที่ออกมาใช้กัน
แต่รู้หรือไม่ว่าจริงๆแล้วยังมีอีกตัวที่น่าสนใจไม่แพ้กัน นั่นก็คือ Koin ที่เข้าใจได้ง่ายกว่า เขียนโค้ดน้อยกว่า และรองรับ Kotlin โดยเฉพาะ จึงเหมาะกับยุคนี้ที่ใครๆก็ใช้ภาษา Kotlin ในการเขียนแอปแอนดรอยด์กันแล้ว
Dependency Injection?
สำหรับผู้ที่หลงเข้ามาอ่านคนใดที่ยังไม่รู้จักกับ Dependency Injection (ต่อไปจะเรียกสั้นๆว่า DI) แนะนำให้ไปอ่านเพิ่มเติมได้ที่ Dagger 2 in Android [Part 1] — Dependency Injection แบบหล่อๆด้วย Dagger 2 ที่เจ้าของบล็อกได้อธิบายไว้ก่อนจะพูดถึง Dagger 2
ถ้าจะให้สรุปแบบคร่าวๆ DI คือวิธีการเขียนโค้ดแบบหนึ่งที่ทำให้คลาสแต่ละตัวเป็นอิสระต่อจากกันและทำหน้าที่ใครหน้าที่มันโดยชัดเจน เพื่อให้สามารถดูแลโค้ดในภายหลังได้ง่าย และสามารถเขียนเทสได้ง่าย
แต่การทำ DI จะต้องเขียนโค้ดยุ่งยากกว่าเดิม เพื่อแก้ปัญหานี้จึงมีการสร้างตัวช่วยขึ้นมาเพื่อให้นักพัฒนาสามารถทำ DI ได้ง่าย ช่วยลดโค้ดที่จะต้องเขียนให้เหลือน้อยลงนั่นเอง
ติดตั้ง Koin ลงในโปรเจค
Koin มีการแบ่งเป็น Library หลายๆชุด เพื่อให้นักพัฒนาหยิบใช้งานเฉพาะตัวที่ต้องการได้ง่าย ซึ่งสำหรับแอนดรอยด์จะมีอยู่ 3 ส่วนด้วยกัน
ตัวหลักของ Koin เพื่อใช้ในแอนดรอยด์
implementation 'org.koin:koin-android:2.1.5'
ส่วนเสริมเพื่อรองรับ Lifecycle Scoping บนแอนดรอยด์
// Android Support Library
implementation 'org.koin:koin-android-scope:2.1.5'
// AndroidX
implementation 'org.koin:koin-androidx-scope:2.1.5'
ส่วนเสริมเพื่อรองรับ ViewModel ของ Android Architecture Components
// Android Support Library
implementation 'org.koin:koin-android-viewmodel:2.1.5'
// AndroidX
implementation 'org.koin:koin-androidx-viewmodel:2.1.5'
บทความนี้จะพูดถึง Koin เวอร์ชัน 2.1.5
ถ้าถามว่าจะต้องใช้ตัวไหนบ้าง? ปกติแล้วจะต้องใช้ 2 ตัวแรก แต่ถ้าโปรเจคของผู้ที่หลงเข้ามาอ่านเขียนเป็น MVVM ที่ใช้ Android Architecture Components ก็ให้เพิ่มตัวสุดท้ายเข้าไปด้วย
เขียนแบบปกติกับใช้ Koin ต่างกันยังไง?
ยกตัวอย่างว่าเจ้าของบล็อกมีคลาสที่เอาไว้ดึงข้อมูลของผู้ใช้ โดยแบ่งหน้าที่ในแต่ละส่วนออกมาเป็นคลาสต่างๆที่ทำงานร่วมกันในลักษณะแบบนี้
เมื่อสร้างคลาสตามรูปข้างบนก็จะได้ออกมาเป็น
// NetworkClient.kt
class NetworkClient() { ... }
// ApiService.kt
class ApiService(private val client: NetworkClient) { ... }
// DatabaseService.kt
class DatabaseService() { ... }
// ProfileRepository.kt
class ProfileRepository(private val api: ApiService, private val db: DatabaseService) { ... }
ถ้าจะเรียกใช้ ProfileRepository แบบยังไม่ใช้ Koin ก็จะออกมาเป็นหน้าตาแบบนี้
val client = NetworkClient()
val api = ApiService(client)
val db = DatabaseService()
val repo = ProfileRepository(api, db)
แต่ถ้าเป็น Koin ก็จะเหลือเพียงแค่นี้
// แบบ Lazy
val repo : ProfileRepository by inject()
// แบบ Direct
val repo: ProfileRepository = get()
สุดยอดไปเลยใช่มั้ยล่ะ!?
โดยการทำให้โค้ดสั้นจนเหลือแบบนี้จะต้องมีการเตรียมโค้ดแบบ Kotlin DSL เพื่อบอก Koin ก่อนว่าแต่ละคลาสจะสัมพันธ์กันยังไง
มาเตรียมโค้ดในโปรเจคให้รองรับ Koin กันเถอะ
ในการบอกให้ Koin รู้ว่าคลาสแต่ละตัวที่เจ้าของบล็อกสร้างขึ้นมานั้นมีความสัมพันธ์อย่างไร จะต้องกำหนดไว้ใน Module ของ Koin ที่มีหน้าตาแบบนี้
val appModule = module {
single { NetworkClient() }
single { ApiService(get()) }
single { DatabaseService() }
single { ProfileRepository(get(), get()) }
}
เรียบง่ายใช่มั้ยล่ะ!? อยากจะให้มีกี่ Module ก็สร้างเป็น Instance ง่ายๆแบบนี้ได้เลย ส่วนตัวอย่างนี้ขอสร้างเป็นชื่อรวมๆว่า appModule
ไปก่อนก็แล้วกัน
วิธีการ Provide ในแบบฉบับ Koin
โดยข้างใน module { ... }
จะมีคำสั่งของ Kotlin DSL ที่ Koin เตรียมไว้ให้ใช้งานดังนี้
- single สำหรับคลาสที่ต้องการให้ Koin ทำเป็น Singleton
- factory สำหรับคลาสที่ต้องการให้ Koin สร้าง Instance ขึ้นมาใหม่ทุกครั้งเมื่อต้องการเรียกใช้งาน
- scope สำหรับคลาสที่จะให้ Koin สร้างขึ้นมาพร้อมๆกับคลาสตัวอื่นๆ
val appModule = module {
single { CommonUtility(...) }
factory { NetworkConfig(...) }
scope(named<ProfileActivity>()) {
scoped<ProfilePresenter> { ProfilePresenter() }
}
}
จากโค้ดข้างบนนี้ เจ้าของบล็อกกำหนดให้ CommonUtility
เป็น Singleton และ NetworkConfig
เป็น Factory ซึ่งมีจุดประสงค์ในการใช้งานที่ต่างกัน
ที่สำคัญคือ ProfilePresenter
จะถูกสร้างขึ้นมาเมื่อมีการสร้าง ProfileActivity
และก็จะถูกทำลายทิ้งไปพร้อมๆกันด้วย ซึ่งเป็นความสามารถของ Scope นั่นเอง
Resolve Dependency ด้วยคำสั่งง่ายๆ
เจ้าของบล็อกชอบที่สุดก็คือตอนที่ใส่คลาสไว้ใน Module แล้ว เมื่อจะต้อง Inject เข้าไปในคลาสอื่นๆด้วย ให้ใช้คำสั่ง get()
ได้เลย เดี๋ยว Koin จะไปเตรียมคลาสดังกล่าวเพื่อ Inject เข้าไปในคลาสที่ต้องการให้เอง
class NetworkClient() { ... }
class ApiService(private val client: NetworkClient) { ... }
val appModule = module {
single { NetworkClient() }
single { ApiService(get()) }
}
โคตรสะดวกเลยยยยยยย
ต้องใช้ Context หรอ? สบายมาก
ในกรณีที่ต้องใช้ Context ในคลาสนั้นๆ ก็สามารถส่ง Context เข้าไปด้วยคำสั่ง androidContext()
ได้เลย
val appModule = module {
single { ErrorCodeMapper(androidContext()) }
}
ส่วน Context ตัวนี้มาจากไหน เดี๋ยวค่อยพูดถึงทีหลังนะ
แล้ว Module ที่สร้างขึ้นมา จะต้องกำหนดไว้ในไหนต่อล่ะ?
การใช้ Koin จะต้องกำหนด Module ไว้ในคลาส Application ดังนั้นเจ้าของบล็อกก็ต้องสร้างขึ้นมาดังนี้
// AppModule.kt
val appModule = module { ... }
// AwesomeApplication.kt
...
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class AwesomeApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MainApplication)
modules(appModule)
}
}
}
อย่าลืมกำหนดคลาสนี้ไว้ใน Android Manifest ด้วยล่ะ เดี๋ยวหาว่าไม่เตือน
โดยจะต้องใส่คำสั่ง startKoin { ... }
ไว้ใน onCreate()
ตามตัวอย่างข้างบน พร้อมกับกำหนด Module ที่ต้องการไว้ในนั้นให้เรียบร้อย
และจะเห็นว่ามีคำสั่ง androidContext(...)
ด้วย ซึ่งเป็นที่มาว่า Koin ไปเอา Context จากไหนมา Inject ให้กับคลาสต่างๆ
มีมากกว่า 1 Module?
ถ้าผู้ที่หลงเข้ามาอ่านแบ่ง Module เป็นหลายๆชุด (โปรเจคใหญ่ๆก็ควรทำแบบนี้นะ) ก็สามารถกำหนดทั้งหมดนั้นไว้ในคำสั่ง modules { ... }
ได้เลย
startKoin {
...
modules(appModule, networkModule, ...)
}
มี Logger ให้ดูด้วยนะ
ถ้าอยากรู้ว่าแต่ละคลาสมีที่มาที่ไปยังไงบ้าง สามารถใช้คำสั่ง androidLogger()
เพื่อให้ Koin แสดง Log ตอนที่ทำการ Inject คลาสแต่ละตัวได้ด้วยล่ะ
startKoin {
...
androidLogger()
}
ทำให้นักพัฒนาสามารถเช็คได้ว่า Koin ทำงานได้ถูกต้องจริงๆหรือป่าว ซึ่งจะช่วยลดเวลาในการไล่โค้ดเมื่อมีปัญหาจากการ Inject ไม่ถูกต้องหรือไม่ตรงกับที่ตั้งใจไว้
I/[Koin]: [init] declare Android Context
I/[Koin]: bind type:'android.content.Context' ~ [type:Single,class:'android.content.Context']
I/[Koin]: bind type:'android.app.Application' ~ [type:Single,class:'android.app.Application']
I/[Koin]: bind type:'com.akexorcist.example.koin.NetworkClient' ~ [type:Single,class:'com.akexorcist.example.koin.NetworkClient']
I/[Koin]: bind type:'com.akexorcist.example.koin.ApiService' ~ [type:Single,class:'com.akexorcist.example.koin.ApiService']
I/[Koin]: bind type:'com.akexorcist.example.koin.DatabaseService' ~ [type:Single,class:'com.akexorcist.example.koin.DatabaseService']
I/[Koin]: bind type:'com.akexorcist.example.koin.ProfileRepository' ~ [type:Single,class:'com.akexorcist.example.koin.ProfileRepository']
I/[Koin]: registered 6 definitions
I/[Koin]: modules loaded in 7.664 ms
พร้อมใช้งานแล้ว เย้!
เมื่อเตรียมคลาสต่างๆไว้ใน Koin เสร็จหมดแล้ว สามารถเรียกใช้งานได้ทันที โดยจะมีวิธีเรียกใช้งานอยู่ 2 วิธีคือ Lazy กับ Direct
- Lazy : ประกาศไว้ใน Global Variable ซึ่งจะต้องใช้คำสั่ง
inject()
เท่านั้น - Direct : ประกาศไว้ในตำแหน่งที่ต้องการเรียกใช้งาน ซึ่งจะต้องใช้คำสั่ง
get()
เท่านั้น
และวิธีการเรียกใช้งานจะถูกแบ่งระหว่าง Single/Factory กับ Scope (Lifecycle Scoping) อีกด้วย
Dependency ที่สร้างด้วย Single หรือ Factory
แบบ Lazy
import org.koin.android.ext.android.inject
class MainActivity : AppCompatActivity() {
private val repo: ProfileRepository by inject()
override fun onCreate(savedInstanceState: Bundle?) {
...
repo.getProfile("Akexorcist")
}
}
แบบ Direct
import org.koin.android.ext.android.get
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val repo: ProfileRepository = get()
repo.getProfile("Akexorcist")
}
}
Dependency ที่สร้างด้วย Scope (Lifecycle Scoping)
แบบ Lazy
import org.koin.android.ext.android.inject
class MainActivity : AppCompatActivity() {
private val presenter: ProfilePresenter by currentScope.inject()
override fun onCreate(savedInstanceState: Bundle?) {
...
presenter.doSomething()
}
}
แบบ Direct
import org.koin.android.ext.android.get
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val presenter: ProfileRepository = currentScope.get()
presenter.doSomething()
}
}
ซึ่งคำสั่ง currentScope
เป็นคำสั่งของ Lifecycle Scoping ที่ทำมาเพื่อ Activity ให้โดยเฉพาะนั่นเอง
ดังนั้นถ้าเป็นคลาสทั่วๆไปที่ไม่ได้เกี่ยวกับ Lifecycle ของ Activity ก็ให้ใช้เป็น Single หรือ Factory ตามปกติ แต่ถ้าคลาสนั้นๆจำเป็นต้องทำงานร่วมกับ Lifecycle ของ Activity จริงๆ (อย่างเช่นคลาส Presenter ของ MVP) ก็ให้ใช้ Scope แทน
ถ้าต้องการส่งค่าบางอย่างเข้าไปใน Constructor ของคลาสนั้นๆด้วยล่ะ?
ส่วนใหญ่มักจะกำหนดค่าทุกอย่างไว้ในตอนทำ DI ทั้งหมดก็จริง แต่บางครั้งก็อาจจะมีกรณีที่ต้องการโยนค่าบางอย่างเข้าไปใน Constructor ผ่านโค้ด
// DatabaseService.kt
class DatabaseService(private val databaseName: String) { ... }
ในกรณีนี้ผู้ที่หลงเข้ามาอ่านสามารถกำหนดใน Module ได้เลยว่าคลาสนั้นๆมี Parameter ที่จะให้ปลายทางโยนเข้ามาในตอนที่เรียกใช้งาน
val appModule = module {
factory{ (databaseName: String) -> DatabaseService(databaseName) }
...
}
เมื่อเรียกใช้งานจากคลาสใดๆ ในตอนที่ Inject ก็ให้กำหนดค่าที่คลาสนั้นๆต้องการลงใน parametersOf(...)
ด้วย
// Lazy
val db: DatabaseService by inject { parametersOf("app_database") }
// Direct
val db: DatabaseService = get { parametersOf("app_database") }
ถ้า Parameter มีมากกว่า 1 ตัว?
การส่ง Parameter เข้ามาใน Factory ไม่มีจำกัดจำนวน Parameter ดังนั้น คลาสนั้นๆต้องการ Parameter กี่ตัวก็สามารถส่งเข้ามาได้หมดเลย
// DatabaseService.kt
class DatabaseService(private val databaseName: String, private val inMemory: Boolean) { ... }
// AppModule.kt
val appModule = module {
...
factory { (databaseName: String, inMemory: Boolean) -> DatabaseService(databaseName, inMemory) }
}
เพราะคำสั่ง parametersOf(...)
รับค่าเป็น Array ขอแค่โยนไปตามลำดับที่ถูกต้องก็พอ อย่าสลับกันหรืออย่าผิด Type ก็พอ ไม่งั้นเดี๋ยวแอปจะพังเอา
// Lazy
private val db: DatabaseService by inject { parametersOf("app_database", true) }
// Direct
val db: DatabaseService = get { parametersOf("app_database", true) }
ดังนั้นในคลาสนั้นๆต้องการค่าแบบไหนก็ตาม Koin ก็รองรับได้หมดแหละ
// NetworkClient.kt
class NetworkClient() { ... }
// ApiService.kt
class ApiService(
private val context: Context,
private val endpoint: String,
private val client: NetworkClient
) { ... }
// AppModule.kt
val appModule = module {
...
single { NetworkClient() }
factory { (endpoint: String) -> ApiService(androidContext(), endpoint, get()) }
}
หรือถ้าอยากจะทำแบบนี้ใน Scope ก็สามารถทำได้เช่นกันนะ
// HomeActivity.kt
class HomeActivity : AppCompatActivity(), HomeContractor.View {
private val presenter: HomeContractor.Presenter by currentScope.inject { parametersOf(this) }
...
}
// HomePresenter.kt
class HomePresenter(private val view: HomeContractor.View) : HomeContractor.Presenter { ... }
// HomeContractor.kt
object HomeContractor {
interface Presenter { ... }
interface View { ... }
}
// AppModule.kt
val appModule = module {
...
scope(named<HomeActivity>()) {
scoped<HomeContractor.Presenter> { (view: HomeContractor.View) -> HomePresenter(view) }
}
}
Koin กับ ViewModel
การใช้ Koin จะยิ่งสะดวกมากขึ้นไปอีกกับนักพัฒนาที่ใช้ MVVM และ Android Architecture Component ในโปรเจค เพราะว่า Koin สามารถทำ Injection ให้กับ ViewModel ได้ง่ายๆ (ถึงกับทำเป็น Library แยกออกมาอีกตัวให้เลย)
// HomeViewModel.kt
class HomeViewModel(private val repo: HomeRepository) : ViewModel() { ... }
// AppModule.kt
val appModule = module {
...
single { HomeRepository() }
viewModel { HomeViewModel(get()) }
}
โดยสามารถใช้คำสั่ง viewModel { ... }
ในตอนสร้าง Module ได้เลย
// HomeActivity.kt
import org.koin.androidx.viewmodel.ext.android.viewModel
class HomeActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModel()
}
หรือจะเป็น ViewModel ใน Fragment ก็ไม่มีปัญหา สามารถเรียกใช้งานด้วยคำสั่งแบบเดียวกับใน Activity ได้เลย
// HomeFragment.kt
import org.koin.androidx.viewmodel.ext.android.viewModel
class HomeFragment : Fragment() {
private val viewModel: HomeViewModel by viewModel()
...
}
ถ้าอยากจะให้ Fragment ใช้เป็น Shared ViewModel (Instance ตัวเดียวกับที่ Activity ใช้อยู่) ก็มีคำสั่งง่ายๆอย่าง sharedViewModel() ให้ใช้งานเลยนะ
// HomeFragment.kt
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class HomeFragment : Fragment() {
private val viewModel: HomeViewModel by sharedViewModel()
...
}
ชีวิตสะดวกสบายขึ้นเยอะ ไม่ต้องมานั่ง Binding เองให้เสียเวลาอีกต่อไป
ความแตกต่างระหว่าง Koin กับ Dagger 2
ถ้าพูดถึง Koin ก็คงไม่พ้นคำถามว่ามันต่างกับ Dagger 2 อย่างไร ถึงแม้ว่าทั้งคู่จะเป็น DI Framework เหมือนกันก็ตาม
จากการที่เจ้าของบล็อกได้ลองใช้งานดูก็พบว่า Koin จุดเด่นดังนี้
Less Code
เมื่อเทียบกับตอนใช้ Dagger 2 ที่ต้องสร้างไฟล์อยู่ประมาณ 5–6 ไฟล์ถึงจะเริ่มทำงานได้ ในขณะที่ Koin มีแค่ไฟล์สำหรับ Module เท่านั้นเอง
No Generated Files
ไม่ต้อง Build Project เพื่อสร้าง Generated File แบบ Dagger 2 จึงทำให้ Koin มี Build Time ที่น้อยกว่า
Everywhere Injectable
อยากจะ Inject ตอนไหนก็ทำได้เลย แถมไม่ต้องทำ Injection ให้กับ Activity และ Fragment แบบ Dagger 2
Powered by Kotlin DSL
ทำให้โค้ดของ Koin ไม่ซับซ้อนเท่า Dagger 2 จึงสามารถอ่านได้ง่าย ไม่ว่าจะตอนสร้าง Module หรือตอน Inject เพื่อใช้งาน
แท้จริงแล้ว Koin นั้นเป็น Service Locator ไม่ใช่ Dependency Injection
ด้วยรูปแบบการทำงานของ Koin ต้องบอกว่าจริงๆแล้ว Koin นั้นเป็นแค่ Service Locator เท่านั้น ไม่ใช่ Dependency Injection แต่อย่างใด เพียงแค่ให้ผลลัพธ์เหมือนกัน
สรุป
เพราะการเขียน Android App ในยุคสมัยใหม่นี้ที่ไม่ใช่แค่เพียงการเขียนโค้ดให้พอทำงานได้อีกต่อไปแล้ว แต่ควรใส่ใจไปถึงระดับ Code Structure ในนั้นด้วย ยิ่งโปรเจคที่มีขนาดใหญ่มากและพัฒนามาเป็นเวลานานมากเท่าไร โอกาสที่โค้ดจะเละเทะ วุ่นวาย และแก้ไขยากก็จะเกิดขึ้นได้ง่าย
จึงทำให้นักพัฒนาต่างพากันมองหารูปแบบการเขียนโค้ดไม่ว่าจะเป็น MVP, MVVM, MVI หรือ VIPER เข้ามาใช้เพื่อจัดสรรปันส่วนโค้ดให้เป็นระเบียบมากขึ้น ซึ่งสุดท้ายแล้วก็จะต้องใช้ DI เข้ามาช่วยในการวาง Code Structure เหล่านี้นี่เอง เพื่อให้โค้ดแต่ละส่วนแยกออกจากกันอย่างชัดเจน สามารถดูแลได้ง่าย เขียนเทสก็ได้
พอพูดถึง DI แล้ว ก็จะนึกถึงโค้ดที่เยอะแยะและยุ่งเหยิงไปหมด จึงทำให้เกิดเป็น DI Framework ขึ้นมา เพื่อลดโค้ดที่ไม่จำเป็นให้น้อยลงแล้วเอาเวลาไปทุ่มเทกับส่วนอื่นๆที่สำคัญกว่าแทน
และ Koin ก็เป็น DI Framework ตัวหนึ่งที่ดีมาก ใช้ง่าย เหมาะกับยุคสมัยนี้ที่โปรเจคแอนดรอยด์ส่วนใหญ่เปลี่ยนมาใช้ภาษา Kotlin กันแล้ว และด้วยความสามารถของภาษา Kotlin จึงทำให้ Koin นั้นมีลูกเล่นที่น่าสนใจมากกว่า Dagger 2 ที่ใช้เป็นภาษา Java อย่างเห็นได้ชัด
ถ้าผู้ที่หลงเข้ามาอ่านจะขึ้นโปรเจคใหม่ที่เป็นภาษา Kotlin บอกเลยว่าลองใช้ Koin ดูครับ แล้วชีวิตจะง่ายขึ้นเยอะเลยล่ะ 😄