برنامه‌نویسی پیشرفته اندروید با زبان کاتلین – بخش دوم

نویسنده : سید ایوب کوکبی ۲۳ تیر ۱۳۹۷

برنامه‌نویسی پیشرفته اندروید با زبان کاتلین - بخش دوم

ادامه‌ی سلسله مطالب برنامه‌نویسی پیشرفته‌ی اندروید با زبان کاتلین را با بخش دوم پی می‌گیریم.

۵- معماری MVVM + الگوی repository و Android Manager Wrappers

ابتدا لازم است کمی بیشتر با مفهوم معماری در دنیای اندروید آشنا شوید. مدت‌های طولانی توسعه‌دهندگان اندروید در پروژه‌ها پیرو معماری خاصی نبودند و به قول معروف دیمی کار می‌کردند! اما در سه سال اخیر استفاده از معماری رواج یافت. کم‌کم بساط God Activityها (اکتیویتی‌های بزرگ) جمع شد و گوگل با انتشار رسمی Android Architecture Blueprint در گیت‌هاب با مثال‌های، فراوان تفاوت رویکردهای مختلف معماری را نشان داد. گوگل نهایتاً در هفدهمین کنفرانس سالانه‌اش (IO ’17)، مجموعه‌ی Android Architecture Components را منتشر کرد. این کامپوننت‌ها مجموعه‌ای از کتابخانه‌هاست که به نوشتن کدهای تمییزتر و اپلیکیشن‌های بهتر کمک می‌کند. منظور از کامپوننت این است که می‌توانید از تمام یا بخش‌هایی از آن در پروزه‌ی خود استفاده کنید. در ادامه با کاربرد این کامپوننت‌ها در عمل آشنا می‌شوید. روال کار در این مقاله اینطور است که ابتدا مسئله را به شیوه‌ی رایج کدنویسی می‌کنیم، سپس با استفاده از کامپوننت‌ها و کتابخانه‌ها که هر کدام توانایی حل گوشه‌ای از مشکلات را دارند کدهای نوشته شده را ریفکتور می‌کنیم.

برای جداسازی GUI (واسط گرافیکی) و منطق برنامه دو معماری اصلی وجود دارد که معروف‌تر از بقیه است:

  • MVP
  • MVVM

از بین این دو الگو به سختی می‌توان یکی را برتر از دیگری دانست. خودتان باید هر دو را استفاده کنید سپس تصمیم بگیرید. به شخصه خودم الگوی MVVM را بیشتر می‌پسندم چون آگاهی بیشتری نسبت به چرخه‌ی حیات داراست. در این مقاله سعی می‌کنم توضیحات لازم برای آشنایی با این الگو را ارائه دهم ولی بهتر است با جستجویی در گوگل درباره‌ی الگوی MVP هم اطلاعاتی کسب کنید.

الگوی MVVM چیست؟

MVVM در دسته‌ی الگوهای معماری قرار می‌گیرد. این الگو سرنام کلمات Model-View-ViewModel است. به نظر من این نام‌گذاری باعث سردرگمی توسعه‌دهندگان می‌شود. من بودم اسمش را می‌گذاشتم View-ViewModel-Model تا ViewModel به عنوان رابط، بین View و Model قرار بگیرد.

View یک مفهوم کلی است که به اکتیویتی، فرگمنت یا هر کاستوم ویوی دیگری دلالت دارد. دقت کنید، این ویو با آن ویویی که در اندروید به کار می‌برید فرق می‌کند. شما نباید هیچ کدی در ویو بنویسید. ویو نباید هیچگونه داده‌ای را نگه‌داری کند. تمامی داده‌های مورد نیاز ویو از طریق لایه ViewModel تامین می‌شود. ضمناً ویو باید چشمش به ViewModel باشد تا به محض تغییر داده‌ها، ویو را با داده‌های جدید بروزرسانی کند. خلاصه اینکه وظیفه‌ی View، تنظیم شکل نمایش داده‌ها و حالات مختلف برنامه است. مثلاً امتیاز کاربر تغییر می‌کند. این وظیفه‌ی ویو است که مشخص کند امتیاز کاربر را با ستاره نشان دهد یا عدد؟ ویو کاری ندارد داده‌ها از کجا می‌آید. فقط طرز نمایش داده‌ها را مدیریت می‌کند.

ViewModel مفهومی انتزاعی برای کلاس‌ها و ساختارهایی است که داده‌ها و منطق برنامه در آنجا مشخص می‌شود. این منطق برنامه‌ی شماست که تعیین می‌کند داده‌ها چه زمانی استخراج، بروزرسانی و نمایش داده شود. ویومدل  حالت فعلی برنامه که برنامه‌نویس‌ها به آن State می‌گویند را نگه‌داری می‌‌کند. همچنین ویومدل به یک یا چند نمونه از Model به منظور دریافت اطلاعات متصل است. ویومدل نباید از منبع داده‌ها مطلع باشد یعنی مهم نیست که داده‌هایش از دیتابیس محلی یا ریموت سرور می‌آید. همچنین نباید اطلاعاتی از ویو داشته باشد. ویومدل نباید هیچ‌گونه اطلاعاتی از فریم‌ورک اندروید داشته باشد.

Model لایه‌ای است که تامین‌کننده‌ی داده‌های مورد نیاز برای ViewModel است. این لایه معمولاً حاوی کلاس‌هایی است که داده‌ها را از سرور دریافت کرده و در کش یا دیتابیس محلی ذخیره می‌کند. دقت کنید مدل به کلاس‌هایی مثل User, Car, Square و … اطلاق نمی‌شود. مدل تنها و تنها داده‌ها را نگه‌داری می‌کند. معمولاً این لایه پیاده‌سازی الگوی Repository است که در ادامه‌ی متن توضیح می‌دهم. Model نباید اطلاعاتی از ViewModel داشته باشد.

