Dewu Technology login component reconstruction

1. Historical background

The login module is very important for an app, in which stability and smooth user experience are the most important, which are directly related to the growth and retention of app users. After taking over the Dewu login module, I have found some problems in it one after another, which will lead to lower iteration efficiency and less stable stability. So this time I will upgrade the login module for the above problems.

2. How to remodel

By combing the login module code, the first problem found is that there are many types and styles of login pages, but the core logic of different styles of login pages is basically similar. However, the existing code practice is to generate some different pages by copying and copying, and then do additional differential processing respectively. This implementation method may have only one advantage, that is, it is simpler and faster, and the rest should be shortcomings, especially for Dewu App, there are often iterative requirements related to login.

How to solve the above problems? Through analysis, it is found that different types of login pages are relatively uniform in terms of function and ui design. Each page can be divided into several login widgets, which can be a style of login page through the arrangement and combination of different widgets. . Therefore, I decided to divide the login page according to its functions, split it into login widgets one by one, and then implement different types of login pages by combining them, which can greatly improve the reusability of components, and subsequent iterations can also be used. Quickly develop a new page with more combinations. This is the origin of the modular refactoring described below.

2.1 Modular reconstruction

Target

  1. High reuse
  2. Easy to expand
  3. Simple maintenance
  4. Clear logic and stable operation

design

In order to achieve the above goals, the concept component of the login component needs to be abstracted first. Implementing a component represents a login widget, which has complete functions. For example, it can be a login button, you can control the appearance of the button, click event, clickable state, etc. A component is as follows,

The key is the identity of the component, which represents the identity of the component and is mainly used for communication between components.

loginScope is a runtime environment for components. Through loginScope, you can manage pages, obtain common configuration of some pages, and interact with components. The lifecycle is related to the lifecycle and is provided by loginScope. cache is cache related. track is related to buried points, generally click buried points.

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

关注得物技术,做最潮技术人!

Guess you like

Origin juejin.im/post/7080728612391813133
Recommended