得物登入元件重構

語言: CN / TW / HK

1.歷史背景

登入模組對於一個App來說是十分重要的,其中穩定性和使用者流暢體驗更是重中之重,直接關乎到App使用者的增長和留存。接手得物登入模組以後,我陸續發現了一些其中存在的問題,會導致迭代效率變低,穩定性也不能得到很好的保障。所以此次我將針對以上的問題,對登入模組進行升級改造。

2. 如何改造

通過梳理登入模組程式碼,發現的第一個問題就是登入頁面種類樣式比較多,但不同樣式的登入頁面的核心邏輯是基本類似的。但現有的程式碼做法是通過拷貝複製的方式,生成了一些不一樣的頁面,再分別做額外的差別處理。這種實現方式可能就只有一個優點,就是比較簡單速度比較快,其餘的應該都是缺點,特別是對於得物App來說,經常會有登入相關的迭代需求。

          

對於上述問題,該如何解決呢?通過分析發現,各不同型別的登入頁面,不管是從功能還是UI設計上還是比較統一的,每個頁面都可以分成若干個登入小元件,通過不同的小元件排列組合可以就是一個樣式的登入頁面了。因此我決定把登入頁面中按照功能劃分,把它拆分成一個個登入小元件,然後通過組合的方式去實現不同型別的登入頁面,這樣可以極大的元件的複用性,後續迭代也可以通過更多組合快速開發一個新的頁面。這就是下面所要講的模組化重構的由來。

2.1 模組化重構

2.1.1 目標

  1. 高複用

  2. 易擴充套件

  3. 維護簡單

  4. 邏輯清晰,執行穩定

2.1.2 設計

為了實現上述目標,首先需要抽象出登入元件的概念component,實現一個component就代表一個登入小元件,它具備完整的功能。比如它可以是一個登入按鈕,可以控制這個按鈕的外觀,點選事件,可點選狀態等等。一個component如下:

其中key是這個元件的標識,代表這個元件的標識,主要用於元件間通訊。

loginScope是元件的一個執行時環境,通過loginScope可以管理頁面,獲取一些頁面的公共配置,以及元件間的互動。lifecycle生命週期相關,由loginScope提供。cache是快取相關。track為埋點相關,一般都是點選埋點。

loginScope提供componentStore,component通過組合的方式註冊到componentStore統一管理。

componentStore通過key可以獲取到對應的component元件,從而實現通訊。

容器是所有component元件的宿主,也就是一個個頁面,一般為activity和fragment,當然也可以是自定義。

2.1.3 實現

定義ILoginComponent:

interface ILoginComponent : FullLifecycleObserver, ActivityResultCallback {


val key: Key<*>


val loginScope: ILoginScope


interface Key<E : ILoginComponent>
}

封裝一個抽象的父元件,實現了預設的生命週期,需要一個key去標識這個元件,可以處理onActivityResult事件,並提供了一個預設的防抖view點選方法:

open class AbstractLoginComponent(
override val key: ILoginComponent.Key<*>
) : ILoginComponent {


private lateinit var delegate: ILoginScope


override val loginScope: ILoginScope
get() = delegate


fun registerComponent(delegate: ILoginScope) {
this.delegate = delegate
loginScope.loginModelStore.registerLoginComponent(this)
}


override fun onCreate() {
}


...
override fun onDestroy() {
}


override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
}
}

下面是一個簡單的元件實現,這是一個標題元件:

class LoginBannerComponent(
private val titleText: TextView
) : AbstractLoginComponent(LoginBannerComponent) {


companion object Key : ILoginComponent.Key<LoginBannerComponent>


override fun onCreate() {
titleText.isVisible = true
titleText.text = loginScope.param.title
}
}

c omponent元件通常情況下並不關心檢視長什麼樣,核心是處理元件的業務邏輯和互動。

根據登入業務梳理分析,元件的登入執行時環境LoginRuntime,可以定義成如下這樣:

interface ILoginScope {
val loginModelStore: ILoginComponentModel
val loginHost: Any
val loginContext: Context?
var isEnable: Boolean
val param: LoginParam
val loginLifecycleOwner: LifecycleOwner
fun toast(message: String?)
fun showLoading(message: String? = null)
fun hideLoading()
fun close()
}

這是一個場景的以activity或者fragment為宿主的元件執行時環境:

class LoginScopeImpl : ILoginScope {
private var activity: AppCompatActivity? = null


private var fragment: Fragment? = null


override val loginModelStore: ILoginComponentModel


override val loginHost: Any
get() = activity ?: requireNotNull(fragment)


override val param: LoginParam


constructor(owner: ILoginComponentModelOwner, activity: AppCompatActivity, param: LoginParam) {
this.loginModelStore = owner.loginModelStore
this.param = param
this.activity = activity
}
constructor(owner: ILoginComponentModelOwner, fragment: Fragment, param: LoginParam) {
this.loginModelStore = owner.loginModelStore
this.param = param
this.fragment = fragment
}
override val loginContext: Context?
get() = activity ?: requireNotNull(fragment).context


override val loginLifecycleOwner: LifecycleOwner
get() = activity ?: SafeViewLifecycleOwner(requireNotNull(fragment))


override var isEnable: Boolean = true
}

這裡其實就是圍繞activity或者fragment的代理呼叫封裝,值得注意的是fragment我採用的是viewLifecyleOwner,保證了不會發生記憶體洩漏,又因為viewLifecyleOwner需要在特定生命週期獲取,否則會發生異常,這裡就利用包裝類的形式定義了一個安全的SafeViewLifecycleOwner。

private class SafeViewLifecycleOwner(fragment: Fragment) : LifecycleOwner {


private val mLifecycleRegistry = LifecycleRegistry(this)


init {
fun Fragment.innerSafeViewLifecycleOwner(block: (LifecycleOwner?) -> Unit) {
viewLifecycleOwnerLiveData.value?.also {
block(it)
} ?: run {
viewLifecycleOwnerLiveData.observeLifecycleForever(this) {
block(it)
}
}
}


fragment.innerSafeViewLifecycleOwner {
if (it == null) {
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
} else {
it.lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
mLifecycleRegistry.handleLifecycleEvent(event)
}
})
}
}
}
override fun getLifecycle(): Lifecycle = mLifecycleRegistry


}

下面是ILoginComponentModel介面,抽象了componentStore管理元件的方法 ,這裡主要定義了元件的管理方法,比如註冊繫結,解綁,獲取其他元件等等,主要用於元件間通訊互相呼叫。

interface ILoginComponentModel {


fun registerLoginComponent(component: ILoginComponent)


fun unregisterLoginComponent(loginScope: ILoginScope)


fun <T : ILoginComponent> tryGet(key: ILoginComponent.Key<T>): T?


fun <T : ILoginComponent, R> callWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R?


operator fun <T : ILoginComponent> get(key: ILoginComponent.Key<T>): T


fun <T : ILoginComponent, R> requireCallWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R
}

這是具體的實現類,這裡主要解決了viewModelStore儲存和管理viewmodel的思想,還有kotlin協程通過key去獲取CoroutineContext的思想去實現這個componentStore。

class LoginComponentModelStore : ILoginComponentModel {


private var componentArrays: Array<ILoginComponent> = emptyArray()


private val lifecycleObserverMap by lazy {
SparseArrayCompat<LoginScopeLifecycleObserver>()
}
fun initLoginComponent(loginScope: ILoginScope, vararg componentArrays: ILoginComponent) {
lifecycleObserverMap[System.identityHashCode(loginScope)]?.apply {
componentArrays.forEach {
initLoginComponentLifecycle(it)
}
}
}
override fun registerLoginComponent(component: ILoginComponent) {
component.loginScope.apply {
if (loginLifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) {
return
}
lifecycleObserverMap.putIfAbsentV2(System.identityHashCode(this)) {
LoginScopeLifecycleObserver(this).also {
loginLifecycleOwner.lifecycle.addObserver(it)
}
}.also {
componentArrays = componentArrays.plus(component)
it.initLoginComponentLifecycle(component)
}
}
}
override fun <T : ILoginComponent> tryGet(key: ILoginComponent.Key<T>): T? {
return componentArrays.find {
it.key === key && it.loginScope.isEnable
}?.let {
@Suppress("UNCHECKED_CAST")
it as? T?
}
}
private fun dispatch(loginScope: ILoginScope, block: ILoginComponent.() -> Unit) {
componentArrays.forEach {
if (it.loginScope === loginScope) {
it.block()
}
}
}
/**
* ILoginComponent生命週期分發
**/
private inner class LoginScopeLifecycleObserver(private val loginScope: ILoginScope) : LifecycleEventObserver {


private var event = Lifecycle.Event.ON_ANY


override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
...
dispatch(loginScope) { xxxx }
...
}
}
}