در صورت پیاده‌سازی صحیح الگوی MVVM کدهای برنامه به خوبی جداسازی شده و آزمون‌پذیری‌اش نیز بیشتر می‌شود. این الگو کمک می‌کند تا کدهایمان را مطابق اصول SOLID بنا کنیم.

مثال

سعی می‌کنم با یک مثال خیلی ساده، طرز کار این الگو را توضیح دهم.

در ابتدا یک مدل خیلی ساده درست می‌کنیم. این مدل فقط یک رشته‌ی معمولی را برمی‌گرداند:

class RepoModel {

fun refreshData() : String {
return "Some new data"
}
}

معمولاً داده‌ها به صورت ناهمگام (Async) دریافت می‌شود. مثلاً کمی زمان می‌برد تا اطلاعات مورد نظر از سرور دریافت شود. برای واقعی‌تر شدن مدل یک تاخیر ۲ ثانیه‌ای ایجاد می‌کنیم:

class RepoModel {

fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
}
}

interface OnDataReadyCallback {
fun onDataReady(data : String)
}

در ابتدا من اینتفریس OnDataReadyCallback را ساخته‌ام که حاوی تابع onDataReady است. متد refreshData، یک پیاده‌سازی از اینتفریس فوق را دریافت می‌کند. برای شبیه‌سازی انتظار از Handler استفاده کرده‌ام طوری که بعد از دو ثانیه تابع onDataReady از پیاده‌سازی اینتفریس OnDataReadyCallBack فراخوانی می‌شود.

خب برویم سراغ ViewModel:

class MainViewModel {
var repoModel: RepoModel = RepoModel()
var text: String = ""
var isLoading: Boolean = false
}

همانطور که می‌بینید، متغیر اول نمونه‌ای از کلاس RepoModel است، text متنی که باید نمایش دهد را ذخیره می‌کند و isloading وضعیت فعلی برنامه را نگه‌داری می‌کند. خب تابع refresh را کامل کنیم که وظیفه‌ی اصلی دریافت داده‌ها را بر عهده دارد:

class MainViewModel {
...

val onDataReadyCallback = object : OnDataReadyCallback {
override fun onDataReady(data: String) {
isLoading.set(false)
text.set(data)
}
}

fun refresh(){
isLoading.set(true)
repoModel.refreshData(onDataReadyCallback)
}
}

تابع refresh تابع refreshData را بر روی نمونه‌ی ایجاد شده از RepoModel که خودش اینتفرفیس OnDataReadyCallback را پیاده‌سازی کرده فرخوانی می‌کند. درست؛ اما داستان این object چیست؟ هر زمان لازم بود اینترفیس یا کلاسی را بدون ساخت زیرکلاس پیاده‌سازی یا اکستند نمایید باید از object استفاده کنید.

class MainViewModel {
var repoModel: RepoModel = RepoModel()
var text: String = ""
var isLoading: Boolean = false

fun refresh() {
repoModel.refreshData( object : OnDataReadyCallback {
override fun onDataReady(data: String) {
text = data
})
}
}

در هنگام فراخوانی refresh باید مقدار متغیر isLoading را true کنیم و بعد از آماده شدن اطلاعات مقدارش را به false تغییر دهیم. بعداً خواهید دید که این تغییرات مستقیماً روی ویو تاثیر می‌گذارد برای این منظور بایستی متغیر isLoading را به <ObservableField<Boolean تغییر داده و همینطور مقدار text را هم به <ObservableField<String تغییر دهید. کلاس ObservableField منعلق به کتابخانه‌ی Data Binding است که زحمت ساخت دستی این کلاس را از دوش ما برداشته است. این کلاس، شی‌ای که باید اصطلاحاً Observe شود را پوشش می‌دهد:

بخوانید  برنامه‌نویسی پیشرفته اندروید با زبان کاتلین - بخش اول

class MainViewModel {
var repoModel: RepoModel = RepoModel()

val text = ObservableField&lt;String&gt;()

val isLoading = ObservableField&lt;Boolean&gt;()

fun refresh(){
isLoading.set(true)
repoModel.refreshData(object : OnDataReadyCallback {
override fun onDataReady(data: String) {
isLoading.set(false)
text.set(data)
}
})
}
}

دقت کنید من به جای var از val استفاده کرده‌ام تا فقط مقدار درون فیلد تغییر کند نه خودِ فیلد و برای مقدار دهی آن باید اینگونه عمل کنید:

val text = ObservableField("old data")
val isLoading = ObservableField(false)

خب حالا layout را تغییر دهیم تا مقدار متغیرهای text و isLoading را منعکس کند. در ابتدا به جای Repository باید به MainViewModel بایند کنیم.

<data>
<variable name=”viewModel” type=”me.mladenrakonjac.modernandroidapp.MainViewModel” />
</data>

به همین صورت کارهای دیگری نیز لازم است:

