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 discovered some problems 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 relatively simple and fast, 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 by function, 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
- High reuse
- Easy to expand
- Simple maintenance
- 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 provides componentStore, and the component is registered to the componentStore for unified management in a combined way.
The componentStore can obtain the corresponding component component through the key, so as to realize communication
The container is the host of all component components, that is, pages, usually activities and fragments, of course, can also be customized.
accomplish
Define ILoginComponent
interface ILoginComponent : FullLifecycleObserver, ActivityResultCallback {
val key: Key<*>
val loginScope: ILoginScope
interface Key<E : ILoginComponent>
}
Encapsulates an abstract parent component, implements the default life cycle, requires a key to identify this component, can handle the onActivityResult event, and provides a default anti-shake view click method
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?) {
}
}
A simple component implementation, this is a header component
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
}
}
The component component usually does not care what the view looks like, and the core is to handle the business logic and interaction of the component.
According to the analysis of the login business, the login runtime environment of the component, LoginRuntime, can be defined as follows
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()
}
This is the component runtime environment for a scene hosted by an activity or 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
}
}
This is actually the encapsulation of the proxy call around the activity or fragment. It is worth noting that the fragment I use is viewLifecyleOwner, which ensures that no memory leaks will occur, and because the viewLifecyleOwner needs to be acquired in a specific life cycle, otherwise an exception will occur, so I use the packaging here. The class form defines a safe SafeViewLifecycleOwner.
The following is the ILoginComponentModel interface, which abstracts the method of componentStore management components
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
}
This is the specific implementation class, which mainly solves the idea of viewModelStore saving and managing viewmodel, and the idea of kotlin coroutine to obtain CoroutineContext through key to realize this 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")
}
}
}
}
Finally, after showing a modular refactoring, use a combination method to quickly implement a login page
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)
)
}
}
Under normal circumstances, only one layout xml file needs to be implemented. If there are special requirements, it can also be implemented by adding or inheriting a copy component.
2.2 Logging into separate components
After the login business logic is reconstructed, the next goal is to separate the login business from du_account and put it in a separate component du_login. The independent login business will redesign a new login interface based on the existing business, which is clearer and easier to maintain.
Target
- Interface design responsibility is clear
- Dynamic configuration of login information
- Login routing page downgrade capability
- The whole process of login is sensible
- Multi-process support
- Login engine ab switch
design
The ILoginModuleService interface is designed to expose only the methods required by the business.
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()
}
Login parameter configuration
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?
)
Supports configuring multiple styles of login pages in priority order, and routing failures will automatically degrade
Support to trace the login source, which is conducive to burying points
Support configuration page open and close animation
Support configuring custom parameter Bundle
Support cross-process observation of login status changes
internal sealed class LoginStatus {
object UnLogged : LoginStatus()
object Logging : LoginStatus()
object Logged : LoginStatus()
}
Support cross-process aware login process
/**
* [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?
)
accomplish
The core of the entire component is LoginServiceImpl, which implements the ILoginModuleService interface to manage the entire login process. In order to ensure the user experience, the login page will not be opened repeatedly, so it is particularly important to maintain the login state correctly. How to ensure the correct login status? In addition to ensuring correct business logic, it is critical to ensure thread safety and process safety.
Process safety and thread safety
How to ensure process safety and thread safety?
Here, Activity, one of the four components, is used to achieve process safety and thread safety. LoginHelperActivity is a transparent invisible activity.
<activity
android:name=".LoginHelperActivity"
android:label=""
android:launchMode="singleInstance"
android:screenOrientation="portrait"
android:theme="@style/TranslucentStyle" />
The main purpose of LoginHelperActivity is to use its thread-safe process security feature to maintain the login process, prevent repeated opening of the login page, and close it immediately after opening the execution logic. Its startup mode is singleInstance, there is a separate task stack, that is, it is turned on and off. It will not affect the login process when it is started at any time, and it can also solve the problem of cross-process and thread safety. Logout is also implemented using LoginHelperActivity, and it also uses the thread-safe cross-process feature to ensure that the state will not go wrong.
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()
}
}
The login logic opens an auxiliary LoginEntryActivity, which is also transparent and invisible. Its startup mode is singleTask. It will be used as the root Activity of all login processes and will always exist with the entire login process, except for special cases (such as not retaining Active mode, the process is killed, out of memory), the destruction of LoginEntryActivity represents the end of the login process (except in special cases). In the onResume life cycle of LoginEntryActivity, it will be routed to the real login page. In order to prevent unexpected situations, a timeout detection will be enabled at the same time to prevent the real login page from being opened, resulting in the problem of staying in the LoginEntryActivity interface and causing the interface to become unresponsive. .
<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)
})
}
}
The activity life cycle changes are sensed through registerActivityLifecycleCallbacks, which is used to observe the start and end of the login process and the abnormal exit of the login process. For example, the behavior of other businesses to actively finish after obtaining LoginEntryActivity through registerActivityLifecycleCallbacks will be perceived, and then exit the login process.
The end of the login process also uses the singleTask feature to destroy all the login pages. There is a small detail here to prevent the LoginEntryActivity from being destroyed in advance if the activity is not retained. It may not be possible to use the singleTask feature to destroy other pages. , all still have a bottom-up operation of actively caching the activity.
Distribute events across processes
Distributing the status and events of the login process across processes is implemented through ArbitraryIPCEvent, which may be considered open in the future. The main schematic diagram is as follows:
ab scheme
Therefore, the minor refactoring and independent componentization have changed a lot, so it is necessary to design a reliable ab solution. In order to make the ab scheme simpler and more controllable, the modular code this time only exists in the new login component, and the original du_account code remains unchanged. A in ab runs the code in the original du_account, b runs the code in du_login, and also ensures that the value of ab will not change during a complete app life cycle, because if it changes, the code will becomes uncontrollable. Because the ab value needs to be issued by the server, and some initialization work for login is in the process of application initialization, in order to make the online device run the code according to the issued ab experiment configuration as much as possible, the initialization operation is delayed. back. The main strategy is that when the application starts, it is not easy to start initialization immediately. A timer with a 3s timeout will be executed first. If the value issued by ab is obtained before the timeout, it will be initialized immediately. If the delivered ab configuration has not been obtained after the timeout, it will be initialized immediately, and the default is the a configuration. If any login code is called during the timeout wait, it will be initialized immediately first.
use
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);
}
});
}
Pits encountered in development
1. The most time-consuming part should be the problem of rebuilding the view id of the fragment page.
When testing the case that does not keep activities, it is found that the page will become blank, but the results queried by fragmentManger are normal (isAdded = true, isHided = false, isAttached = true). After investigating for a long time, I suddenly thought of the id problem. The id of the fragment's host containerView was dynamically generated by me. I did not use xml to write the layout, but used code to generate the view.
2. Another is the timing of view onRestoreInstanceState
This problem is also encountered when the test does not retain the activity case. According to common sense, as long as the view is set with an id, Android's native controls will retain the previous state. For example, checkBox will retain the checked state. I foundViewById in the onViewCreated method of the fragment page reconstruction and reached the checkBox, but the value obtained through isChecked is always false. I can't understand it, and don't debug the source code. Later, when the ability to save the state was implemented for the custom control ThirdLoginLayout, it was found through debugging that the onRestoreInstanceState callback timing was relatively late, and the view did not restore the state when onViewCreated.
Text/Dylan
Pay attention to Dewu Technology and be the most fashionable technical person!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。