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
اگر سؤال، بازخورد یا ایده ای در مورد چگونگی بهبود این رویکرد دارید، در صورت تمایل با ما تماس بگیرید!