  • تغییر TextView برای نمایش مقدار text از MainViewModel؛
  • اضافه کردن یک پروگرس‌بار طوری که فقط وقتی isLoading مقدارش true است نمایش داده شود؛
  • اضافه کردن دکمه‌ای برای فراخوانی تابع refresh از MainViewModel طوری که فقط در هنگام false بودن isLoading فعال باشد.

…

<TextView android:id=”@+id/repository_name” android:text=”@{viewModel.text}” … />

…
<ProgressBar android:id=”@+id/loading” android:visibility=”@{viewModel.isLoading ? View.VISIBLE : View.GONE}” … />

<Button android:id=”@+id/refresh_button” android:onClick=”@{() -> viewModel.refresh()}”
android:clickable=”@{viewModel.isLoading ? false : true}”
/>
…

اگر برنامه را اجرا کنید به خاطر ایمپورت نکردن View برای View.Visible و View.Gone با خطا مواجه می‌شوید پس View را ایمپورت کنید:

<data>
<import type=”android.view.View”/>

<variable name=”viewModel” type=”me.fleka.modernandroidapp.MainViewModel” />
</data>

خب کارمان با layout تمام شد. اکنون نوبت تکمیل کارهای binding است. همانطور که گفتم View باید نمونه‌ای از ViewModel را داشته باشد:

class MainActivity : AppCompatActivity() {

lateinit var binding: ActivityMainBinding

var mainViewModel = MainViewModel()

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

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.viewModel = mainViewModel
binding.executePendingBindings()

}
}

در نهایت برنامه را می‌توانیم اجرا کنیم:

https://giphy.com/gifs/xT9IgGDfxL2zUD2UBa

همانطور که می‌بینید مقدار old data به new data تغییر داده می‌شود.

این یک مثال ساده از MVVM بود.

اما یک مشکلی این وسط هست. گوشی را بچرخانید:

https://giphy.com/gifs/l378wzfwrjQULQMa4

مشاهده می‌کنید که مقدار new data به مقدار قبلی‌اش یعنی old data تغییر می‌کند. دلیلش چیست؟ بیایید نگاهی به چرخه‌ی حیات اکتیویتی بیندازیم:

وقتی گوشی را می‌چرخانید، نمونه یا instance جدیدی از اکتیویتی ساخته می‌شود، بنابراین متد ()onCreate فرخوانی می‌شود. حالا به اکتیویتی نگاه کنیم:

class MainActivity : AppCompatActivity() {

lateinit var binding: ActivityMainBinding

var mainViewModel = MainViewModel()

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

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.viewModel = mainViewModel
binding.executePendingBindings()

}
}

همانطور که می‌بینید با ساخته شدن یک نمونه‌ی جدید از اکتیویتی، نمونه‌ی جدیدی از MainViewModel هم ساخته می‌شود. خب مشکل را پیدا کردیم. باید MainViewModel طوری باشد که برای هر نمونه‌ای از MainActivity بدون تغییر باقی بماند.

مقدمه‌ای بر Lifecycle-aware components

از آنجایی که این مسئله، مشکل اغلب افراد است، تیم توسعه‌ی فریم‌ورک اندروید تصمیم گرفت تا با ارائه‌ی یک کتابخانه این مشکل را حل کند. ViewModel یکی از همین کلاس‌هاست. این کلاس بایستی توسط همه‌ی ViewModelها اکستند شود.

پس بیایید MainViewModel را تغییر دهیم تا کلاس ViewModel را اکستند کند. برای انجام این کار ابتدا باید کتابخانه‌ی lifecycle-aware components را به فایل build.gradle اضافه کنیم:

dependencies {
...

implementation "android.arch.lifecycle:runtime:1.0.0-alpha9"
implementation "android.arch.lifecycle:extensions:1.0.0-alpha9"
kapt "android.arch.lifecycle:compiler:1.0.0-alpha9"
}

حالا MainViewModel می‌تواند ViewModel را اکستند کند:

package me.mladenrakonjac.modernandroidapp

import android.arch.lifecycle.ViewModel

class MainViewModel : ViewModel() {
...
}

و تابع ()onCreate از کلاس MainActivity را هم باید اینگونه تغییر دهید:

class MainActivity : AppCompatActivity() {

lateinit var binding: ActivityMainBinding

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

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
binding.executePendingBindings()

}
}

توجه کنید ما هیچ نمونه‌ی جدیدی از MainViewModel نساخته‌ایم بلکه ViewModel را از ViewModelProviders گرفته‌ایم. ViewModelProviders یک کلاس کمکی است که متدهایی برای دریافت ViewModelProvider دارد. با فرخوانی (ViewModelProviders.of(this در اکتیوینی مورد نظر ویومدل تا اتمام چرخه‌ی حیات اکتیویتی (یعنی تا زمان فرخوانی متدهای destroyed) زنده می‌ماند و اگر داخل فرگمنت فرخوانی کنید تا پایان حیات آن فرگمنت باقی است. تصویر پایین همه چیز را به خوبی نشان می‌دهد:

ViewModelProviders مسئول نمونه‌سازی ViewModel در اولین فرخوانی است و در سایر دفعات ViewModelای که قبلاً نمونه‌سازی شده را برمی‌گرداند.

این خط شما را گیج نکند:

MainViewModel::class.java

در کاتلین، اگر فقط کد پایین را به کار برید:

MainViewModel::class

فقط KClass که چیزی متفاوت از کلاس جاوا است برگردانده می‌شود. ولی در صورت به کار بردن java. معادل آن کلاس در زبان جاوا برگردانده می‌شود.

حالا دوباره تست کنیم با چرخاندن صفحه چه میشود:

https://giphy.com/gifs/3ohhwNDWxsh4pr0u1W

بله مشکل حل شده است. همان اطلاعاتی که قبل از چرخاندن صفحه داشتیم نمایش داده می‌شود.

در مقاله‌ی قبلی گفتم که اپلیکیشن ما لیستی از ریپوزیتوری‌های گیت‌هاب را دریافت و نمایش می‌دهد. برای ساخت چنین چیزی به تابعی نیاز داریم که به صورت فیک لیستی را تولید و در اختیار ما قرار دهد.

class RepoModel {

fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
}

fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
var arrayList = ArrayList&lt;Repository&gt;()
arrayList.add(Repository("First", "Owner 1", 100 , false))
arrayList.add(Repository("Second", "Owner 2", 30 , true))
arrayList.add(Repository("Third", "Owner 3", 430 , false))

Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) },2000)
}
}

