برنامه نویسی

Hotwire Native – Switch Environments

یکی از مواردی که مدتی است در برنامه های Hotwire Native خود می خواستم، توانایی تغییر محیط ها (صحنه، تولید و غیره) بدون نیاز به بازسازی برنامه است.

این می تواند برای مواردی مانند:

توجه: من به جزئیات نحوه ایجاد یک برنامه Hotwire Native نمی پردازم.

منابع بسیار خوبی برای آن وجود دارد، مانند وبلاگ جو مازیلوتی یا ویلیام کندی.

یا اخیراً، کتاب بسیار جذاب Hotwire Native اثر جو مازیلوتی.

وب

ایده اصلی این است که لیستی از محیط ها را با URL های مربوطه نمایش دهید.

هنگامی که کاربر محیطی را انتخاب می کند، URL انتخاب شده را از طریق یک JS Bridge Component به برنامه Hotwire Native ارسال می کنیم.

data-controller="native--base-url">

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

توجه: در یک سناریوی واقعی، ممکن است بخواهید URL های ممکن را در جایی ذخیره کنید، روی آنها تکرار کنید و مشخص کنید کدام یک در حال حاضر انتخاب شده است. اما برای سادگی، فعلا این کار انجام خواهد شد.

جزء پل:

import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

// Connects to data-controller="native--base-url"
export default class extends BridgeComponent {
  static component = "base-url"

