我们应该对这样的需求不陌生:App允许用户以游客的身份浏览,在用户点击某些操作时,先判断用户的登录状态,如果已登录,则执行对应操作,如果没登录,则跳转至登录页面要求登录。只是这样倒也不难处理,无非是加个if判断,来决定是跳登录还是直接执行对应操作。但是产品经理可能又说了,登录成功后不能直接回到原来的页面就完了,你还要自动继续之前中断的操作。稍加思考,也还行,跳页面的时候把要执行的操作记录一下,用startActivityForResult跳转,要求登录模块把结果返回来,然后我们在onActivityForResult中获取登录结果,如果登录成功了再根据跳转前的记录,执行之前中断的操作。这么看好像也不存在难度上的问题,只是繁琐,过于繁琐,尤其是页面上有多个需要登录的操作时,在onActivityForResult里的判断要写炸了,这一点我们在下面的例子中可以看到。
先上项目地址,建议配合项目代码看此文章
假定几个需求
页面是上面这样,我们的app有登录模块(loginmodule)、实名认证模块(authmodule)、vip模块(vipmodule),“点赞”要求用户登录,并在登录成功后自动点赞;“评论”操作首先要求用户是登录状态,其次还要是进行了实名认证状态;“屏蔽”要求用户vip等级达到3,如果不到3会跳转到购买vip页面,在购买的时候再验证登录状态,同样,购买成功后自动执行屏蔽操作。
一些背景交待
我们的几种方式都是基于startActivityForResult和onActivityResult来实现的,在各个模块内部有多少个页面,如何跳转我们都不关心,我们只关心以下几点:
- 模块的入口是哪,即我这个startActivityForResult要往哪跳(以loginmodule为例,入口是InputAccountActivity)
- 模块内部怎么跳转我不管,但必须在入口Activity这儿给我setResult把结果给我返回来,要不我怎么拿到结果并执行之前中断的操作啊
- 注意:模块内部页面较多时,我们可能倾向于在登录成功时通过singleTask启动模式返回到入口Activity,并在onNewIntent中setResult和finish,但是不要直接在manifest里设置入口activity的启动模式为singleTask,因为这样onActivityResult就收不到了。
For example, if the activity you are launching uses the singleTask launch mode,
it will not run in your task and thus you will immediately receive a cancel result.
基于此原因,可以在manifest中设置为标准模式,在返回入口Activity的时候给跳转的intent设置flags来达到和singleTask一样的效果。具体可参考loginmodule的代码
val intent = Intent(this,InputAccountActivity::class.java)
intent.putExtra("loginResult",true)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
下面我们会由繁到简介绍多种方式来处理这种业务流程,最终的方案只需在方法上加几个注解就ok了。
方式一
最原始的,使用startActivityForResult跳转,并在onActivityResult里处理结果
这是recyclerView子view的点击事件代码(用的BRVAH,好东西不必多说)
mAdapter.setOnItemChildClickListener { adapter, view, position ->
when (view.id) {
R.id.tv_like -> doLike(position)
R.id.tv_comment -> doComment(position)
R.id.tv_block -> doBlock(position)
}
}
下面是各个方法的代码以及onActivityResult代码
//需要登录才能点赞
private fun doLike(position: Int) {
if (!LoginManager.isLogin) {
action = "like"
clickedPosition = position
val i = Intent(this, InputAccountActivity::class.java)
startActivityForResult(i, REQUEST_CODE_LOGIN)
return
}
val user = mAdapter.getItem(position)
toast("点赞了 ${user?.name}")
}
//需要登录且通过实名认证才能评论
private fun doComment(position: Int) {
if (!LoginManager.isLogin) {
action = "comment"
clickedPosition = position
val i = Intent(this, InputAccountActivity::class.java)
startActivityForResult(i, REQUEST_CODE_LOGIN)
return
}
if (!AuthManager.isAuthed) {
action = "comment"
clickedPosition = position
val i = Intent(this, AuthActivity::class.java)
startActivityForResult(i, REQUEST_CODE_AUTH)
return
}
val user = mAdapter.getItem(position)
toast("评论了 ${user?.name}")
}
//需要达到vip3才能屏蔽其他人
private fun doBlock(position: Int) {
if(VipManager.vipLevel<3){
action = "block"
clickedPosition = position
val i = Intent(this,BuyVipActivity::class.java)
startActivityForResult(i,REQUEST_CODE_VIP)
return
}
val user = mAdapter.getItem(position)
toast("屏蔽了 ${user?.name}")
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK) {
if (requestCode == REQUEST_CODE_LOGIN) {
val loginResult = data?.getBooleanExtra("loginResult", false)
if (loginResult == true) {
when (action) {
"like" -> doLike(clickedPosition)
"comment" -> doComment(clickedPosition)
"block" -> doBlock(clickedPosition)
}
}
} else if (requestCode == REQUEST_CODE_AUTH) {
val authResult = data?.getBooleanExtra("authResult",false)
if(authResult == true){
when(action){
"comment" -> doComment(clickedPosition)
}
}
} else if (requestCode == REQUEST_CODE_VIP) {
val vipLevel:Int = data?.getIntExtra("vipLevel",0) ?:0
if(vipLevel>=3){
when(action){
"block" -> doBlock(clickedPosition)
}
}
}
}
}
以点赞为例,在方法里先判断登录状态,没登录则记录以下点击的操作action,以及点击的位置clickedPosition,并startActivityForResult跳至InputAccountActivity,等待在onActivityResult里处理登录结果。如果是已登录状态则直接执行点赞操作(这里用toast代替)。
再来看onActivityResult里的逻辑,先判断resultCode,再根据不同的requestCode从data里取出对应的结果值,如果是true(即登录成功),再根据之前记录下的action和clickedPosition执行之前中断的操作。
屏蔽与点赞逻辑类似,至于评论,不过是又加了个实名认证的判断,不再赘述。
可以看到代码几乎不存在多少难度,主要就是一层又一层的判断太繁琐,导致代码臃肿。而造成这一切的原因是所有的处理必须在onActivityResult中进行,如果获取的结果能直接在doLike中拿到就不会这样了,也就不必再记录action和clickedPosition了。所以,凶手只有一个,onActivityResult!下面方式二我们会对此进行处理。
方式二
针对方式一的问题,是时候介绍一下我之前写的一个东西了AvoidOnResult,如果想了解原理可以看这篇文章如何避免使用onActivityResult,以提高代码可读性,如果暂时只想知道怎么用,我来简单介绍一下,它主要用来解决startActivityForResult和onActivityResult这种发起调用和回调分离的问题,它的使用方式如下
AvoidOnResult(activity).startForResult(XXXActivity::class.java,object :AvoidOnResult.Callback{
override fun onActivityResult(resultCode: Int, data: Intent?) {
//TODO
}
})
构造器里需要传一个activity实例,startForResult方法传要跳转过去的class(或intent),同时,再传一个AvoidOnResult.Callback,在callback的onActivityResult中就可以处理收到的结果了,而完全不用重写activity的onActivityResult方法。
这样我们的点击事件可以写成这样了
when (view.id) {
R.id.tv_like -> {
if (LoginManager.isLogin) {
doLike(position)
} else {
AvoidOnResult(activity).startForResult(InputAccountActivity::class.java, object : AvoidOnResult.Callback {
override fun onActivityResult(resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK && data?.getBooleanExtra("loginResult", false) == true) {
doLike(position)
}
}
})
}
}
……
}
可以看到所有的处理都在点击事件这儿进行了,不需要在onActivityResult统一判断处理了,也不需要记录action和clickedPosition。当然我们还可以进一步封装一下,LoginManager代码如下
object LoginManager {
var isLogin = false
fun toLogin(activity:Activity,loginCallback: LoginCallback) {
AvoidOnResult(activity).startForResult(InputAccountActivity::class.java,object :AvoidOnResult.Callback{
override fun onActivityResult(resultCode: Int, data: Intent?) {
if(resultCode == Activity.RESULT_OK && data?.getBooleanExtra("loginResult",false)==true){
loginCallback.onLoginResult(true)
}else{
loginCallback.onLoginResult(false)
}
}
})
}
interface LoginCallback{
fun onLoginResult(loginResult: Boolean)
}
}
其他两个模块也类似,最终点击事件是下面这样的,而doLike、doComment、doBlock中不再进行判断了,可以看到代码整齐多了。
mAdapter.setOnItemChildClickListener { adapter, view, position ->
when (view.id) {
R.id.tv_like -> {
if (LoginManager.isLogin) {
doLike(position)
} else {
LoginManager.toLogin(this, object : LoginManager.LoginCallback {
override fun onLoginResult(loginResult: Boolean) {
if (loginResult) {
doLike(position)
}
}
})
}
}
R.id.tv_comment -> {
if(AuthManager.isAuthed){
doComment(position)
}else{
AuthManager.toAuth2(this,object :AuthManager.AuthCallback{
override fun onAuthResult(authResult: Boolean) {
if(authResult){
doComment(position)
}
}
})
}
}
R.id.tv_block -> {
if (VipManager.vipLevel >= 3) {
doBlock(position)
} else {
VipManager.toBuyVip(this, object : VipManager.VipCallback {
override fun onBuyVip(vipLevel: Int) {
if (vipLevel >= 3) {
doBlock(position)
}
}
})
}
}
}
}
可能有人会问,doComment只进行了实名认证状态的判断,没判断登录啊,那是因为在AuthManager的toAuth2方法中处理过登录的检查了,也就是说要进行实名认证,首先你得登录,否则你都进不了实名认证模块,这个听起来很合理吧(但后面我们会推翻它……)
//方式2
fun toAuth2(activity: Activity, authCallback: AuthCallback){
if(LoginManager.isLogin){
realToAuth(activity,authCallback)
}else{
LoginManager.toLogin(activity,object :LoginManager.LoginCallback{
override fun onLoginResult(loginResult: Boolean) {
if(loginResult){
realToAuth(activity,authCallback)
}else{
authCallback.onAuthResult(false)
}
}
})
}
}
private fun realToAuth(activity: Activity, authCallback: AuthCallback){
AvoidOnResult(activity).startForResult(AuthActivity::class.java,object :AvoidOnResult.Callback{
override fun onActivityResult(resultCode: Int, data: Intent?) {
if(resultCode == Activity.RESULT_OK && data?.getBooleanExtra("authResult",false) == true){
authCallback.onAuthResult(true)
}else{
authCallback.onAuthResult(false)
}
}
})
}
其实到这里,相比方式一的代码已经改善很多了。但是,我说但是,作为一名程序猿软件工程师——这个世界上最接近魔法师的神奇职业之一,怎么能就此满足!继续优化!
方式三
这次要祭出的是AOP(面向切面)了,项目用的是aspectjx,为Android处理过的aspectj。没听过的可以看我之前写的文章
AOP:利用Aspectj注入代码,无侵入实现各种功能,比如一个注解请求权限
如果你还是不想看的话,我简单介绍一下(我尽量说明白吧)
不同于面向对象,面向切面编程是针对满足某些条件的切面进行统一处理,比方我们现在有一个面包(面向对象里的对象),需要把它做成汉堡,所需要的操作就是把它中间切一刀(这就是切面了),然后向切面里塞入一些肉和菜什么的。
对应现在的例子呢,所有需要验证登录的地方就是一个切面,我们要做的就是确定这个切面,然后在这个切面统一处理(用@Around,可以实现方法的拦截或允许继续执行),判断登录状态,已登录就允许执行(joinpint.proceed()),否则就跳转至登录模块,登录成功再执行。
aspectj还涉及到一些Aspect、Pointcut、Advice等名词,还是建议了解一下
再继续往下看,我在之后的讲述中也会假定各位对此有了一些了解。
还是以点赞登录为例,先分析一下,首先我们要找到需要检查登录的切面,然后在Advice中判断如果已登录,就允许方法执行,即proceed,这个很容易;如果未登录,则要走登录流程,登录成功再proceed,登录失败就相当于拦截了,回顾之前的LoginManager,它需要一个Activity参数,只要能拿到activity实例就行,最简单粗暴的,我不管你方法在哪,我直接取top Activity,即当前resume的activity,关于top Activity的获取不多说,我是Application中获取的,Weak是我自己写的一个委托,你只把它当弱引用就行了。好了,关于activity实例的获取解决了,那就正式开始吧
class MyApplication: Application() {
companion object {
var topActivity by Weak()
}
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(object :ActivityLifecycleCallbacks{
override fun onActivityPaused(activity: Activity?) {
}
override fun onActivityResumed(activity: Activity?) {
topActivity = activity
}
override fun onActivityStarted(activity: Activity?) {
}
override fun onActivityDestroyed(activity: Activity?) {
}
override fun onActivitySaveInstanceState(activity: Activity?, outState: Bundle?) {
}
override fun onActivityStopped(activity: Activity?) {
}
override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {
}
})
}
}
首先是需要登录的切面,我是通过注解来确定的,先来个RequireLogin注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireLogin {
boolean proceed() default true;
}
可以看到我定义了一个boolean类型的参数proceed(字面义:继续,尤指打断后),因为产品的需求中可能会要求某些地方登录完后自动继续之前打断的操作,有些地方却不用,这个参数我们会用来判断要不要继续被打断的操作,即要不要执行joinpoint.proceed()。
继续,上aspect,首先来个pointcut,所有方法的执行
//所有方法的execution
@Pointcut("execution(* *..*.*(..))")
public void anyExecution() {
}
针对RequireLogin注解,再来个pointcut
//注解有RequireLogin
@Pointcut("@annotation(requireLogin)")
public void annotatedWithRequireLogin(RequireLogin requireLogin) {
}
再来是它们两个pointcut的交集,也就是所有注解有RequireLogin的方法的执行,这就是需要验证登录的切面了
@Pointcut("anyExecution() && annotatedWithRequireLogin(requireLogin)")
public void requireLoginPointcut(RequireLogin requireLogin) {
}
怎么拦截处理呢?上Advice
@Around("requireLoginPointcut(requireLogin)")
public void requireLogin(final ProceedingJoinPoint proceedingJoinPoint, RequireLogin requireLogin) throws Throwable {
final boolean proceed = requireLogin.proceed();
if (LoginManager.INSTANCE.isLogin()) {
proceedingJoinPoint.proceed();
} else {
Activity activity = MyApplication.Companion.getTopActivity();
if (activity != null) {
LoginManager.INSTANCE.toLogin(activity, new LoginManager.LoginCallback() {
@Override
public void onLoginResult(boolean loginResult) {
if (loginResult && proceed) {
try {
proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
});
}
}
}
proceedingJoinPoint.proceed()就是执行了被拦截的方法,不调用这个方法就相当于代码被拦截不执行了。逻辑和我们之前说的一样,先取了注解的参数proceed,然后判断登录状态,如果登录了就proceedingJoinPoint.proceed(),否则就先获取topActivity,走登录流程,在登录结果中判断,如果登录成功并且注解的参数proceed传的true,就proceedingJoinPoint.proceed()。
另两个模块不赘述了,直接看代码吧,这时候UserListActivity3的方法是这样的
//需要登录才能点赞
@RequireLogin(proceed = true)
private fun doLike(position: Int) {
val user = mAdapter.getItem(position)
toast("点赞了 ${user?.name}")
}
//需要登录且通过实名认证才能评论
@RequireAuth(proceed = true)
private fun doComment(position: Int) {
val user = mAdapter.getItem(position)
toast("评论了 ${user?.name}")
}
//需要达到vip3才能屏蔽其他人
@RequireVip(proceed = true,requireLevel = 3)
private fun doBlock(position: Int) {
val user = mAdapter.getItem(position)
toast("屏蔽了 ${user?.name}")
}
代码中没有一丁点的判断,doLike需要登录,那就给它加个RequireLogin注解,要求登录完自动执行点赞,那就给proceed参数设为true,否则就false。以后其他地方如果也要求登录了,只需要在对应的方法上也给它加个注解,哪里登录注哪里。
还是doComment,明明又要登录又要注册,可是却只有一个RequireAuth注解,为什么,因为AuthManager的toAuth上加了RequireLogin注解啊,即要想实名认证,首先你得登录。
//方式3请添加注解,方式四请注掉下面的注解
@RequireLogin(proceed = true)
fun toAuth(activity: Activity, authCallback: AuthCallback){
AvoidOnResult(activity).startForResult(AuthActivity::class.java,object :AvoidOnResult.Callback{
override fun onActivityResult(resultCode: Int, data: Intent?) {
if(resultCode == Activity.RESULT_OK && data?.getBooleanExtra("authResult",false) == true){
authCallback.onAuthResult(true)
}else{
authCallback.onAuthResult(false)
}
}
})
}
到这里代码够精简了吧,一个注解就能完成之前一大堆的判断,难道还能再减少?下一步的优化不再是精简代码了,而是增加灵活性。之前两次提到了实名认证对于登录的依赖,即要想进行实名认证的话,首先得检查登录。依赖关系大概这样的 doComment -> toAuth -> toLogin,大概是个链式的依赖关系,虽然这个需求听起来很合理,但是代码这么写却缺少灵活性。怎么说呢?比如现在又有需求了,想屏蔽别人首先你得是vip3,然后还要通过了实名认证,很多人第一时间想到的可能是像实名认证那样,我在VipManager的toBuyVip方法上再加个RequireAuth注解,然而并不可以,因为要求不登录也可以跳转到vip购买页面,在点购买的时候才要求登录,而如果给toBuyVip添加RequireAuth注解之后依赖关系就是这样的了doBlock -> toBuyVip -> toAuth -> toLogin,这样在点屏蔽的时候会先走登录注册流程,导致用户无法以游客的身份进入vip购买页面。当然我们在此不讨论需求的合理性,不讨论游客该不该进入vip购买页面。
说这么多也不知道表达清楚没,我就是想说,我们现在存在的问题是各个切面有依赖关系,有耦合,如果让它们彼此独立,我们可以自由地组合就好了,比如实名认证模块就只管判断实名认证的状态,不管你登没登录(你没登录,那实名认证就该是false的状态)。我们往方法上加注解的时候直接加多个注解,比如doComment,要求两点,一登录,二认证,那我就注解RequireLogin和RequireAuth;doBlock要求vip3和实名认证,那我就注解RequireVip和RequireAuth,就像搭积木一样,自由组合。
方式四
这次以doComment为例,既然要做到各个切面独立,那就先把AuthManager.toAuth方法的RequireLogin注解去掉,让它不依赖于登录切面,然后我们往doComment上加两个注解,RequireLogin、RequireAuth,然后运行一下先看下效果,点击评论,发现两个问题:
- 两个流程的先后顺序有问题,先走了实名认证,然后才走了登录
- 两个流程走完后发现并没有弹出toast
首先先说第二个问题,我们的几种方式本质上都是用的onActivityResult,AvoidOnResult的callback就是在构造器传入的activity实例的onActivityResult中调用的,如果这个activity实例finish掉了,那callback就不会调用了。我们可以打个断点,分别打在AuthAspect和LoginAspect中获取topActivity的地方,点击评论首先会走到AuthAspect中,这里我们可以看到获取到的topActivity是UserListActivity4,是我们期望的结果,没问题。放开断点继续走,走完实名认证的流程后会进入LoginAspect的代码,这里我们发现获取到的topActivity是AuthActivity,也就是这时虽然已经在UserListActivity4的onActivityResult代码中了,但是当前resume状态的activity却还是AuthActivity,而我们再通过AuthActivity去startForResult,callback肯定不会执行了,因为它马上就要finish掉了,关于onActivityResult和onResume的执行顺序问题在Activity的onActivityResult的源码注释中其实说的也很明白了,onActivityResult比onResume先执行。
You will receive this call immediately before onResume() when your activity is re-starting.
那也就是说在多个切面的情况下,我们直接获取resume的Activity是不可行的。那怎么解决呢?我的方法是先通过joinpoint的this,target,args,看看这些地方有没有能拿到的activity,如果有,就用这里拿到的activity,如果没有再用top Activity,下面的方法只判断了Activity,其实如果能获取到Fragment或其他什么类型的实例,再间接获取到Activity也可以,这里图简单没做太多处理。
public class AspectUtils {
public static Activity getActivity(JoinPoint joinPoint){
//先看this
if(joinPoint.getThis() instanceof Activity){
return (Activity) joinPoint.getThis();
}
//target
if(joinPoint.getTarget() instanceof Activity){
return (Activity) joinPoint.getTarget();
}
//args
for(Object arg:joinPoint.getArgs()){
if (arg instanceof Activity){
return (Activity) arg;
}
}
//如果实在找不到,再返回topActivity
return MyApplication.Companion.getTopActivity();
}
}
第二个问题解决了再来看第一个问题,aspectj织入顺序问题,我是在这里找到方法的https://stackoverflow.com/questions/11850160/aspectj-execution-order-precedence-for-multiple-advice-within-one-aspect
When two pieces of advice defined in the same aspect both need to run at the same join point, the ordering is undefined (since there is no way to retrieve the declaration order via reflection for javac-compiled classes). Consider collapsing such advice methods into one advice method per joinpoint in each aspect class, or refactor the pieces of advice into separate aspect classes - which can be ordered at the aspect level.
也就是说同一个aspect中的多个advice的顺序是不确定的,可以考虑把想排序的advice分别放到不同的aspect中,然后对这些aspect排序(用@DeclarePrecedence)
分拆aspect很简单,不必说。排序再创建个类,如下
@Aspect
@DeclarePrecedence("LoginAspect,VipAspect,AuthAspect")
public class CoordinationAspect {
// empty
}
评论的时候要求先登录,再实名认证,所以LoginAspect在AuthAspect前面,屏蔽的时候要求首先是vip,然后还要通过了实名认证,所以VipAspect在AuthAspect前面。
这样之后再运行一下,应该已经没问题了。
方式四的代码如下,相比方式三更灵活,可以自由组合,当然要确定好各个切面的顺序
//需要登录才能点赞
@RequireLogin(proceed = true)
private fun doLike(position: Int) {
val user = mAdapter.getItem(position)
toast("点赞了 ${user?.name}")
}
//需要登录且通过实名认证才能评论
@RequireLogin(proceed = true)
@RequireAuth(proceed = true)
private fun doComment(position: Int) {
val user = mAdapter.getItem(position)
toast("评论了 ${user?.name}")
}
//需要达到vip3且通过实名认证才能屏蔽其他人
@RequireVip(proceed = true,requireLevel = 3)
@RequireAuth(proceed = true)
private fun doBlock(position: Int) {
val user = mAdapter.getItem(position)
toast("屏蔽了 ${user?.name}")
}
存在的问题
- AvoidOnResult,低内存极端情况下A跳转到B,如果回到A之前A被系统回收了,会触发不了callback,也就是说会无法继续之前中断的操作,如果你的应用对此很在意,请慎用。如果你有解决方案,欢迎来讨论。
- 如果有不能运行的问题,先clean或者sync一下,不行的话到aspectjx的github看有没有对应的issue。
结束
demo里还有个ShowConfirmAspect,是用来在一个方法执行前对用户进行一些询问操作,弹个对话框,根据用户反馈决定要不要执行该方法,我在MainActivity的onBackPressed上加了该注解来询问用户是否要退出应用,这里不细说了,只是想告诉大家面向切面有很多应用场景,特别适合进行一些统一的处理,从而避免大量的重复工作,千万不要局限于本文所举的几个例子中。