通過Array去儲存註冊進來的元件ILoginComponent,通過key可以遍歷查詢對應ILoginComponent元件,其中同一個key只能新增一個ILoginComponent,不能重複。再通過loginScope的loginLifecycleOwner監測host的生命週期變化,然後分發給各個ILoginComponent。

最後展現一個模組化重構後,使用組合的方式快速實現一個登入頁面:

internal class FullOneKeyLoginFragment : OneKeyLoginFragment() {


override val eventPage: String = LoginSensorUtil.PAGE_ONE_KEY_LOGIN_FULL


override fun layoutId() = R.layout.fragment_module_phone_onekey_login


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)


val btnClose = view.findViewById<ImageView>(R.id.btn_close)
val tvTitle = view.findViewById<TextView>(R.id.tv_title)
val thirdLayout = view.findViewById<ThirdLoginLayout>(R.id.third_layout)
val btnLogin = view.findViewById<View>(R.id.btn_login)
val btnOtherLogin = view.findViewById<TextView>(R.id.btn_other_login)
val cbPrivacy = view.findViewById<CheckBox>(R.id.cb_privacy)
val tvAgreement = view.findViewById<TextView>(R.id.tv_agreement)


loadLoginComponent(
loginScope,
LoginCloseComponent(btnClose),
LoginBannerComponent(tvTitle),
OneKeyLoginComponent(null, btnLogin, loginType),
LoginOtherStyleComponent(thirdLayout),
LoginOtherButtonComponent(btnOtherLogin),
loginPrivacyLinkComponent(btnLogin, cbPrivacy, tvAgreement)
)
}
}

一般情況下,只需要實現一個佈局xml檔案即可,如有特殊需求,也可以通過新增或者是繼承複寫元件實現。

2.2 登入單獨元件化

登入業務邏輯進行重構之後,下一個目標就是把登入業務從du_account剝離出來,單獨放在一個元件du_login中。此次獨立登入業務將根據現有業務重新設計新的登入介面,更加清晰明瞭利於維護。

2.2.1 目標

  1. 介面設計職責明確

  2. 登入資訊動態配置

  3. 登入路由頁面降級能力

  4. 登入流程全程可感可知

  5. 多程序支援

  6. 登入引擎ab切換

2.2.2 設計

ILoginModuleService介面設計,只暴露業務需要的方法。

interface ILoginModuleService : IProvider {


/**
* 是否登入
*/
fun isLogged(): Boolean


/**
* 開啟登入頁,一般kotlin使用
* @return 返回此次登入唯一標識
*/
@MainThread
fun showLoginPage(context: Context? = null, builder: (LoginBuilder.() -> Unit)? = null): String


/**
* 開啟登入頁,一般java使用
* @return 返回此次登入唯一標識
*/
@MainThread
fun showLoginPage(context: Context? = null, builder: LoginBuilder): String


/**
* 授權登入,一般人用不到
*/
fun oauthLogin(activity: Activity, authModel: OAuthModel, cancelIfUnLogin: Boolean)


/**
* 使用者登入狀態liveData,支援跨程序
*/
fun loginStatusLiveData(): LiveData<LoginStatus>


/**
* 登入事件liveData,支援跨程序
*/
fun loginEventLiveData(): LiveData<LoginEvent>
/**
* 退出登入
*/
fun logout()
}

登入引數配置:

class NewLoginConfig private constructor(
val styles: IntArray,
val title: String,
val from: String,
val tag: String,
val enterAnimId: Int,
val exitAnimId: Int,
val flag: Int,
val extra: Bundle?
)
  • 支援按優先順序順序配置多種樣式的登入頁面,路由失敗會自動降級;

  • 支援追溯登入來源,利於埋點;

  • 支援配置頁面開啟關閉動畫;

  • 支援配置自定義引數Bundle;

  • 支援跨程序觀察登入狀態變化;

internal sealed class LoginStatus {


object UnLogged : LoginStatus()


object Logging : LoginStatus()


object Logged : LoginStatus()
}

支援跨程序感知登入流程:

/**
* [type]
* -1 開啟登入頁失敗,不滿足條件
* 0 cancel
* 1 logging
* 2 logged
* 3 logout
* 4 open第一個登入頁
* 5 授權登入頁面開啟
*/
class LoginEvent constructor(
val type: Int,
val key: String,
val user: UsersModel?
)

