ארכיטקטורת האפליקציות המומלצת של Android מעודדת חלוקה של הקוד למחלקות כדי ליהנות מהפרדה בין נושאים, עיקרון שבו לכל מחלקה בהיררכיה יש אחריות מוגדרת אחת. כך נוצרים יותר מחלקות קטנות יותר שצריך לקשר ביניהן כדי למלא את התלות של כל אחת מהן.
אפשר לייצג את התלות בין הכיתות כגרף, שבו כל כיתה מחוברת לכיתות שהיא תלויה בהן. הייצוג של כל המחלקות והתלויות שלהן יוצר את גרף האפליקציה. באיור 1 אפשר לראות הפשטה של גרף האפליקציה. אם מחלקה A (ViewModel) תלויה במחלקה B (Repository), יש קו שמצביע מ-A ל-B ומייצג את התלות הזו.
הזרקת תלות עוזרת ליצור את החיבורים האלה ומאפשרת להחליף יישומים לצורך בדיקה. לדוגמה, כשבודקים ViewModel שתלוי במאגר, אפשר להעביר יישומים שונים של Repository עם זיופים או מוקים כדי לבדוק את המקרים השונים.
היסודות של הזרקת תלות ידנית
בקטע הזה נסביר איך להשתמש בהזרקת תלות ידנית בתרחיש אמיתי של אפליקציית Android. המאמר מתאר גישה איטרטיבית להטמעת הזרקת תלות באפליקציה. הגישה משתפרת עד שהיא מגיעה לנקודה שדומה מאוד למה ש-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 היא נקודת הכניסה. ההגדרה של התלות מתבצעת פעם אחת ב-ComponentActivity, וממשק המשתמש מתואר על ידי רכיבים קומפוזביליים שנקראים מ-setContent:onCreate
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)
)
// ...
}
יש כמה בעיות בגישה הזו:
- צריך להצהיר על יחסי התלות בסדר מסוים. כדי ליצור את
UserRepository, צריך ליצור מופע שלו לפניLoginViewModel. - קשה לעשות שימוש חוזר באובייקטים. אם רוצים להשתמש מחדש ב-
UserRepositoryבכמה תכונות, צריך להגדיר אותו לפי תבנית singleton. הדפוס singleton מקשה על הבדיקה כי כל הבדיקות חולקות את אותו מופע singleton.
ניהול יחסי תלות באמצעות קונטיינר
כדי לפתור את הבעיה של שימוש חוזר באובייקטים, אפשר ליצור מחלקה משלכם של מאגר תלויות שמשמשת לקבלת תלויות. כל המופעים שסופקו על ידי מאגר התגים הזה יכולים להיות ציבוריים. בדוגמה, מכיוון שצריך רק מופע של 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, או מתוך קומפוזבילי, באמצעות 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 מנקודה עמוקה בעץ. כך אפשר לבדוק את הקומפוזיציות והקלט שלהן יהיה ברור. פותרים את הבעיה עם הקונטיינר פעם אחת בשורש המסך ומעבירים את מה שצריך כלפי מטה.
בדרך הזו, לא נוצר סינגלטון 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
נצרך על ידי ה-composable viewModel, כך שה-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)
// ...
}
הגישה הזו טובה יותר מהגישה הקודמת, אבל עדיין יש כמה אתגרים שצריך לקחת בחשבון:
אתם צריכים לנהל את
AppContainerבעצמכם, וליצור מופעים לכל התלות באופן ידני.עדיין יש הרבה קוד שחוזר על עצמו (boilerplate). צריך ליצור מפעלים או פרמטרים באופן ידני, בהתאם לשאלה אם רוצים לעשות שימוש חוזר באובייקט או לא.
ניהול יחסי תלות בתהליכי עבודה של אפליקציות
השימוש ב-AppContainer מסתבך כשרוצים לכלול בפרויקט פונקציונליות נוספת. כשהאפליקציה גדלה ומתחילים להוסיף לה תהליכים שונים של תכונות, מתעוררות עוד בעיות:
אם יש לכם כמה תהליכים, יכול להיות שתרצו שהאובייקטים יהיו רק בהיקף של התהליך הזה. לדוגמה, כשיוצרים
LoginUserData(שעשוי לכלול את שם המשתמש והסיסמה שמשמשים רק בתהליך הכניסה), לא רוצים לשמור נתונים מתהליך כניסה ישן של משתמש אחר. אתם רוצים ליצור מופע חדש לכל תהליך חדש. כדי לעשות את זה, יוצרים אובייקטים שלFlowContainerבתוךAppContainer, כמו בדוגמת הקוד הבאה.יכול להיות שיהיה קשה גם לבצע אופטימיזציה של גרף האפליקציה ושל קונטיינרים של זרימות. בהתאם לתהליך שבו אתם נמצאים, חשוב לזכור למחוק מקרים שבהם אין לכם צורך.
נוסיף LoginContainer לקוד לדוגמה. אתם רוצים ליצור כמה מופעים של LoginContainer באפליקציה, ולכן במקום להגדיר אותו כ-singleton, אתם מגדירים אותו כמחלקה עם התלות שנדרשת לתהליך הכניסה מ-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 משותף, כי פונקציות קומפוזביליות מקבלות את התלויות שלהן כפרמטרים או קוראות אותן מ-ViewModel שהועלה. עומדות בפניך שתי אפשרויות:
- תרשים מוטמע של Navigation Compose (מומלץ לזרימות עם ריבוי מסכים).
ממקמים את כל המסכים בתהליך ההתחברות מתחת לתרשים ניווט מקונן, ומגדירים את היקף השימוש של הקטע
NavBackStackEntryשל הקונטיינר לתרשים הזה. מאגר התגים נוצר כשהמשתמש נכנס לתהליך, ומפונה כשהערך במקבץ פעילויות קודמות (back stack) מוסר, בלי שנדרשות קריאות ידניות למחזור החיים. מידע נוסף זמין במאמר עיצוב גרף הניווט. rememberבשורש המסך (בתהליך של מסך יחיד או כשלא משתמשים ב-Navigation Compose). יוצרים את הקומפוזיציה בתוךrememberכך שהיא נוצרת פעם אחת לכל כניסה לקומפוזיציה, ומתבצע איסוף אשפה כשהקומפוזיציה יוצאת:
@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.
}
סיכום
הזרקת תלות היא טכניקה טובה ליצירת אפליקציות ל-Android שניתנות להרחבה ולבדיקה. אפשר להשתמש בקונטיינרים כדי לשתף מופעים של מחלקות בחלקים שונים של האפליקציה, וכמקום מרכזי ליצירת מופעים של מחלקות באמצעות פקטורי.
כשהאפליקציה שלכם תגדל, תתחילו לראות שאתם כותבים הרבה קוד boilerplate (כמו factories), וזה עלול להוביל לשגיאות. בנוסף, אתם צריכים לנהל בעצמכם את ההיקף ומחזור החיים של הקונטיינרים, ולבצע אופטימיזציה של קונטיינרים שכבר לא נחוצים ולבטל אותם כדי לפנות זיכרון. אם תעשו את זה בצורה לא נכונה, עלולות להופיע באפליקציה באגים קלים ודליפות זיכרון.
בקטע על Dagger מוסבר איך אפשר להשתמש ב-Dagger כדי להפוך את התהליך הזה לאוטומטי וליצור את אותו קוד שהייתם כותבים ידנית.