interface OnDataReadyCallback {
fun onDataReady(data : String)
}

interface OnRepositoryReadyCallback {
fun onDataReady(data : ArrayList&lt;Repository&gt;)
}

همچنین به تابعی در MainViewModel نیاز داریم که از داخل RepoModel تابع getRepositories را فرخوانی کند:

class MainViewModel : ViewModel() {
...
var repositories = ArrayList&lt;Repository&gt;()

fun refresh(){
...
}

fun loadRepositories(){
isLoading.set(true)
repoModel.getRepositories(object : OnRepositoryReadyCallback{
override fun onDataReady(data: ArrayList&lt;Repository&gt;) {
isLoading.set(false)
repositories = data
}
})
}
}

و در پایان، این لیست را باید در یک RecyclerView نمایش دهیم. برای این منظور باید کارهای زیر را انجام دهیم:

  • ساخت rv_item_repository.xml؛
  • اضافه کردن RecyclerView به activity_main.xml؛
  • ساخت RepositoryRecyclerViewAdapter؛
  • اتصال adapter به RecyclerView.

من برای ساخت rv_item_repository.xml از کتابخانه‌ی CardView استفاده کرده‌ام. بنابراین به افزودن کتابخانه‌ی آن در build.gradle نیاز داریم:

implementation 'com.android.support:cardview-v7:26.0.1'

محتوای این فایل چنین چیزی است:

<?xml version=”1.0″ encoding=”utf-8″?>
<layout xmlns:android=”http://schemas.android.com/apk/res/android” xmlns:app=”http://schemas.android.com/apk/res-auto” xmlns:tools=”http://schemas.android.com/tools”>

<data>

<import type=”android.view.View” />

<variable name=”repository” type=”me.mladenrakonjac.modernandroidapp.uimodels.Repository” />
</data>

<android.support.v7.widget.CardView android:layout_width=”match_parent” android:layout_height=”96dp” android:layout_margin=”8dp”>

<android.support.constraint.ConstraintLayout android:layout_width=”match_parent” android:layout_height=”match_parent”>

<TextView android:id=”@+id/repository_name” android:layout_width=”wrap_content” android:layout_height=”wrap_content” android:layout_marginEnd=”16dp” android:layout_marginStart=”16dp” android:text=”@{repository.repositoryName}” android:textSize=”20sp” app:layout_constraintBottom_toBottomOf=”parent” app:layout_constraintHorizontal_bias=”0.0″ app:layout_constraintLeft_toLeftOf=”parent” app:layout_constraintRight_toRightOf=”parent” app:layout_constraintTop_toTopOf=”parent” app:layout_constraintVertical_bias=”0.083″ tools:text=”Modern Android App” />

<TextView android:id=”@+id/repository_has_issues” android:layout_width=”wrap_content” android:layout_height=”wrap_content” android:layout_marginEnd=”16dp” android:layout_marginStart=”16dp” android:layout_marginTop=”8dp” android:text=”@string/has_issues” android:textStyle=”bold” android:visibility=”@{repository.hasIssues ? View.VISIBLE : View.GONE}” app:layout_constraintBottom_toBottomOf=”@+id/repository_name” app:layout_constraintEnd_toEndOf=”parent” app:layout_constraintHorizontal_bias=”1.0″ app:layout_constraintStart_toEndOf=”@+id/repository_name” app:layout_constraintTop_toTopOf=”@+id/repository_name” app:layout_constraintVertical_bias=”1.0″ />

<TextView android:id=”@+id/repository_owner” android:layout_width=”0dp” android:layout_height=”wrap_content” android:layout_marginBottom=”8dp” android:layout_marginEnd=”16dp” android:layout_marginStart=”16dp” android:text=”@{repository.repositoryOwner}” app:layout_constraintBottom_toBottomOf=”parent” app:layout_constraintEnd_toEndOf=”parent” app:layout_constraintStart_toStartOf=”parent” app:layout_constraintTop_toBottomOf=”@+id/repository_name” app:layout_constraintVertical_bias=”0.0″ tools:text=”Mladen Rakonjac” />

<TextView android:id=”@+id/number_of_starts” android:layout_width=”wrap_content” android:layout_height=”wrap_content” android:layout_marginBottom=”8dp” android:layout_marginEnd=”16dp” android:layout_marginStart=”16dp” android:layout_marginTop=”8dp” android:text=”@{String.valueOf(repository.numberOfStars)}” app:layout_constraintBottom_toBottomOf=”parent” app:layout_constraintEnd_toEndOf=”parent” app:layout_constraintHorizontal_bias=”1″ app:layout_constraintStart_toStartOf=”parent” app:layout_constraintTop_toBottomOf=”@+id/repository_owner” app:layout_constraintVertical_bias=”0.0″ tools:text=”0 stars” />

</android.support.constraint.ConstraintLayout>

</android.support.v7.widget.CardView>

</layout>

گام بعدی اضافه کردن RecyclerView به activity_main.xml است. قبل از انجام این کار باید کتابخانه RecyclerView را اضافه کنید:

بخوانید  برنامه‌نویسی پیشرفته اندروید با زبان کاتلین - بخش سوم (RxJava)

implementation ‘com.android.support:recyclerview-v7:26.0.1’

