Android架构设计(会话管理)

前言:对于大多数读者来说,管理会话无非就是持久化会话数据(cookie or token等等),然后根据本地的会话数据相应拦截用户操作或Http响应。本文并非标新立异,而旨在达到上述目的的基础上,对会话管理流程进行最优化。


常见问题

  • 杂乱无章的持久化

<!-- 不良代码示范————持久化工具不分类 -->

class SharedPreferencesUtils{
    //保存用户id
    public static void saveUserId(String userid){}
    //获取用户id
    public static void getUserId(){}
    //保存用户手机号
    public static void saveUserTel(String userTel){}
    //获取用户手机号
    public static String getUserTel(){}
    //保存最近一次获取验证码时间
    public static void saveVercode(long time){}
    //获取最近一次获取验证码时间
    public static long getVercode(){}
}

该段代码的意图是将应用中所有需要持久化的数据通过SharedPreferencesUtils统一进行存储。从持久化存储的角度讲这样设计方便集中管理和维护持久化数据,这是没有问题。但需要指出的是,即便是集中管理持久化数据也需要进行分类存储,比如用户的会话数据和其他类型的数据应该分类管理。如果不分类管理的结果就是SharedPreferencesUtils将会随着业务扩张变得无限臃肿,反而不利于管理。

  • 会话数据不向下兼容

<!-- 不良代码示范————会话数据随业务扩张,老版本无法兼容 -->

class SharedPreferencesUtils{
    //保存用户id 2016-12-20
    public static void saveUserId(String userid){}
    //获取用户id 2016-12-20
    public static void getUserId(){}
    //保存用户手机号 2017-02-11
    public static void saveUserTel(String userTel){}
    //获取用户手机号 2017-02-11
    public static String getUserTel(){}
    //保存用户推广码 2017-04-11
    public static void saveSpreadCode(String spreadCode){}
    //获取用户推广码 2017-04-11
    public static String getSpreadCode(){}
    //保存用户Token 2018-09-30
    public static void saveToken(String token){}
    //获取用户Token 2018-09-30
    public static String getToken(){}
}

随着业务的扩张,会话数据变得越来越多,不同的会话数据针对不同业务又有不同程度的耦合。依照上述代码维护会话数据,当发布新版本后,我们时常需要考虑老版本的数据兼容性问题,如果设计不合理就会常造成用户更新一次APP就要重新登录的问题。

  • 无休止的IO读写

<!-- 不良代码示范————频繁IO操作 -->

class UserInfoActivity extends Activity {
     protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        HttpCenter.POST(Request.ini(this, UserInfo.class)
                            .url("http://www.baidu.com/getUserInfo")
                            .putParams("token", SharedPreferencesUtils.getUserId())
                            .requestListener(new RequestListener<UserInfo>() {
                                @Override
                                public void response(UserInfo response) {
                                
                                }
                            }));
    }
}

这段代码中,token作为参数传递给HttpCenter进行网络请求,其中token取自SharedPreferencesUtils持久化工具。那么问题在哪儿呢?实际开发中,多线程Http请求不是什么新鲜的事情,然而如果同时10条线程都适用SharedPreferencesUtils持久化工具读取磁盘会怎么样呢,这样的频繁的IO访问一方面是有性能问题,另一方面如果同时出现写操作又会出现并发问题。

  • 处处冗余的操作拦截

<!-- 不良代码示范————冗余操作拦截之事件触发 -->

class ProductActivity extends Activity {
     Button mBuy;
     Button mFollow;
     protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mBuy.setOnClickListener(
            new View.OnClickLisener(View view){
                if("".equas(SharedPreferencesUtils.getUserId())){
                    //去登陆
                }
                if("".equas(SharedPreferencesUtils.getTel())){
                    //去绑定手机号
                }
                //if  if  if...
            }
        );
        mFollow.setOnClickListener(
            new View.OnClickLisener(View view){
                if("".equas(SharedPreferencesUtils.getUserId())){
                    //去登陆
                }
                if("".equas(SharedPreferencesUtils.getTel())){
                    //去绑定手机号
                }
                //if  if  if...
            }
        );
        
    }
}

这是一段常见的根据会话拦截用户操作的代码,同时也是一段及其恶心的代码。首先很多需要拦截的业务非常多,然后随着业务的增长,同一处的拦截的if也要跟着更新。使用这种方式只会让代码越来越难以维护,出现bug的几率也会随之越来越大。

<!-- 不良代码示范————冗余操作拦截之请求Http触发 -->

class ProductActivity extends Activity {

     protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if(!"".equas(SharedPreferencesUtils.getUserId())){
              HttpCenter.POST(Request.ini(this, UserInfo.class)
                            .url("http://www.baidu.com/getUserInfo")
                            .putParams("token", SharedPreferencesUtils.getUserId())
                            .requestListener(new RequestListener<UserInfo>() {
                                @Override
                                public void response(UserInfo response) {
                                
                                }
                            }));      
         }
    }
}

这是一段常见的根据会话拦截Http请求的代码。这种方式从功能上看没有任何问题。从设计上讲,其一:业务层和通讯层是分离的,假设这段请求在多个地方都有使用,那是不是每个地方都要写上这段冗余的判断呢;其二:假设业务变更,该请求已无需登录即可使用,是不是所有冗余的地方都要进行变更;所有这里犯得是业务层、通讯层高耦合的设计错误问题。

  • 复杂的Http响应拦截 

<!-- 不良代码示范————复杂的Http-Response拦截 -->

class ProductActivity extends Activity {

     protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        HttpCenter.POST(Request.ini(this, UserInfo.class)
                            .url("http://www.baidu.com/getUserInfo")
                            .putParams("token", SharedPreferencesUtils.getUserId())
                            .requestListener(new RequestListener<UserInfo>() {
                                @Override
                                public void response(UserInfo response) {
                                    if(登录超时){
                                        if(){
                                        //跳转xx页面

                                        //告知登录后返回指向页面

                                        //...
                                        }
                                    }
                                }
                            }));      
    }
}

这是一段也是非常常见的根据服务器返回登录超时情况进行被动拦截操作的代码。一般出现这种情况,都需要跳转到登录页面让用户进行登录,同时登录后回转的页面也是具体业务具体分析了。但是这段代码的问题也是显而易见的,几乎所有需要会话状态的请求返回处理都要有这段冗余的代码,试想如果一些接口不需要登录状态了,要改的地方特别多,反之也是。本质上来说,这里犯得是依然是业务层、通讯层高耦合的设计错误问题。

解决方案

  • 全局唯一会话管理服务

public class SessionService {
    private static SessionService sessionService = null;
    private final String userInfoKey = "SessionService_userInfoKey";
    private Context context;
    private User userInfo;
    private Gson gson;
    public static void create(Context context) {
        if (sessionService == null) {
            sessionService = new SessionService(context);
        }
    }
    private SessionService(Context context) {
        this.context = context;
        this.gson = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
    }

    /**
     * 登录上线
     *
     * @param user 用户资料
     */
    public static void loginIn(User user) {
        if (sessionService != null) {
            sessionService.updateUserInfo(user);
            sessionService.startUserService();
        }
    }

    /**
     * 登录下线
     */
    public static void loginOut() {
        if (sessionService != null) {
            sessionService.stopUserService();
            sessionService.deleteUserInfo();
        }
    }

    /**
     * 保存用户资料
     *
     * @param user 用户资料
     */
    private void updateUserInfo(User user) {
        StorageService.putStringValue(userInfoKey, gson.toJson(user));
        this.userInfo = user;
    }

    /**
     * 删除用户资料
     */
    private void deleteUserInfo() {
        this.userInfo = null;
        StorageService.putStringValue(userInfoKey, null);
    }

    /**
     * 获取用户资料
     *
     * @return
     */
    private User getUserInfo() {
        if (this.userInfo == null) {
            String userStr = StorageService.getStringValue(userInfoKey, "");
            if (TextUtils.isEmpty(userStr)){
                this.userInfo = new User();
            }else{
                this.userInfo = gson.fromJson(userStr, User.class);
            }
        }
        return this.userInfo;
    }

    /**
     * 启动登录上线相关服务
     * 在必要的地方启动服务
     */
    public static void startUserService() {
       
    }

    /**
     * 关闭登录上线服务
     */
    private void stopUserService() {
        
    }

    /**
     * 获取登录状态
     *
     * @return
     */
    public static boolean isOnline() {
        return !TextUtils.isEmpty(getUserId());
    }

    /**
     * 获取绑定手机号状态
     *
     * @return
     */
    public static boolean isBindTel() {
        return !TextUtils.isEmpty(getTel());
    }

    /**
     * 获取用于socket连接的userId
     */
    public static String getUserId() {
        return sessionService.getUserInfo().getUserId();
    }

    /**
     * 获取token
     *
     * @return
     */
    public static String getToken() {
        return sessionService.getUserInfo().getToken();
    }

    /**
     * 获取用户推广码
     *
     * @return
     */
    public static String getSpreadCode() {
        return sessionService.getUserInfo().getSpreadCode();
    }