2.2.3 實現

整個元件的核心是LoginServiceImpl, 它實現ILoginModuleService介面去管理整個登入流程。為了保證使用者體驗,登入頁面不會重複開啟,所以正確維護登入狀態特別重要。如何保證登入狀態的正確呢?除了保證正確的業務邏輯,保證執行緒安全和程序安全是至關重要的。

(1)程序安全和執行緒安全

如何實現保證程序安全和執行緒安全?

這裡利用了四大元件之一的Activity去實現,程序安全和執行緒安全。LoginHelperActivity是一個透明看不見的activity。

<activity
android:name=".LoginHelperActivity"
android:label=""
android:launchMode="singleInstance"
android:screenOrientation="portrait"
android:theme="@style/TranslucentStyle" />

LoginHelperActivity的主要就是利用它的執行緒安全程序安全的特性,去維護登入流程,防止重複開啟登入頁面,開啟執行完邏輯以後就立刻關閉。它的啟動模式是singleInstance,單獨存在一個任務棧,即開即關,在任何時候啟動都不會影響登入流程,還能很好解決跨程序和執行緒安全的問題。退出登入也是利用LoginHelperActivity去實現的,也是利用了執行緒安全跨程序的特性,保證狀態不會出錯。

internal companion object {
internal const val KEY_TYPE = "key_type"

internal fun login(context: Context, newConfig: NewLoginConfig) {
context.startActivity(Intent(context, LoginHelperActivity::class.java).also {
if (context !is Activity) {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
it.putExtra(KEY_TYPE, 0)
it.putExtra(NewLoginConfig.KEY, newConfig)
})
}

internal fun logout(context: Context) {
context.startActivity(Intent(context, LoginHelperActivity::class.java).also {
if (context !is Activity) {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
it.putExtra(KEY_TYPE, 1)
})
}
}




override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isFinishing) {
return
}
try {
if (intent?.getIntExtra(KEY_TYPE, 0) == 0) {
tryOpenLoginPage()
} else {
loginImpl.logout()
}
} catch (e: Exception) {

} finally {
finish()
}
}

登入邏輯開啟的也是一個輔助的LoginEntryActivity,也是一個透明看不見的,它的啟動模式是singleTask的,它將作為所有登入流程的根Activity,會伴隨整個登入流程一直存在,特殊情況除外(比如不保留活動模式,程序被殺死,記憶體不足),LoginEntryActivity的銷燬代表著登入流程的結束(特殊情況除外)。在LoginEntryActivity的onResume生命週期才會路由到真正的登入頁面,為了防止意外情況發生,路由的同時會開啟一個超時檢測,防止真正的登入頁面無法開啟,導致一直停留在LoginEntryActivity介面導致介面無響應的問題。

<activity
android:name=".LoginEntryActivity"
android:label=""
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:theme="@style/TranslucentStyle" />


internal companion object {
private const val SAVE_STATE_KEY = "save_state_key"


internal fun login(activity: Activity, extra: Bundle?) {
activity.startActivity(Intent(activity, LoginEntryActivity::class.java).also {
if (extra != null) {
it.putExtras(extra)
}
})
}


/**
* 結束登入流程,一般用於登入成功
*/
internal fun finishLoginFlow(activity: LoginEntryActivity) {
activity.startActivity(Intent(activity, LoginEntryActivity::class.java).also {
it.putExtra(KEY_TYPE, 2)
})
}
}

通過registerActivityLifecycleCallbacks感知activity生命週期變化,用於觀察登入流程開始和結束,以及登入流程的異常退出。像是其他業務通過registerActivityLifecycleCallbacks獲取LoginEntryActivity後主動finish的行為,是會被感知到的,然後退出登入流程的。

登入流程的結束也是利用了singleTask的特性去銷燬所有的登入頁面,這裡還有一個小細節是為了防止如不保留活動的異常情況,LoginEntryActivity被提前銷燬,可能就沒辦法利用singleTask特性去銷燬其他頁面,所有還是有一個主動快取activity的兜底操作。

(2)跨程序分發事件

跨程序分發登入流程的狀態和事件是通過ArbitraryIPCEvent實現的,後續可能會考慮開放出來。主要原理圖如下:

(3)AB方案

