تبدیل نماهای کاتلین به تابع حالت — از صفر تا صد

۸۰ بازدید
آخرین به‌روزرسانی: ۰۱ مهر ۱۴۰۲
زمان مطالعه: ۶ دقیقه
تبدیل نماهای کاتلین به تابع حالت — از صفر تا صد

یکی از جالب‌ترین تغییرهایی که در چند سال اخیر رخ داده است، ‌ظهور کتابخانه‌های مدیریت حالت مانند Redux ‌،Flux یا MobX است. بدین ترتیب مباحث خوبی در میان توسعه‌دهندگان در مورد شیوه نظم‌بخشی به روند رو به رشد پیچیدگی در نرم‌افزار ایجاد شده است. در این مقاله به بررسی روش تبدیل نماهای کاتلین به تابع حالت می‌پردازیم.

فهرست مطالب این نوشته

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

  • تنها یک «منبع واقعیت» وجود دارد که «حالت» (State) است.
  • حالت، «تغییرناپذیر» (immutable) است.
  • حالت جدید می‌تواند به وسیله تابع خاصی به نام reducer و در نتیجه نوعی اکشن قبلی ایجاد شود.

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

متأسفانه در اغلب موارد شاهد کدهایی مانند زیر در UI هستیم:

1class ExampleView : RelativeLayout {
2
3    // Inflate layout, initialise subviews, etc 
4
5    fun updateContact() {
6        val contact = viewModel.getContact()
7
8        nameTextView.text = contact.name
9        phoneTextView.text = contact.phoneNumber
10
11        followButton.isSelected = contact.isFollowed
12        followButton.text = if (followButton.isSelected) "UNFOLLOW" else "FOLLOW"
13
14        Glide.with(context).load(Uri.parse(contact.icon)).into(iconImageView)
15    }
16
17    fun addContactToFavourites(view: View) {
18        if (followButton.isSelected) {
19            viewModel.removeFromFavourites()
20        } else {
21            viewModel.addToFavourites()
22        }
23
24        followButton.isSelected = !followButton.isSelected
25        followButton.text = if (followButton.isSelected) "UNFOLLOW" else "FOLLOW"
26    }
27}

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

1followButton.isSelected = contact.isFollowed
2
3// etc ... 
4
5if (followButton.isSelected) {
6  viewModel.removeFromFavourites()
7} else {
8  viewModel.addToFavourites()
9}
10
11// etc ...
12
13followButton.isSelected = !followButton.isSelected

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

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

برای شروع یک ایده بصری از نمایی که می‌خواهیم طراحی کنیم ارائه می‌کنیم:

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

پیش از ادامه توضیحات، ابتدا باید کد نحوه نمایش این نما را بنویسیم:

1class ContactView
2@JvmOverloads
3constructor(context: Context?, attrs: AttributeSet? = null) : RelativeLayout(context, attrs) {
4
5    val nameTextView by lazy { findViewById<TextView>(R.id.contact_user_name) }
6
7    val phoneTextView by lazy { findViewById<TextView>(R.id.contact_phone_number) }
8
9    val iconImageView by lazy { findViewById<ImageView>(R.id.contact_image_icon) }
10
11    val followButton by lazy { findViewById<Button>(R.id.contact_follow_button) }
12
13    init {
14        LayoutInflater.from(context).inflate(R.layout.view_contact, this, true)
15    }
16}

چنان که پیش‌تر گفتیم، ‌یکی از ارکان اساسی Redux این است که حالت باید تغییرناپذیر باشد و تنها می‌تواند به وسیله «تابع‌های خالص» (Pure Function) به نام reducer تولید شود. شیوه کار به صورت زیر است:

1val newState = reducer(action, oldState)

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

  • یک منبع URI برای تصویر پروفایل مخاطب.
  • یک رشته برای نام کاربر.
  • یک رشته برای شماره تلفن.
  • یک منبع تصویر برای دکمه مخاطبین محبوب.

کد آن به صورت زیر است:

1class ContactView {
2  
3  // etc ...
4  
5  interface ViewState {
6        val nameText: String
7        val phoneNumberText: String
8        val iconUri: Uri?
9        val isFollowButtonSelected: Boolean
10        val followButtonTitle: String
11    }
12}

در مثال فوق، نما با استفاده از «واژگونی کنترل» (Inversion of Control) ‌به دنیای بیرون اعلام می‌کند که فکر می‌کند نما باید چگونه باشد. همچنین حالت به طور خاص برای نما ساخته شده است. حالت تنها شامل اطلاعاتی است که نما به طور مستقیم برای رندر مجدد خود نیاز دارد و نه چیز دیگر. در این وضعیت، کافی است یک متد برای رسم مجدد نما با استفاده از ViewState مفروض بنویسیم:

1class ContactView {
2   
3   // etc ...
4  
5   fun redraw(viewState: ViewState) = with(viewState) {
6        nameTextView.text = nameText
7        phoneTextView.text = phoneNumberText
8        followButton.isSelected = isFollowButtonSelected
9        followButton.text = followButtonTitle
10        Glide.with(context).load(iconUri).into(iconImageView)
11   }
12}

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