    /**
     * 获取用户手机号
     *
     * @return
     */
    public static String getTel() {
        return sessionService.getUserInfo().getTel();
    }
}

这里创建了一个专门的会话管理服务,这个类集中管理会话数据的存储和读取,管理用户登录和登出需要同步的其他服务,这样子就解决了数据持久层杂乱无章的问题,这个类可以让你更专注于处理会话数据,也可以包装好访问权限,不让其余开发成员滥用。

 

  • 会话数据单一对象化持久化

/**
     * 保存用户资料
     *
     * @param user 用户资料
     */
    private void updateUserInfo(User user) {
        StorageService.putStringValue(userInfoKey, gson.toJson(user));
        this.userInfo = user;
    }

可以看到通过将会话数据存储到对象中,通过对象化字符串进行统一持久化,不论新版本增加多少的会话数据及相关业务,你要做的只需在对象类中响应的删减字段即可,这就解决了会话数据可能不向下兼容的问题。

  • 持久化数据同步到内存

/**
     * 获取用户资料
     *
     * @return
     */
    private User getUserInfo() {
        if (this.userInfo == null) {
            String userStr = StorageService.getStringValue(userInfoKey, "");
            this.userInfo = gson.fromJson(userStr, User.class);
        }
        return this.userInfo;
    }
/**
     * 保存用户资料
     *
     * @param user 用户资料
     */
    private void updateUserInfo(User user) {
        StorageService.putStringValue(userInfoKey, gson.toJson(user));
        this.userInfo = user;
    }

可以看到需要使用会话数据时,会话服务会自动从持久层载入到内存,如果更新了会话数据,也会自动同步到持久层和内存,也就是说大部分时间都无需访问磁盘,这样就解决了频繁读写IO的问题,更进一步的话,你还可以在这两个方法间处理多线程并发的问题。

  • 面向用户操作事件切面编程

TIPS:这里需要用到一个IOC框架,该框架最大的特点是支持面向注解切面编程,支持对Click,LongClick...等事件进行拦截

https://github.com/chengnuovax/EasyAnontion
    <!--业务层代码-->
    @NeedLogin
    @Click
    void mBlessing() {}
    @Click
    void mQuestions() {}
    @NeedTel
    @NeedLogin(UCenterActivity.class)
    @Click
    void mReport() {}
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD})
public @interface NeedLogin {
    /**
     * 关闭登录页面后默认去往页面
     * @return
     */
    Class<? extends Activity> value() default MainControllerActivity.class;
}
/**
 * 用于方法注解,对于需要绑定手机号的业务会自动转向绑定手机号页面
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface NeedTel {
}
//Application中启用点击事件拦截器
AnnotionInit.getInstance().clickPlugDecorate(new MyClickPlugDecorate());
/**
 * 点击按钮拦截器
 * 拦截登录状态、绑定手机号状态
 */
public class MyClickPlugDecorate implements InjectPlugDecorate {
    @Override
    public boolean injectWork(Context context, Method method) {
        //验证登录状态
        if (method.isAnnotationPresent(NeedLogin.class) && !SessionService.isOnline()) {
            Class<? extends Activity> cls = method.getAnnotation(NeedLogin.class).value();
            SessionService.goLogin("请先登录!", cls);
            return true;
        }
        //验证手机号
        if (method.isAnnotationPresent(NeedTel.class) && !SessionService.isBindTel()) {
            BindTelDialog.ini(context).show();
            return true;
        }
        return false;
    }
}

可以看到这段代码中,通过开发自定义注解,配合向IOC框架中注入点击拦截器可轻松处理掉以前处处冗余的操作拦截代码,需要拦截的地方加上你自定义的注解就可以了,如果需要配以不同的参数,你可以在你的注解里面随意开发就行了。当然你也可以拦截其他事件,具体使用可以参考该框架的API。

  • 集中维护Api

public class ApiPath {
    /**
     * 使用手机号登录接口
     */
    public final static String loginByTel = BuildConfig.SERVER_URL+"user/login";
    /**
     * 获取用户资料接口
     */
    @NeedLogin
    public final static String getUserInfo = BuildConfig.SERVER_URL+"user/get/info";
    /**
     * 获取个人中心角标提醒接口
     */
    @NeedLogin(UpdateUserInfoActivity.class)
    public final static String getUCenterSuperscript = BuildConfig.SERVER_URL+"user/personal/center/marks";
}

将所有的API地址集中存放,如果改动的话就不用到处找了。

