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

خرید بک لینک

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

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

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

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

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

class ExampleView : RelativeLayout {

    // Inflate layout, initialise subviews, etc 

    fun updateContact() {
        val contact = viewModel.getContact()

        nameTextView.text = contact.name
        phoneTextView.text = contact.phoneNumber

        followButton.isSelected = contact.isFollowed
        followButton.text = if (followButton.isSelected) "UNFOLLOW" else "FOLLOW"

        Glide.with(context).load(Uri.parse(contact.icon)).into(iconImageView)
    }

    fun addContactToFavourites(view: View) {
        if (followButton.isSelected) {
            viewModel.removeFromFavourites()
        } else {
            viewModel.addToFavourites()
        }

        followButton.isSelected = !followButton.isSelected
        followButton.text = if (followButton.isSelected) "UNFOLLOW" else "FOLLOW"
    }
}

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

followButton.isSelected = contact.isFollowed

// etc ... 

if (followButton.isSelected) {
  viewModel.removeFromFavourites()
} else {
  viewModel.addToFavourites()
}

// etc ...

followButton.isSelected = !followButton.isSelected

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

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

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

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

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

class ContactView
@JvmOverloads
constructor(context: Context?, attrs: AttributeSet? = null) : RelativeLayout(context, attrs) {

    val nameTextView by lazy { findViewById<TextView>(R.id.contact_user_name) }

    val phoneTextView by lazy { findViewById<TextView>(R.id.contact_phone_number) }

    val iconImageView by lazy { findViewById<ImageView>(R.id.contact_image_icon) }

    val followButton by lazy { findViewById<Button>(R.id.contact_follow_button) }

    init {
        LayoutInflater.from(context).inflate(R.layout.view_contact, this, true)
    }
}

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

val newState = reducer(action, oldState)

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

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

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

class ContactView {
  
  // etc ...
  
  interface ViewState {
        val nameText: String
        val phoneNumberText: String
        val iconUri: Uri?
        val isFollowButtonSelected: Boolean
        val followButtonTitle: String
    }
}

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

class ContactView {
   
   // etc ...
  
   fun redraw(viewState: ViewState) = with(viewState) {
        nameTextView.text = nameText
        phoneTextView.text = phoneNumberText
        followButton.isSelected = isFollowButtonSelected
        followButton.text = followButtonTitle
        Glide.with(context).load(iconUri).into(iconImageView)
   }
}

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

@Test
fun contactViewIsRedrawnCorrectly() {
  // given
  val view = ContactView(....)
  val state = ContactViewStateImpl(
    nameText = "John Doe",
    phoneNumberText = "+00 123 45 67890",
    iconUri = "https://mock.api/image.png",
    isFollowButtonSelected = true,
    followButtonText = "UNFOLLOW")
  
  // when
  view.redraw(viewState = state)
  
  // then
  onView(withId(R.id.contact_user_name)).check(matches(withText("John Doe")))
  onView(withId(R.id.contact_phone_number)).check(matches(withText("+00 123 45 67890")))
  onView(withId(R.id.contact_image_icon)).check(matches(isDisplayed()))
  onView(withId(R.id.contact_follow_button)).check(matches(isSelected())).check(matches(withText("UNFOLLOW")))                                            
}

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

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

class ContactView {
  
  var interactor: Interactor? = null
  
  // etc
  
  interface Interactor {
    fun initialDataLoad()
    fun addOrRemoveFromFavourites()
  }
}

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

class ContactView {
   
   var interactor: Interactor? = null
    set(value) {
      field = value
      interactor?.initialDataLoad()
    }
   
   // etc ...
   
   val followButton by lazy {
    findViewById<Button>(R.id.contact_follow_button).apply {
      setOnClickListener { interactor?.addOrRemoveFromFavourites() }
    }
   }
   
   // etc ...
 }

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

@Test
fun interactionsWithContactViewToWorkAsExpected() {
  // given
  val view = ContactView(....)
  val interactor = Mockito.mock(ExampleView.Interactor)
  view.interactor = interactor
  
  // when
  onView(withId(R.id.contact_follow_button)).perform(click())
  
  // then
  Mockito.verify(interactor).initialDataLoad()
  Mockito.verify(interactor).addOrRemoveFromFavourites()
}

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

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

class ContactView
@JvmOverloads
constructor(context: Context?, attrs: AttributeSet? = null) : RelativeLayout(context, attrs) {

    var interactor: Interactor? = null
        set(value) {
            field = value
            interactor?.initialDataLoad()
        }

    val nameTextView by lazy { findViewById<TextView>(R.id.contact_user_name) }

    val phoneTextView by lazy { findViewById<TextView>(R.id.contact_phone_number) }

    val iconImageView by lazy { findViewById<ImageView>(R.id.contact_image_icon) }

    val followButton by lazy {
        findViewById<Button>(R.id.contact_follow_button).apply {
            setOnClickListener { interactor?.addOrRemoveFromFavourites() }
        }
    }

    init {
        LayoutInflater.from(context).inflate(R.layout.view_contact, this, true)
    }

    fun redraw(viewState: ViewState) = with(viewState) {
        nameTextView.text = nameText
        phoneTextView.text = phoneNumberText
        followButton.isSelected = isFollowButtonSelected
        followButton.text = followButtonTitle
        Glide.with(context).load(iconUri).into(iconImageView)
    }

    interface ViewState {
        val nameText: String
        val phoneNumberText: String
        val iconUri: Uri?
        val isFollowButtonSelected: Boolean
        val followButtonTitle: String
    }

    interface Interactor {
        fun initialDataLoad()
        fun addOrRemoveFromFavourites()
    }
}

سخن پایانی

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

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

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

==

telegram
twitter

میثم لطفی

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

نوشته تبدیل نماهای کاتلین به تابع حالت — از صفر تا صد اولین بار در مجله فرادرس. پدیدار شد.

مطالب درسی...

ما را در سایت مطالب درسی دنبال می‌کنید

برچسب: نویسنده: خنجی بازدید: 374 تاريخ: شنبه 30 فروردين 1399 ساعت: 21:04

صفحه بندی