  updateBaseURL({ params: { url } }) {
    this.send("updateBaseURL", { url })
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در سمت Hotwire Native، باید پیام JS Bridge را مدیریت کنیم و URL انتخاب شده را ذخیره کنیم تا بتوانیم از آن به عنوان URL اصلی برای درخواست‌های آینده استفاده کنیم. برای ذخیره URL در اندروید از SharedPreferences و برای iOS می توانیم از UserDefaults استفاده کنیم.

بیایید نگاهی به کد هر دو پلتفرم بیندازیم.

Hotwire Native Android

ابتدا مقداری آماده سازی، ما به راهی برای ذخیره URL ارسال شده بین جلسات برنامه نیاز داریم.

در اینجا یک کلاس ساده برای دسترسی به SharedPreferences ممکن است به نظر برسد:

SharedPreferencesAccess.kt:

object SharedPreferencesAccess {
  const val SHARED_PREFERENCES_FILE_KEY = "MobileAppData"
  const val BASE_URL_KEY = "BaseURL"

  fun setBaseURL(context: Context, baseURL: String) {
    val editor = getPreferences(context).edit()
    editor.putString(BASE_URL_KEY, baseURL)
    editor.apply()
  }

  fun getBaseURL(context: Context?): String {
    return getPreferences(context!!).getString(BASE_URL_KEY, "") ?: ""
  }

  private fun getPreferences(context: Context): SharedPreferences {
    return context.getSharedPreferences(SHARED_PREFERENCES_FILE_KEY, Context.MODE_PRIVATE)
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

پس از آن، ما به روشی نیاز داریم تا URL های خود را بر اساس محیط انتخاب شده و اینکه آیا URL ذخیره شده داریم یا خیر، بسازیم.

من از یک viewmodel به نام EndpointModel برای آن استفاده می کنم:

class EndpointModel(application: Application):AndroidViewModel(application) {
    private var baseURL: String

    init {
        this.baseURL = loadBaseURL()
    }

    fun setBaseURL(url: String) {
        this.baseURL = url
    }

    private fun loadBaseURL(): String {
        val savedURL = SharedPreferencesAccess.getBaseURL(getApplication<Application>().applicationContext)

        // Here is the basic idea of this article.   
        // If we have a saved URL, we use it. 
        if (savedURL.isNotEmpty()) {
            return savedURL
        }

        // Otherwise we use the default URL based on the build type.   
        if (BuildConfig.DEBUG) {
            return LOCAL_URL
        }
        return PRODUCTION_URL
    }

    val startURL: String
        get() { return "$baseURL/home" }

    val pathConfigurationURL: String
        get() {return "$baseURL/api/v1/android/path_configuration.json"}
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

ثابت های استفاده شده در یک فایل جداگانه به نام Constants.kt تعریف شده اند.

Constants.kt:

const val PRODUCTION_URL = "https://myapp.com"
const val LOCAL_URL = "http://192.168.1.42:3000"
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

خوب، تا اینجا خیلی خوب است.

ما URL انتخاب شده را ذخیره کرده ایم و راهی برای ایجاد URL های خود بر اساس اینکه آیا یک URL ذخیره شده داریم یا خیر داریم.

اکنون فقط باید به Hotwire Native بگوییم که از URL انتخاب شده به عنوان محل شروع استفاده کند.

به طور پیش فرض، Hotwire Native انتظار دارد a startLocation در MainActivity تعریف شود.

برای دسترسی به endpointModel، ابتدا باید آن را مقداردهی اولیه کنیم. این را می توان در کلاس “MainApplication” ما انجام داد (در پروژه Hotwire Native Demo، این کلاس نامیده می شود DemoApplication\):

class MainApplication : Application() {
    val endpointModel: EndpointModel by lazy {
        ViewModelProvider.AndroidViewModelFactory.getInstance(this)
            .create(EndpointModel::class.java)
    }

    override fun onCreate() {
        super.onCreate()

        // Load the path configuration
        Hotwire.loadPathConfiguration(
            context = this,
            location = PathConfiguration.Location(
                assetFilePath = "json/configuration.json",
                remoteFileUrl = endpointModel.pathConfigurationURL
            )
        )
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با وجود endpointModel، می‌توانیم از آن برای تعریف کردن استفاده کنیم startLocation در MainActivity:

class MainActivity : HotwireActivity() {
    lateinit var endpointModel: EndpointModel

    override fun onCreate(savedInstanceState: Bundle?) {
        this.endpointModel = (application as MainApplication).endpointModel

        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun navigatorConfigurations() = listOf(
        NavigatorConfiguration(
            name = "main",
            startLocation = endpointModel.startURL,
            navigatorHostId = R.id.main_nav_host
        )
    )
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اکنون ما تمام قطعات را برای جابه‌جایی پویا بین محیط‌های مختلف در برنامه Hotwire Native آماده کرده‌ایم.

تنها چیزی که از دست رفته است، مؤلفه پل است که پیام را از وب مدیریت می کند و URL پایه را به روز می کند:

class BaseURLComponent(
    name: String,
    private val hotwireDelegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, hotwireDelegate) {

    private val fragment: Fragment
        get() = hotwireDelegate.destination.fragment

    override fun onReceive(message: Message) {
        when (message.event) {
            "updateBaseURL" -> updateBaseURL(message)
            else -> Log.w("BaseURLComponent", "Unknown event for message: $message")
        }
    }

    private fun updateBaseURL(message: Message) {
        val data = message.data<MessageData>() ?: return
        val url = data.url

        // Save the new base URL to SharedPreferences
        SharedPreferencesAccess.setBaseURL(fragment.requireContext(), url)

        // Apply the new base URL and reset the navigators
        val mainActivity = fragment.activity as? MainActivity
        mainActivity?.endpointModel?.setBaseURL(url)
        mainActivity?.delegate?.resetNavigators()
    }

    @Serializable
    data class MessageData(
        @SerialName("url") val url: String
    )
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

انجام شد 🎉

این باید تمام مراحل مورد نیاز برای جابه‌جایی بین محیط‌های مختلف در یک برنامه Hotwire Native Android را مشخص کند.

اما یک شکار وجود دارد. هنگامی که این کار را امتحان می کنید، ممکن است متوجه شوید که سوئیچ محیط تنها زمانی کار می کند که برنامه را مجدداً راه اندازی کنید.

مشکل این است که Hotwire Native URL پایه جدید را به عنوان “پیمایش درون برنامه ای” نمی شناسد و URL ها را با URL پایه جدید در یک مرورگر خارجی باز می کند.

این به این دلیل است که AppNavigationRouteDecisionHandler از URL پایه جدید اطلاعی ندارد. صادقانه بگویم مطمئن نیستم که آیا این رفتار مورد انتظار است یا یک اشکال در Hotwire Native Android. اما ما به راحتی می توانیم با اضافه کردن یک سفارشی این مشکل را برطرف کنیم Router.RouteDecisionHandler. می‌توانید در اسناد رسمی درباره کنترل‌کننده‌های مسیر اطلاعات بیشتری کسب کنید.

class NavigationDecisionHandler : Router.RouteDecisionHandler {
    override val name = "app-navigation-router"

    override val decision = Router.Decision.NAVIGATE

    override fun matches(
        location: String,
        configuration: NavigatorConfiguration
    ): Boolean {
        val baseURL = MainApplication().endpointModel.homeURL
        return baseURL.toUri().host == location.toUri().host
    }

    override fun handle(
        location: String,
        configuration: NavigatorConfiguration,
        activity: HotwireActivity
    ) {
        // No-op
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

تنها موردی که اکنون گم شده است، اطلاع رسانی به Hotwire Native در مورد روتر جدید و ثبت آن همراه با روترهای پیش فرضی است که می خواهیم استفاده کنیم. این را می توان در فایل MainApplication.kt انجام داد، جایی که ما دیگر تنظیمات Hotwire Native را نیز داریم:

// Register route decision handlers
// https://native.hotwired.dev/android/reference#handling-url-routes
Hotwire.registerRouteDecisionHandlers(
    NavigationDecisionHandler(),
    AppNavigationRouteDecisionHandler(),
    BrowserRouteDecisionHandler()
)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

و حالا کارمون تموم شد🎉🎉.

این بار واقعی

من قول می دهم سمت iOS کمی ساده تر است.

Hotwire Native iOS

ایده اصلی یکسان است: URL انتخاب شده را ذخیره کنید و از آن به عنوان URL اصلی برای درخواست های آینده استفاده کنید. یک کلاس اصلی UserDefaultsAccess می تواند به شکل زیر باشد:

UserDefaultsAccess:

import Foundation

class UserDefaultsAccess {
  static let KEY_BASE_URL = "BaseURL"

  private init(){}

  static func setBaseURL(url: String) {
    UserDefaults.standard.set(url, forKey: KEY_BASE_URL)
  }

  static func getBaseURL() -> String {
    return UserDefaults.standard.string(forKey: KEY_BASE_URL) ?? ""
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اکنون ما به راهی نیاز داریم که URL های خود را بر اساس محیط انتخاب شده و اینکه آیا URL ذخیره شده ای داریم یا خیر، بسازیم.

مشابه سمت اندروید، کلاسی به نام Endpoint ایجاد کردم که حاوی این منطق است.

نقطه پایان:

// Learn more about this Endpoint class at this Joe Masilotti's Blog post: https://masilotti.com/turbo-ios/tips-and-tricks/
import Foundation

class Endpoint {
    static let instance = Endpoint()

    private var baseURL: URL {
        let baseURL = UserDefaultsAccess.getBaseURL()

        // Same as in Android.
        // If we have a saved URL, we use it.
        if !baseURL.isEmpty {
            return URL(string: baseURL)!
        }

        // Otherwise we use the default URL based on the current environment.
        switch Environment.current {
        case .development:
            return URL(string: "http://192.168.1.42:3000")!
        case .production:
            return URL(string: "https://myapp.com")!
        }
    }

    private init() {}

    var start: URL {
        return baseURL.appendingPathComponent("/home")
    }

    var pathConfiguration: URL {
        return baseURL.appendingPathComponent("/api/v1/ios/path_configuration.json")
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

هر کجا که درخواست اولیه را ارائه دهید:

func didStart() {
  navigator.route(Endpoint.instance.start)
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با وجود آن، ما می توانیم Bridge Component را پیاده سازی کنیم که پیام را از وب مدیریت می کند و URL پایه را به روز می کند.

import Foundation
import HotwireNative
import UIKit

final class BaseURLComponent: BridgeComponent {
    override class var name: String { "base-url" }

    override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }

        switch event {
        case .updateBaseURL:
            handleupdateBaseURL(message: message)
        }
    }

    // MARK: Private

    private func handleupdateBaseURL(message: Message) {
        guard let data: MessageData = message.data() else { return }
        let url = data.url
        UserDefaultsAccess.setBaseURL(url: url)
        HotwireCentral.instance.resetNavigator()
    }
}

// MARK: Events

private extension BaseURLComponent {
    enum Event: String {
        case updateBaseURL
    }
}

// MARK: Message data

private extension BaseURLComponent {
    struct MessageData: Decodable {
        let url: String
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

تقریباً تمام شده است. جزء پل تماس می گیرد resetNavigator برای اعمال URL پایه جدید و بازنشانی ناوبرها. کار از resetNavigator این است که ویژگی های PathConfiguration جدید را بر اساس URL پایه جدید تنظیم کنید و برای اعمال تنظیمات جدید، Navigator را مجدداً راه اندازی کنید.

این تابع هنوز وجود ندارد، بنابراین باید آن را به کلاس HotwireCentral اضافه کنیم:

func resetNavigator() {
    self.pathConfiguration = PathConfiguration(sources: [
        .server(Endpoint.instance.pathConfiguration)
    ])
    self.navigator = Navigator(pathConfiguration: pathConfiguration)
    navigator.route(Endpoint.instance.start)
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

همین! 🎉

به این ترتیب می توانید به راحتی بین محیط های مختلف در برنامه Hotwire Native خود بدون نیاز به بازسازی برنامه جابجا شوید.

می توانید کد کامل این پست را در این روابط عمومی پیدا کنید: https://github.com/leonvogt/example-42/pull/1

اگر سؤال، بازخورد یا ایده ای در مورد چگونگی بهبود این رویکرد دارید، در صورت تمایل با ما تماس بگیرید!

نوشته های مشابه

دیدگاهتان را بنویسید

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

دکمه بازگشت به بالا