<?xml version=”1.0″ encoding=”utf-8″?>
<layout xmlns:android=”http://schemas.android.com/apk/res/android” xmlns:app=”http://schemas.android.com/apk/res-auto” xmlns:tools=”http://schemas.android.com/tools”>

<data>

<import type=”android.view.View”/>

<variable name=”viewModel” type=”me.fleka.modernandroidapp.MainViewModel” />
</data>

<android.support.constraint.ConstraintLayout android:layout_width=”match_parent” android:layout_height=”match_parent” tools:context=”me.fleka.modernandroidapp.MainActivity”>

<ProgressBar android:id=”@+id/loading” android:layout_width=”48dp” android:layout_height=”48dp” android:indeterminate=”true” android:visibility=”@{viewModel.isLoading ? View.VISIBLE : View.GONE}” app:layout_constraintBottom_toTopOf=”@+id/refresh_button” app:layout_constraintEnd_toEndOf=”parent” app:layout_constraintStart_toStartOf=”parent” app:layout_constraintTop_toTopOf=”parent” />

<android.support.v7.widget.RecyclerView android:id=”@+id/repository_rv” android:layout_width=”0dp” android:layout_height=”0dp” android:indeterminate=”true” android:visibility=”@{viewModel.isLoading ? View.GONE : View.VISIBLE}” app:layout_constraintBottom_toTopOf=”@+id/refresh_button” app:layout_constraintEnd_toEndOf=”parent” app:layout_constraintStart_toStartOf=”parent” app:layout_constraintTop_toTopOf=”parent” tools:listitem=”@layout/rv_item_repository” />

<Button android:id=”@+id/refresh_button” android:layout_width=”160dp” android:layout_height=”40dp” android:layout_marginBottom=”8dp” android:layout_marginEnd=”8dp” android:layout_marginStart=”8dp” android:layout_marginTop=”8dp” android:onClick=”@{() -> viewModel.loadRepositories()}”
android:clickable=”@{viewModel.isLoading ? false : true}”
android:text=”Refresh”
app:layout_constraintBottom_toBottomOf=”parent”
app:layout_constraintEnd_toEndOf=”parent”
app:layout_constraintStart_toStartOf=”parent”
app:layout_constraintTop_toTopOf=”parent”
app:layout_constraintVertical_bias=”1.0″ />

</android.support.constraint.ConstraintLayout>

</layout>

با این حساب می‌توانید تابع refresh از MainViewModel و refreshData از RepoModel را حذف کنید. دیگر به آن‌ها نیازی نداریم. اکنون بایستی آداپتور داده را به RecyclerView متصل کنیم:

class RepositoryRecyclerViewAdapter(private var items: ArrayList&lt;Repository&gt;,
private var listener: OnItemClickListener)
: RecyclerView.Adapter&lt;RepositoryRecyclerViewAdapter.ViewHolder&gt;() {

override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
val layoutInflater = LayoutInflater.from(parent?.context)
val binding = RvItemRepositoryBinding.inflate(layoutInflater, parent, false)
return ViewHolder(binding)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int)
= holder.bind(items[position], listener)

override fun getItemCount(): Int = items.size

interface OnItemClickListener {
fun onItemClick(position: Int)
}

class ViewHolder(private var binding: RvItemRepositoryBinding) :
RecyclerView.ViewHolder(binding.root) {

fun bind(repo: Repository, listener: OnItemClickListener?) {
binding.repository = repo
if (listener != null) {
binding.root.setOnClickListener({ _ -&gt; listener.onItemClick(layoutPosition) })
}

binding.executePendingBindings()
}
}

}

دقت کنید ViewHolder به جای View نمونه‌ای از نوع RvItemRepositoryBinding را می‌گیرد بنابراین خواهیم توانست Data Binding را در ViewHolder برای هر آیتمی پیاده‌سازی کنیم. ضمناً توضیحی هم در مورد این خط بدهم:

override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(items[position], listener)

این در واقع شکل کوتاه‌تر کد پایین است:

override fun onBindViewHolder(holder: ViewHolder, position: Int){
return holder.bind(items[position], listener)
}