因此次重構和獨立元件化改動較大,所以設計一套可靠的AB方案是很有必要的。為了讓AB方案更加簡單可控,此次模組化程式碼只存在於新的登入元件中,原有的du_account的程式碼不變。AB中的A就執行原有的du_account中的程式碼,B則執行du_login中的程式碼,另外還要確保在一次完整的App生命週期內,AB的值不會發生變化,因為如果發生變化,程式碼就會變得不可控制。

因AB值需要依賴服務端下發,而登入有一些初始化的工作是在application初始化的過程,為了使得線上裝置儘可能的按照下發的AB實驗配置執行程式碼,所以對初始化操作進行了一個延後。主要策略就是,當application啟動的時候不好立刻開始初始化,會先執行一個3s超時的定時器,如果在超時之前獲取到AB下發值,則立刻初始化。如果超時後還沒有獲取到下發的ab配置,則立刻初始化,預設為A配置。如果在超時等待期間有任何登入程式碼被呼叫,則會立即先初始化。

2.2.4 使用

下面是在需要喚起登入頁的地方,呼叫登入的一個例子。可以通過自由配置頁面的樣式,引數,降級策略,開啟各種登入頁面。

ServiceManager.getLoginModuleService().showLoginPage(activity) {
withStyle(*LoginBuilder.transformArrayByStyle(config))
withTitle(config.title)
withFrom(config.callFrom)
config.tag?.also {
withTag(it)
}
config.extra?.also {
if (it is Bundle) {
withExtra(it)
}
}
}

3.總結

此次登入重構改造之路比不是那麼順利的,其中也踩了許多坑,替換後也遇到了一些問題。 下是一些值得注意的地方:

  • 首先在重構之前,要充分考慮所有使用到登入的業務,確保相容現有所有業務,保證登入業務的穩定性。

  • 要針對目前迭代中出現的存在的問題,充分思考需要做出哪些改變?

  • 考慮登入業務可能迭代的方向,面向介面程式設計預留擴充套件介面,以防需求的頻繁變更。

  • 對於有跨程序的應用來說,要考慮程序安全和執行緒安全問題,需要保證在任何時候都能拿到最新的登入狀態。

  • 上線前做好AB方案,要做到兩份程式碼充分解耦,儘量不要改原登入業務程式碼。

遇到的坑點:

比較費時的應該是fragment頁面重建view id 的問題

在測試不保留活動的case時,發現頁面會變成空白,但是通過fragmentManger查詢到的結果都是正常的(isAdded = true, isHided = false, isAttached = true)。排查了半天,突然想到了id問題,fragment的宿主containerView的id是我動態生成的,我沒有使用xml寫佈局,是使用程式碼生成view的。

還有一個就是view onRestoreInstanceState的時機

這個問題也是在測試不保留活動case遇到的,按常理只要view設定了id,Android的原生控制元件都會保留之前的狀態,比如checkBox會保留勾選狀態。我在fragment頁面重建的onViewCreated方法中findViewById到了checkBox,但是通過isChecked獲取到的值一直是false的,我百思不得其解,原始碼也不要除錯。後來通過對自定義控制元件ThirdLoginLayout實現儲存狀態能力的時候,通過除錯發現onRestoreInstanceState回撥時機比較靠後,在onViewCreated的時候view還沒有把狀態恢復過來。

埋點問題,因為我為了程序和執行緒安全 ,在登入過程中有建立了不可見的透明activity,由於剛開始登入狀態校驗都放在activity中,導致每次呼叫登入方法,必定會開啟一個透明activity。這可能會影響上一個頁面的曝光埋點,所以登入狀態和前置條件檢測(比如一鍵登入是否預取號成功,微信登入是否安裝微信)不要放在透明activity中,並且做好狀態的程序和執行緒安全。

 *文 /Dylan

關注得物技術,每週一三五晚18:30更新技術乾貨

要是覺得文章對你有幫助的話,歡迎評論轉發點贊~

限時活動:

即日起至5月31日 ,公開轉發得物技術任意一篇文章到朋友圈,就可以在「得物技術」公眾號後臺回覆「得物」,參與得物文化衫抽獎。

活動推薦:

直播主題: Golang微服務專場-得物技術沙龍

會議時間: 2022年5月29日14:00 - 18:00

會議地點:線上

參會方式: 點選小程式自動跳轉可以免費報名

進群方式: 公眾號後臺回覆「Golang」可以進群交流和互動