得物技術登入元件重構

得物技術發表於2022-03-30

1.歷史背景

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

2. 如何改造

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

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

2.1 模組化重構

目標

  1. 高複用
  2. 易擴充套件
  3. 維護簡單
  4. 邏輯清晰,執行穩定

設計

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

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

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

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

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

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

實現

定義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 {

    companion object {
        private const val MMKV_LOGIN_KEY = "mmkv_key_****"
    }

    private lateinit var delegate: ILoginScope

    protected val localCache: MMKV by lazy {
        MMKV.mmkvWithID(MMKV_LOGIN_KEY, MMKV.MULTI_PROCESS_MODE)
    }

    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
    }
}

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

根據登入業務梳理分析,元件的登入執行時環境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

    override fun toast(message: String?) {
        // todo toast
    }

    override fun showLoading(message: String?) {
        // todo showLoading
    }

    override fun hideLoading() {
        // todo hideLoading
    }

    override fun close() {
        activity?.finish() ?: requireNotNull(fragment).also {
            if (it is IBottomAnim) {
                it.activity?.onBackPressedDispatcher?.onBackPressed()
                return
            }
            if (it is DialogFragment) {
                it.dismiss()
            }
            it.activity?.finish()
        }
    }

    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

    }
}

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

下面是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 unregisterLoginComponent(loginScope: ILoginScope) {
        lifecycleObserverMap.remove(System.identityHashCode(loginScope))
        componentArrays = componentArrays.mapNotNull {
            if (it.loginScope === loginScope) {
                null
            } else {
                it
            }
        }.toTypedArray()
    }

    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?
        }
    }

    override fun <T : ILoginComponent, R> callWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R? {
        return tryGet(key)?.run(block)
    }

    override fun <T : ILoginComponent> get(key: ILoginComponent.Key<T>): T {
        return tryGet(key) ?: throw IllegalStateException("找不到指定的ILoginComponent:$key")
    }

    override fun <T : ILoginComponent, R> requireCallWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R {
        return callWithComponent(key, block) ?: throw IllegalStateException("找不到指定的ILoginComponent:$key")
    }

    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) {
            this.event = event
            when (event) {
                Lifecycle.Event.ON_CREATE -> {
                    dispatch(loginScope) { onCreate() }
                }
                Lifecycle.Event.ON_START -> {
                    dispatch(loginScope) { onStart() }
                }
                Lifecycle.Event.ON_RESUME -> {
                    dispatch(loginScope) { onResume() }
                }
                Lifecycle.Event.ON_PAUSE -> {
                    dispatch(loginScope) { onPause() }
                }
                Lifecycle.Event.ON_STOP -> {
                    dispatch(loginScope) { onStop() }
                }
                Lifecycle.Event.ON_DESTROY -> {
                    dispatch(loginScope) { onDestroy() }
                    loginScope.loginLifecycleOwner.lifecycle.removeObserver(this)
                    unregisterLoginComponent(loginScope)
                }
                else -> throw IllegalArgumentException("ON_ANY must not been send by anybody")
            }
        }
    }

}

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

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中。此次獨立登入業務將根據現有業務重新設計新的登入介面,更加清晰明瞭利於維護。

目標

  1. 介面設計職責明確
  2. 登入資訊動態配置
  3. 登入路由頁面降級能力
  4. 登入流程全程可感可知
  5. 多程式支援
  6. 登入引擎ab切換

設計

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?
)

實現

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

程式安全和執行緒安全

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

這裡利用了四大元件之一的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的兜底操作。

跨程式分發事件

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

ab方案

因此次重構和獨立元件化改動較大,所以設計一套可靠的ab方案是很有必要的。為了讓ab方案更加簡單可控,此次模組化程式碼只存在於新的登入元件中,原有的du_account的程式碼不變。ab中的a就執行原有的du_account中的程式碼,b則執行du_login中的程式碼,另外還要確保在一次完整的app生命週期內,ab的值不會發生變化,因為如果發生變化,程式碼就會變得不可控制。因ab值需要依賴服務端下發,而登入有一些初始化的工作是在application初始化的過程,為了使得線上裝置儘可能的按照下發的ab實驗配置執行程式碼,所以對初始化操作進行了一個延後。主要策略就是,當application啟動的時候不好立刻開始初始化,會先執行一個3s超時的定時器,如果在超時之前獲取到ab下發值,則立刻初始化。如果超時後還沒有獲取到下發的ab配置,則立刻初始化,預設為a配置。如果在超時等待期間有任何登入程式碼被呼叫,則會立即先初始化。

使用

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)
        }
    }
}
if (LoginABTestHelper.INSTANCE.getAbApplyLoginModule()) {
    LoginBuilder builder = new LoginBuilder();
    builder.withTitle(LoginHelper.LoginTipsType.TYPE_NEW_USER_RED_PACKET.getType());
    if (LoginHelper.abWechatOneKey) {
        builder.withStyle(LoginStyle.HALF_RED_TECH, LoginStyle.HALF_WECHAT);
    } else {
        builder.withStyle(LoginStyle.HALF_RED_TECH);
    }
    builder.addFlag(LoginBuilder.FLAG_FORCE_PRE_VERIFY_IF_NULL);
    Bundle bundle = new Bundle();
    bundle.putString("url", imageUrl);
    bundle.putInt("popType", data.popType);
    builder.withExtra(bundle);
    builder.withHook(() -> fragmentManager.isResumed() && !fragmentManager.isHidden());
    
    final String tag = ServiceManager.getLoginModuleService().showLoginPage(context, builder);
    LiveData<LoginEvent> liveData = ServiceManager.getLoginModuleService().loginEventLiveData();
    liveData.removeObservers(fragmentManager);
    liveData.observe(fragmentManager, loginEvent -> {
        if (!TextUtils.equals(tag, loginEvent.getKey())) {
            return;
        }
        if (loginEvent.getType() == -1) {
            //利益點彈窗彈出失敗的話,彈新人彈窗
            afterLoginFailedPop(fragmentManager, data, dialogDismissListener);
        } else if (loginEvent.getType() == 2) {
            if (TextUtils.isEmpty(finalRouterUrl)) return;
            Navigator.getInstance().build(finalRouterUrl).navigation(context);
        }
        if (loginEvent.isEndEvent()) {
            liveData.removeObservers(fragmentManager);
        }
    });
}

開發中遇到的坑點

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

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

2、還有一個就是view onRestoreInstanceState的時機

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

文/Dylan

關注得物技術,做最潮技術人!

相關文章