1@Test
2fun contactViewIsRedrawnCorrectly() {
3  // given
4  val view = ContactView(....)
5  val state = ContactViewStateImpl(
6    nameText = "John Doe",
7    phoneNumberText = "+00 123 45 67890",
8    iconUri = "https://mock.api/image.png",
9    isFollowButtonSelected = true,
10    followButtonText = "UNFOLLOW")
11  
12  // when
13  view.redraw(viewState = state)
14  
15  // then
16  onView(withId(R.id.contact_user_name)).check(matches(withText("John Doe")))
17  onView(withId(R.id.contact_phone_number)).check(matches(withText("+00 123 45 67890")))
18  onView(withId(R.id.contact_image_icon)).check(matches(isDisplayed()))
19  onView(withId(R.id.contact_follow_button)).check(matches(isSelected())).check(matches(withText("UNFOLLOW")))                                            
20}

در واقع، بخش بزرگی از منطق تجاری نیز که قبلاً در نما قرار داشت، از آن جدا شده است. این کار از سوی ViewState و به وسیله تبدیل برخی لایه‌های شیء داده به قالبی که برای نما مناسب باشد، صورت گرفته است. این مورد نیز را به نیز به صورت جداگانه تست یونیت می‌کنیم.

با این حال، در مثال اول می‌توانستیم بر اساس تغییرات متناظر در UI مخاطب را به فهرست افراد محبوب نیز اضافه کرده و یا از آن حذف کنیم. برای فعال‌سازی مجدد این کارکرد و حفظ سازوکار قطعی رسم مجدد، می‌توانیم یک اینترفیس جدید به نام Interactor اضافه کنیم:

1class ContactView {
2  
3  var interactor: Interactor? = null
4  
5  // etc
6  
7  interface Interactor {
8    fun initialDataLoad()
9    fun addOrRemoveFromFavourites()
10  }
11}

این سازوکار اساساً به نما امکان می‌دهد که به دنیای خارج (‌یعنی اکتیویتی‌ها، فرگمان‌ها، ویومد‌ل‌ها و غیره) اعلام کند که باید نوعی عملیات اجرا شود. در عمل کد به صورت زیر خواهد بود:

1 class ContactView {
2   
3   var interactor: Interactor? = null
4    set(value) {
5      field = value
6      interactor?.initialDataLoad()
7    }
8   
9   // etc ...
10   
11   val followButton by lazy {
12    findViewById<Button>(R.id.contact_follow_button).apply {
13      setOnClickListener { interactor?.addOrRemoveFromFavourites() }
14    }
15   }
16   
17   // etc ...
18 }

از این جا به بعد، نما این مسئولیت‌ها را به هر کلاسی که اینترفیس Interactor را پیاده‌سازی کند، واگذار خواهد کرد. همانند قبل، ‌تست کردن Interactor با استفاده از هر دو فریمورک Espresso و Mockito بسیار آسان خواهد بود:

1@Test
2fun interactionsWithContactViewToWorkAsExpected() {
3  // given
4  val view = ContactView(....)
5  val interactor = Mockito.mock(ExampleView.Interactor)
6  view.interactor = interactor
7  
8  // when
9  onView(withId(R.id.contact_follow_button)).perform(click())
10  
11  // then
12  Mockito.verify(interactor).initialDataLoad()
13  Mockito.verify(interactor).addOrRemoveFromFavourites()
14}

در نهایت یک ContactView ایجاد کرده‌ایم که به صورت تابعی از یک حالت رسم مجدد می‌شود. این کار از طریق گروه‌بندی همه عملیات رسم مجدد در یک متد و انتقال همه منطق به خارج از نما و به عنوان بخشی از یک Interactor میسر شده است.

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

1class ContactView
2@JvmOverloads
3constructor(context: Context?, attrs: AttributeSet? = null) : RelativeLayout(context, attrs) {
4
5    var interactor: Interactor? = null
6        set(value) {
7            field = value
8            interactor?.initialDataLoad()
9        }
10
11    val nameTextView by lazy { findViewById<TextView>(R.id.contact_user_name) }
12
13    val phoneTextView by lazy { findViewById<TextView>(R.id.contact_phone_number) }
14
15    val iconImageView by lazy { findViewById<ImageView>(R.id.contact_image_icon) }
16
17    val followButton by lazy {
18        findViewById<Button>(R.id.contact_follow_button).apply {
19            setOnClickListener { interactor?.addOrRemoveFromFavourites() }
20        }
21    }
22
23    init {
24        LayoutInflater.from(context).inflate(R.layout.view_contact, this, true)
25    }
26
27    fun redraw(viewState: ViewState) = with(viewState) {
28        nameTextView.text = nameText
29        phoneTextView.text = phoneNumberText
30        followButton.isSelected = isFollowButtonSelected
31        followButton.text = followButtonTitle
32        Glide.with(context).load(iconUri).into(iconImageView)
33    }
34
35    interface ViewState {
36        val nameText: String
37        val phoneNumberText: String
38        val iconUri: Uri?
39        val isFollowButtonSelected: Boolean
40        val followButtonTitle: String
41    }
42
43    interface Interactor {
44        fun initialDataLoad()
45        fun addOrRemoveFromFavourites()
46    }
47}

سخن پایانی

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

اگر لازم باشد نماهای فرعی مانند فیلدهای آدرس، مخاطبین کاری و غیره اضافه کنیم، ‌تنها کافی است چند خط کد را در متد رسم مجدد تغیر دهیم. به طور مشابه اگر لازم باشد قابلیت‌های نما را بسط دهیم، تنها باید چند متد دیگر به Interactor اضافه شود.

اگر مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
swlh
نظر شما چیست؟

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