你可能还发现这里又有@NeedLogin这样的注解,这个注解用于Http-Request切面编程,如果你的Http-Request发现你请求的API有这样的注解就可以针对性的做拦截处理了,比如这个API需要进行登录后调用,或者这个API需要注册3年以上的用户才能调用,等等。这里实际上是在解决常见问题中遇到的业务层、会话层、通讯层耦合的问题,那么具体怎么解耦请看下一点。

  • 面向Http-Request切面编程

​​​​​​​TIPS:这里需要用到一个Http框架,该框架支持面向Request,Response切面编程,我们可以用来做会还拦截

https://github.com/fanqieVip/httpclient
<!--在APPlication中注册网络中心服务-->
HttpCenter.create(getApplicationContext(), Config.ini()
                            .threadPool(AppUtils.getNumCores() * 5)
                            .connectTimeout(5000)
                            .downloadPath(DOWNLOAD_PATH)
                            .requestInterceptor(new POSTRequestInterceptor())
                            .responseInterceptor(new POSTResponseInterceptor()));

这里我们可以看到自定义的POSTRequestInterceptor拦截器

public class POSTRequestInterceptor implements RequestInterceptor {
    Request paramsMachining(Request request){
        if(needLogin(request) && !SessionService.isOnline()){
           request.stop();
            //...
        }
    }
    private boolean needLogin(Request request){
        if (apiPath == null){
            apiPath = new ApiPath();
        }
        boolean needLogin = false;
        try {
            Field[] fields = ApiPath.class.getDeclaredFields();
            for (Field field : fields) {
                Class<?> c = field.getType();
                if (c.isAssignableFrom(String.class) && field.isAnnotationPresent(NeedLogin.class) ){
                    field.setAccessible(true);
                    String value = (String) field.get(apiPath);
                    if (request.getUrl().equals(value)){
                        needLogin = true;
                        break;
                    }
                }
            }
        }catch (Exception e){}
        return needLogin;
    }
}

可以看到该拦截器针对需要登录才能使用的API进行了拦截,如果没有登录将会自动终止该请求。

  • 面向Http-Response切面编程

同样是使用上述Http框架,这里我们可以看到自定义的POSTResponseInterceptor拦截器

public class POSTResponseInterceptor implements ResponseInterceptor {

    @Override
    public <T extends Response> T paramsMachining(Request request, T response) {
        if(登录超时){
            SessionService.loginOut();
            SessionService.goLogin(response.getErrorMsg(), getLoginOutBackPage(request));
        }
    }
   /**
     * 获取被登出并跳转到登录页面后,返回上一级的页面Actiity
     *
     * @return
     */
    private Class<? extends Activity> getLoginOutBackPage(Request request) {
        Class<? extends Activity> activity = null;
        if (apiPath == null) {
            apiPath = new ApiPath();
        }
        try {
            Field[] fields = ApiPath.class.getDeclaredFields();
            for (Field field : fields) {
                Class<?> c = field.getType();
                if (c.isAssignableFrom(String.class) && field.isAnnotationPresent(NeedLogin.class)) {
                    field.setAccessible(true);
                    String value = (String) field.get(apiPath);
                    if (request.getUrl().equals(value)) {
                        activity  = field.getAnnotation(NeedLogin.class).value();
                        break;
                    }
                }
            }
        } catch (Exception e) {
        }
        return activity;
    }
}

这里我们可以看到解析了NeedLogin,由于该注解定义了登录后返回的页面,在这里将会根据该注解自动进行跳转。

我们在回头看看ApiPath这个类,我们在定义API实际上已经根据实际业务指明了登录后返回的页面

​
public class ApiPath {
    /**
     * 使用手机号登录接口
     */
    public final static String loginByTel = BuildConfig.SERVER_URL+"user/login";
    /**
     * 获取用户资料接口
     */
    @NeedLogin
    public final static String getUserInfo = BuildConfig.SERVER_URL+"user/get/info";
    /**
     * 获取个人中心角标提醒接口
     */
    @NeedLogin(UpdateUserInfoActivity.class)
    public final static String getUCenterSuperscript = BuildConfig.SERVER_URL+"user/personal/center/marks";
}

​

这样我们就实现了业务层、会话层、通讯层的隔离。最重要的几个关键词是:注解切面拦截,Request拦截,Respnse拦截。


以上是我对会话管理模块的设计思路,有什么不好的地方,还请大家多多指教。

 

没有更多推荐了,返回首页

私密
私密原因:
请选择设置私密原因
  • 广告
  • 抄袭
  • 版权
  • 政治
  • 色情
  • 无意义
  • 其他
其他原因:
120
出错啦
系统繁忙,请稍后再试