و [items[position پیاده‌سازی عملگر indexing است. تقریباً چیزی شبیه همان (items.get(position است.

چیز دیگری که ممکن است گیج‌کننده به نظر برسد این قسمت است:

binding.root.setOnClickListener({ _ -&gt; listener.onItemClick(layoutPosition) })

شما می‌توانید پارامترهایی که لازم ندارید را با خط زیر _ جایگزین کنید. زیباست نه؟

خب Adapter ما آماده شده ولی هنوز به recyclerView در MainActivity متصل نشده است:

class MainActivity : AppCompatActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

lateinit var binding: ActivityMainBinding

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

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
binding.viewModel = viewModel
binding.executePendingBindings()

binding.repositoryRv.layoutManager = LinearLayoutManager(this)
binding.repositoryRv.adapter = RepositoryRecyclerViewAdapter(viewModel.repositories, this)

}

override fun onItemClick(position: Int) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}

خب کار تمام است، برنامه را اجرا می‌کنیم:

https://giphy.com/gifs/xT9IgoJhfrT5cmgHxS

اما یک چیز مهم! تا الان برنامه به این صورت کار می‌کند:

  • اکتیویتی ساخته می‌شود، بنابراین یک آداپتور جدید با Repositories خالی ایجاد می‌شود؛
  • روی دکمه کلیک می‌کنیم؛
  • تابع loadRepositories فرخوانی شده و پروگرس‌بار نمایش داده می‌شود؛
  • بعد از ۲ ثانیه، ریپوها نمایش داده شده و پروگرس‌بار پنهان می‌شود اما Repositories خیر. دلیلش این است که notifyDataSetChanged روی آداپتور فرخوانی می‌شود؛
  • به محض چرخاندن صفحه‌ی گوشی، اکتیویتی جدیدی ساخته می‌شود بنابراین آداپتور جدیدی ساخته می‌شود که پارامتر repositories آن حاوی تعدادی آیتم است.

خب، MainViewModel چگونه می‌تواند MainActivity را از آیتم‌های جدید مطلغ کند تا بتوانیم از notifyDataSetChanged استفاده کنیم؟

در معماری MVVM بسیار مهم است که MainViewModel اطلاعی از MainActivity نداشته باشد.

MainActivity باید نمونه‌ای از MainViewModel داشته باشد که از طریق آن تغییرات جدید را به آداپتور اطلاع دهد. اما چگونه این کار را انجام دهیم؟

ما می‌توانیم با مانیتور کردن repositories تغییرات داده‌ای را به آداپتور اطلاع دهیم.

اما مشکل این روش چیست؟

این سناریو را در نظر بگیرید:

  • در MainActivity مقدار repositories مانیتور می‌شود. با هر تغییری تابع notifyDataSetChanged صدا زده می‌شود؛
  • روی دکمه کلیک می‌کنیم؛
  • مادامی که منتظر تغییرات داده‌ای هستیم، MainActivity می‌تواند به واسطه‌ی تغییر configuration بازسازی شود (مثلاً با چرخاندن گوشی)؛
  • MainViewModel ما هنوز زنده است؛
  • بعد از دو ثانیه، فیلد repositories تعدادی آیتم جدید دریافت می‌کند و به observer اطلاع می‌دهد که داده‌ها تغییر کرده است؛
  • observer یا ناظر سعی می‌کند روی آداپتوری که وجود ندارد تابع notifyDataSetChanged را فرخوانی کند.

پس این راهکار به اندازه‌ی کافی خوب نیست.

معرفی LiveData

LiveData یکی دیگر از کامپوننت‌های Lifecycle-aware است. این کامپوننت از چرخه‌ی حیات ویو کاملاً مطلع بوده و وقتی یک اکتیویتی به خاطر تغییر Configuration نابود یا اصطلاحاً Destroyed می‌شود LiveData از آن آگاه است پس observer را از اکتیویتی نابود شده حذف می‌کند.

بیایید آن را در MainViewModel پیاده‌سازی کنیم:

class MainViewModel : ViewModel() {
var repoModel: RepoModel = RepoModel()

val text = ObservableField("old data")

val isLoading = ObservableField(false)

var repositories = MutableLiveData&lt;ArrayList&lt;Repository&gt;&gt;()

fun loadRepositories() {
isLoading.set(true)
repoModel.getRepositories(object : OnRepositoryReadyCallback {
override fun onDataReady(data: ArrayList&lt;Repository&gt;) {
isLoading.set(false)
repositories.value = data
}
})
}
}

و برای مشاهده‌ی تغییرات در MainActivity کدهایش را اینگونه تغییر دهید:

class MainActivity : LifecycleActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

private lateinit var binding: ActivityMainBinding
private val repositoryRecyclerViewAdapter = RepositoryRecyclerViewAdapter(arrayListOf(), this)

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

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
binding.viewModel = viewModel
binding.executePendingBindings()

binding.repositoryRv.layoutManager = LinearLayoutManager(this)
binding.repositoryRv.adapter = repositoryRecyclerViewAdapter
viewModel.repositories.observe(this,
Observer&lt;ArrayList&lt;Repository&gt;&gt; { it?.let{ repositoryRecyclerViewAdapter.replaceData(it)} })

}

override fun onItemClick(position: Int) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}

معنی کلیدواژه‌ی it چیست؟ اگر تابعی فقط یک پارامتر داشته باشد، آن پارامتر را می‌توانیم با کلمه‌ی کلیدی it دریافت کنیم. فرض کنید یک عبارت لامبدا که عددی را در ۲ ضرب می‌کند داریم:

((a) -&gt; 2 * a)

به جایش می‌توانیم از کد پایین استفاده کنیم:

(it * 2)

اگر برنامه را اجرا کنید مشاهده خواهید کرد که همه چیز درست کار میکند:

https://giphy.com/gifs/l1J9sHpXgFlSVaPRu

دلیل اینکه MVVM را به MVP ترجیح می‌دهم چیست؟

  • هیچ اینترفیس خسته‌کننده‌ای برای View ندارد چرا ViewModel ارجاعی به View ندارد؛
  • هیچ اینترفیس خسته‌کننده‌ای برای Presenter وجود ندارد و اساساً نیازی به آن نیست؛
  • هندل کردن تغییرات configuration یا پیکربندی ساده‌تر است؛
  • با استفاده از MVVM کدهای کمتری در اکتیویتی، فرگمنت و سایر قسمت‌ها داریم.

الگوی Repository

همانطور که قبلاً گفتم، Model تنها یک مفهوم انتزاعی برای لایه آماده‌سازی داده‌هاست. معمولاً این لایه حاوی کلاس‌ها و مخازن داده است. هر یک از کلاس‌های داده بایستی کلاس Repository متناظری هم داشته باشند. برای مثال اگر کلاس‌های User و Post را داشته باشیم باید کلاس‌های UserRepository و PostRepository را هم داشته باشیم. تمام داده‌ها بایستی از همین ریپوها استخراح شوند. هرگز نباید از داخل Viewیا ViewModelدسترسی مستقیمی به Shared Preference یا دیتابیس داشته باشیم.

بخوانید  آموزش زبان کاتلین - درس 1 (ساخت اولین برنامه)

