得物技术登录组件重构
1.历史背景
登录模块对于一个App来说是十分重要的,其中稳定性和用户流畅体验更是重中之重,直接关乎到App用户的增长和留存。接手得物登录模块以后,我陆续发现了一些其中存在的问题,会导致迭代效率变低,稳定性也不能得到很好的保障。所以此次我将针对以上的问题,对登录模块进行升级改造。
2. 如何改造
通过梳理登录模块代码,发现的第一个问题就是登录页面种类样式比较多,但不同样式的登录页面的核心逻辑是基本类似的。但现有的代码做法是通过拷贝复制的方式,生成了一些不一样的页面,再分别做额外的差别处理。这种实现方式可能就只有一个优点,就是比较简单速度比较快,其余的都应该是缺点,特别是对于得物App来说,经常会有登录相关的迭代需求。
文章图片
文章图片
对于上述问题,该如何解决呢?通过分析发现,各不同类型的登录页面,不管是从功能还是ui设计上还是比较统一的,每个页面都可以分成若干个登录小组件,通过不同的小组件排列组合可以就是一个样式的登录页面了。因此我决定把登录页面中按照功能划分,把它拆分成一个个登录小组件,然后通过组合的方式去实现不同类型的登录页面,这样可以极大的组件的复用性,后续迭代也可以通过更多组合快速开发一个新的页面。这就是下面所要讲的模块化重构的由来。
2.1 模块化重构
目标
- 高复用
- 易扩展
- 维护简单
- 逻辑清晰,运行稳定
文章图片
其中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: ILoginScopeinterface Key}
封装一个抽象的父组件,实现了默认的生命周期,需要一个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: ILoginScopeprotected val localCache: MMKV by lazy {
MMKV.mmkvWithID(MMKV_LOGIN_KEY, MMKV.MULTI_PROCESS_MODE)
}override val loginScope: ILoginScope
get() = delegatefun 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.Keyoverride fun onCreate() {
titleText.isVisible = true
titleText.text = loginScope.param.title
}
}
component组件通常情况下并不关心视图长什么样,核心是处理组件的业务逻辑和交互。
根据登录业务梳理分析,组件的登录运行时环境LoginRuntime,可以定义成如下这样
interface ILoginScope {val loginModelStore: ILoginComponentModelval loginHost: Anyval loginContext: Context?var isEnable: Booleanval param: LoginParamval loginLifecycleOwner: LifecycleOwnerfun toast(message: String?)fun showLoading(message: String? = null)fun hideLoading()fun close()}
这是一个场景的以activity或者fragment为宿主的组件运行时环境
class LoginScopeImpl : ILoginScope {private var activity: AppCompatActivity? = nullprivate var fragment: Fragment? = nulloverride val loginModelStore: ILoginComponentModeloverride val loginHost: Any
get() = activity ?: requireNotNull(fragment)override val param: LoginParamconstructor(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).contextoverride val loginLifecycleOwner: LifecycleOwner
get() = activity ?: SafeViewLifecycleOwner(requireNotNull(fragment))override var isEnable: Boolean = trueoverride 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 tryGet(key: ILoginComponent.Key): T?fun callWithComponent(key: ILoginComponent.Key, block: T.() -> R): R?operator fun get(key: ILoginComponent.Key): Tfun requireCallWithComponent(key: ILoginComponent.Key, block: T.() -> R): R
}
这是具体的实现类,这里主要解决了viewModelStore保存和管理viewmodel的思想,还有kotlin协程通过key去获取CoroutineContext的思想去实现这个componentStore,
class LoginComponentModelStore : ILoginComponentModel {private var componentArrays: Array = emptyArray()private val lifecycleObserverMap by lazy {
SparseArrayCompat()
}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 tryGet(key: ILoginComponent.Key): T? {
return componentArrays.find {
it.key === key && it.loginScope.isEnable
}?.let {
@Suppress("UNCHECKED_CAST")
it as? T?
}
}override fun callWithComponent(key: ILoginComponent.Key, block: T.() -> R): R? {
return tryGet(key)?.run(block)
}override fun get(key: ILoginComponent.Key): T {
return tryGet(key) ?: throw IllegalStateException("找不到指定的ILoginComponent:$key")
}override fun requireCallWithComponent(key: ILoginComponent.Key, 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_ANYoverride 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_FULLoverride fun layoutId() = R.layout.fragment_module_phone_onekey_loginoverride fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)val btnClose = view.findViewById(R.id.btn_close)
val tvTitle = view.findViewById(R.id.tv_title)
val thirdLayout = view.findViewById(R.id.third_layout)
val btnLogin = view.findViewById(R.id.btn_login)
val btnOtherLogin = view.findViewById(R.id.btn_other_login)
val cbPrivacy = view.findViewById(R.id.cb_privacy)
val tvAgreement = view.findViewById(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中。此次独立登录业务将根据现有业务重新设计新的登录接口,更加清晰明了利于维护。
目标
- 接口设计职责明确
- 登录信息动态配置
- 登录路由页面降级能力
- 登录流程全程可感可知
- 多进程支持
- 登录引擎ab切换
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/**
* 登录事件liveData,支持跨进程
*/
fun loginEventLiveData(): LiveData/**
* 退出登录
*/
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。
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界面导致界面无响应的问题。
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 liveData = https://www.it610.com/article/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
关注得物技术,做最潮技术人!
推荐阅读
- 《Java多线程编程核心技术》知识梳理
- 技术分享|MRO工业品行业产业转型,建立B2B集采平台发展业态模式
- 分布式|终于有人把业务中台、数据中台、技术中台都讲明白了
- 命保住了!五年时间,我们也搞了一个技术中台
- 计算机前沿软件应用课程怎么样,信息技术前沿心得体会|信息技术应用心得体会...
- 人工智能换脸技术python_很吓人的技术,200行Python代码实现换脸程序
- 如何用Elementor快速建网站
- 亚马逊雨林吸二氧化碳能力已降低50%!|亚马逊雨林吸二氧化碳能力已降低50%!|技术前沿洞察
- 非对称加密-区块链核心技术之一
- 20|20 万字《网易智企技术合辑》重磅发布!