پس ما می‌توانیم RepoModel خود را به GitRepoRepository تغییر دهیم که منظور از GitRepo همان Github Repository است و Repository آخر منظور الگوی Repository است.

class GitRepoRepository {

fun getGitRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
var arrayList = ArrayList&lt;Repository&gt;()
arrayList.add(Repository("First", "Owner 1", 100, false))
arrayList.add(Repository("Second", "Owner 2", 30, true))
arrayList.add(Repository("Third", "Owner 3", 430, false))

Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) }, 2000)
}
}

interface OnRepositoryReadyCallback {
fun onDataReady(data: ArrayList&lt;Repository&gt;)
}

بسیار خب، MainViewModel لیست تمام ریپوهای گیت‌هاب را از GitRepoRepositories دریافت می‌کند. اما خود GitRepoRepositories داده‌ها را از کجا می‌گیرد؟

شما می‌توانید داخل Repository مستقیماً به دیتابیس متصل شوید ولی روش خوبی نیست. برنامه را تا جای ممکن باید ماژولار ساخت. مثلاً ممکن است به جای کلاینت Volley بخواهید از Retrofit استفاده کنید. در چنین شرایطی تغییر کلاینت کمی دشوار است. Repository شما نباید اطلاعی از کلاینتی که داده‌ها را دریافت می‌کند داشته باشد.

  • تنها چیزی که Repository به آن نیاز دارد این است که بداند داده‌ها محلی است یا  از سرور دریافت می‌شود، نیازی نیست که بداند این داده‌ها چگونه دریافت می‌شوند؛
  • تنها چیزی که ViewModel نیاز دارد داده‌هاست؛
  • تنها چیزی که View باید انجام دهد، نمایش داده‌هاست.

من وقتی برنامه‌نویسی اندروید را شروع کردم همیشه تعجیب می‌کردم که برنامه چطور به صورت آفلاین کار می‌کنند و عملیات بهنگام‌سازی یا sync چگونه صورت می‌گیرد. معماری خوبِ برنامه به سادگی اجازه‌ی چنین کاری را به ما می‌دهد. مثلاً در هنگام فرخوانی loadRepositories در ViewModel اگر اتصال اینترنت برقرار باشد، GitRepoRepositories داده‌ها را می‌تواند از سرور دریافت کرده وان در دیتابیس محلی ذخیره کند. به محض آفلاین شدن گوشی، GitRepoRepositories داده‌ها را از دیتابیس محلی دریافت می‌کند. بنابراین Repositories باید نمونه‌ای از RemoteDataSource و LocalDataSource و منطقی برای هندل کردن محلی که داده‌ها از آنجا سرچشمه می‌گیرند داشته باشد.

ابتدا local data source را اضافه کنیم:

class GitRepoLocalDataSource {

fun getRepositories(onRepositoryReadyCallback: OnRepoLocalReadyCallback) {
var arrayList = ArrayList&lt;Repository&gt;()
arrayList.add(Repository("First From Local", "Owner 1", 100, false))
arrayList.add(Repository("Second From Local", "Owner 2", 30, true))
arrayList.add(Repository("Third From Local", "Owner 3", 430, false))

Handler().postDelayed({ onRepositoryReadyCallback.onLocalDataReady(arrayList) }, 2000)
}

fun saveRepositories(arrayList: ArrayList&lt;Repository&gt;){
//todo save repositories in DB
}
}

interface OnRepoLocalReadyCallback {
fun onLocalDataReady(data: ArrayList&lt;Repository&gt;)
}

اینجا دو متد داریم: اولی داده‌های محلی جعلی برمی‌گرداند و دومی به صورت جعلی این داده‌ها را ذخیره می‌کند.

این هم از remote data source :

class GitRepoRemoteDataSource {

fun getRepositories(onRepositoryReadyCallback: OnRepoRemoteReadyCallback) {
var arrayList = ArrayList<Repository>()
arrayList.add(Repository(“First from remote”, “Owner 1”, 100, false))
arrayList.add(Repository(“Second from remote”, “Owner 2”, 30, true))
arrayList.add(Repository(“Third from remote”, “Owner 3”, 430, false))

Handler().postDelayed({ onRepositoryReadyCallback.onRemoteDataReady(arrayList) }, 2000)
}
}

interface OnRepoRemoteReadyCallback {
fun onRemoteDataReady(data: ArrayList<Repository>)
}


این یکی فقط متدی برای دریافت داده‌ها به صورت جعلی (fake) دارد.

خب حالا می‌توانیم منطقی به Repository اضافه کنیم:

class GitRepoRepository {

val localDataSource = GitRepoLocalDataSource()
val remoteDataSource = GitRepoRemoteDataSource()

fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
remoteDataSource.getRepositories( object : OnRepoRemoteReadyCallback {
override fun onDataReady(data: ArrayList&lt;Repository&gt;) {
localDataSource.saveRepositories(data)
onRepositoryReadyCallback.onDataReady(data)
}

})
}
}

interface OnRepositoryReadyCallback {
fun onDataReady(data: ArrayList&lt;Repository&gt;)
}

بنابراین، با جداسازی دیتاسورس‌ها به راحتی می‌توانیم داده‌ها را به صورت محلی ذخیره کنیم.

چه می‌شد اگر نیازمند دریافت داده‌ها از شبکه بودیم، آیا باز هم به الگوی Repository نیازی داشتیم؟ بله. این الگو باعث سهولت تست کدها خواهد شد، سایر توسعه‌دهندگان می‌توانند کدهای شما را بهتر درک و مدیریت کنند.

Android Manager Wrappers

برای بررسی اتصال اینترنت در GitRepoRepository باید چه کار کنیم؟ ما قبلاً گفتیم که هیچ کدی مرتبط با اندروید نباید در ViewModel و Model وجود داشته باشد، پس چگونه این مشکل را حل کنیم؟

اجازه دهید ابتدا یک Wrapper برای چک کردن اتصال اینترنت بنویسیم:

class NetManager(private var applicationContext: Context) {
private var status: Boolean? = false

val isConnectedToInternet: Boolean?
get() {
val conManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val ni = conManager.activeNetworkInfo
return ni != null && ni.isConnected
}
}

این کد تنها در صورتی کار می‌کند که پرمیژن‌های مورد نیاز را به مانیفست اضافه کرده باشید:

<uses-permission android:name=”android.permission.INTERNET” />
<uses-permission android:name=”android.permission.ACCESS_NETWORK_STATE” />
<uses-permission android:name=”android.permission.ACCESS_WIFI_STATE” />

اما چگونه در Repository نمونه‌سازی کنیم درحالی که به context دسترسی نداریم؟ context را می‌توانید از طریق سازنده دریافت کنید:

class GitRepoRepository (context: Context){

val localDataSource = GitRepoLocalDataSource()
val remoteDataSource = GitRepoRemoteDataSource()
val netManager = NetManager(context)

fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
remoteDataSource.getRepositories(object : OnRepoRemoteReadyCallback {
override fun onDataReady(data: ArrayList&lt;Repository&gt;) {
localDataSource.saveRepositories(data)
onRepositoryReadyCallback.onDataReady(data)
}

})
}
}

interface OnRepositoryReadyCallback {
fun onDataReady(data: ArrayList&lt;Repository&gt;)
}

ما این کار را قبل از ساخت نمونه‌ای از GitRepoRepository در ViewModel انجام دادیم. خب حالا چگونه به NetManager دسترسی داشته باشیم چون به context نیاز داریم؟ شما می‌توانید از context موجود در AndroidViewModel که در کتابخانه‌ی Lifecycle-aware componnents وجود دارد استفاده کنید. البته این context مربوط به اپلیکیشن است نه اکتیویتی:

class MainViewModel : AndroidViewModel {

constructor(application: Application) : super(application)

var gitRepoRepository: GitRepoRepository = GitRepoRepository(NetManager(getApplication()))

val text = ObservableField("old data")

val isLoading = ObservableField(false)

var repositories = MutableLiveData&lt;ArrayList&lt;Repository&gt;&gt;()

fun loadRepositories() {
isLoading.set(true)
gitRepoRepository.getRepositories(object : OnRepositoryReadyCallback {
override fun onDataReady(data: ArrayList&lt;Repository&gt;) {
isLoading.set(false)
repositories.value = data
}
})
}
}

در این خط:

constructor(application: Application) : super(application)

ما سازنده‌ای برای MainViewModel ساخته‌ایم. به این سازنده نیاز داریم چون AndroidViewModel در سازنده‌ی خود به نمونه‌ای از application نیاز دارد. بنابراین در سازنده‌ی خود سوپر متدی فرخوانی کرده‌ایم تا سازنده‌ی AndroidViewModelای که کلاس ما آن را اکستند کرده فرخوانی کند.

توجه: با انجام این کار می‌توانیم یک خط کد صرفه‌جویی کنیم:

class MainViewModel(application: Application) : AndroidViewModel(application) {
...
}

خب حالا که نمونه‌ای از NetManager در GitRepoRepository در دسترس ماست می‌توانیم اتصال به اینترنت را بررسی کنیم:

class GitRepoRepository(val netManager: NetManager) {

val localDataSource = GitRepoLocalDataSource()
val remoteDataSource = GitRepoRemoteDataSource()

fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {

netManager.isConnectedToInternet?.let {
if (it) {
remoteDataSource.getRepositories(object : OnRepoRemoteReadyCallback {
override fun onRemoteDataReady(data: ArrayList&lt;Repository&gt;) {
localDataSource.saveRepositories(data)
onRepositoryReadyCallback.onDataReady(data)
}
})
} else {
localDataSource.getRepositories(object : OnRepoLocalReadyCallback {
override fun onLocalDataReady(data: ArrayList&lt;Repository&gt;) {
onRepositoryReadyCallback.onDataReady(data)
}
})
}
}
}
}

interface OnRepositoryReadyCallback {
fun onDataReady(data: ArrayList&lt;Repository&gt;)
}

بنابراین اگر اتصال اینترنت برقرار باشد داده‌ها را از سرور دریافت کرده و به صورت محلی ذخیره می‌کنیم و در غیر اینصورت داده‌ها را از دیتابیس محلی دریافت خواهیم کرد.

نکته‌ای درباره‌ی کاتلین: عملگر let امکان تهی بودن یا nullability را بررسی کرده و مقدار داخل it را برمی‌گرداند.

در بخش‌های بعدی درباره‌ی تزریق وابستگی (dependency injection) توضیحاتی می‌دهم، خواهم گفت چرا ساخت نمونه از repository موجود در ViewModel کار درستی نیست و چگونه از AndroidViewModel پرهیز کنیم. همچنین درباره‌ی مشکلات کد صحبت‌هایی خواهم داشت. این بحث‌ها را برای سایر مقالات گذاشته‌ام تا مشکلات را به خوبی درک کرده و  دلیل شهرت کتابخانه‌ها را بفهمید.

من نظرم درمورد mappers کاملاً عوض شده، در بخش سوم درموردش بیشتر صحبت می‌کنم.

سید ایوب کوکبی

نویسنده و مترجم...

0 دیدگاه

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *