功能描述
在 APP 中有一个积分抽奖的 H5 页面,要求 抽奖H5 的登录状态必须和本地的登录状态一致,也就是说:如果尚未登录,点击 H5 的抽奖按钮则跳转登录,如果已经登录那么则直接可以抽奖。
早期开发描述
用户可用的 cookie 是在 登录接口 中返回的,但是对应在 Android 和 IOS 两个平台却有着不同的表现:
IOS : IOS 平台在请求接口之后,对于 WebView 而言,就也处于登录状态,也就是说,在调用 登录接口 之后,就可以抽奖了,不需要任何其他的代码,猜测: IOS 统一管理了 cookie ,即 IOS 将 接口请求产生的 cookie 和 WebView 浏览网页产生的 cookie 放在了一处,甚至于可以混合使用对方的 cookie.(PS: 这虽然是个猜测,但是从现象上来看,八九不离十,另外这个猜测很重要!)
Android: 而在Android这边,和IOS完全不同,在调用了 登录接口 之后,WebView 的状态依然处于未登录状态,这样看下来,Android 平台并没有对 接口请求 相关的 cookie 做相应管理,换句话说 Android 中请求所产生的 cookie 根本没有被记录下来。所以才不得不使用 CookieManager 对 WebView 进行手动的 cookie 机制(仿照浏览器)。
相同点: 当然作为一个标准浏览器,在关闭 app 之后,cookie 自然就会被清理掉,这一点不管是 IOS 还是 Android 平台都遵循了这个规则。于是当第二次再次进入 App 之后,本地认为自己是 登录了,但是再次调用 WebView 时,WebView 中的 cookie 其实已经被清除掉了,H5 自然也就认为自己没有登录。所以,为了统一登录状态,必须在 App 每次开启都将 WebView 的 cookie 动态强制设置成上一次登录成功后的 cookie。(事实上,IOS 暂且不提,因为我了解有限,但是 Android 平台上,如果不在 App 每次启动的时候,调用 CookieManager 设置 cookie,不同的手机的表现却令人摸不着头脑,有的手机则如之前所描述的标准浏览器的规则,有的则是,本地和 WebView 的登录状态是一致的,就好像浏览器状态永远保持着,倒是没有试过关机会不会清除状态 cookie,还有的手机保持了一段时间的 cookie 后,貌似就清除了,因为登录状态也不一致了。这也算是 Android 碎片化 的问题之一?)
最初实现
**重要信息:**在登录接口中的 cookie 包含 sid 、path、还有过期字段: sid=54f85966-ed12-48be-9910-f9c01cadc8e4;Path=/1bPlus-web;Max-Age=2592000; Expires=Thu, 12-Jul-2018 10:06:10 GMT; HttpOnly
,在保存 sid 信息的时候,项目值保存了,sid相关的部分:sid=54f85966-ed12-48be-9910-f9c01cadc8e4
。
IOS: IOS 平台在登录接口的回调中,将 接口的 Response Header 中的 cookie 记录在了本地,也仅仅只是记录 sid = "我是一段hash"
。然后在 APP 重启的时候通过 api 动态设置当前 WebView 的 cookie 为之前登录后记录下的 cookie ,没有设置 path,那么默认就是 Path=/
。
Android: Android 平台,在 登录接口 的回调中,不仅仅需要将 cookie 记录在本地,还需要将 cookie 手动的设置到 WebView 中,这样才能刷新 APP 中 WebView 的登录状态。另外在 APP 启动时,也必须设置当前 WebView 的 cookie 为之前登录后记录下的 cookie ,没有设置 path,那么默认就是 Path=/
。
Android平台 问题的诞生
####重要参数相关:
base url: http://www.abc.com
抽奖 url: http://www.abc.com/1bPlus-web/....
项目中cookie设有30天的过期时间
在用户退出登录后,app 并没有清除 cookie。
在登录接口返回的cookie中,Path是/1bPlus-web
可以适当有个印象,在后面分析问题的过程中会用到
####代码
String sidCookie = parseSidCookie(response);
CommonUtils.syncCookie(LoginActivity.this, CommonUtils.getBaseUrl(), sidCookie);
public String parseSidCookie(Response response) {
List<String> headers = response.headers().values("set-cookie");
String sidCookie = null;
for (String header : headers) {
List<String> cookies = Arrays.asList(header.split(";"));
for (String cookie : cookies) {
if (cookie.contains("sid")) {
sidCookie = cookie.trim();
break;
}
}
}
return sidCookie;
}
public static boolean syncCookie(Context context, String url, String cookie) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.removeSessionCookies(null);
cookieManager.setAcceptCookie(true);
cookieManager.setCookie(url, cookie);
cookieManager.flush();
String newCookie = cookieManager.getCookie(url);
return !TextUtils.isEmpty(newCookie);
} else {
CookieSyncManager.createInstance(context);
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.removeSessionCookie();
cookieManager.setAcceptCookie(true);
cookieManager.setCookie(url, cookie);
String newCookie = cookieManager.getCookie(url);
CookieSyncManager.getInstance().sync();
return !TextUtils.isEmpty(newCookie);
}
}
仔细检查过代码,从流程上而言是正确的。
**重要:**问题就出在 cookieManager.setCookie(url,cookie);
这一行,其中 url 是项目的 host: 类似于 http://www.abc.com
, cookie: sid=我是一个测试的hash
,其中需要注意的是,保存&设置的 cookie 并没有保留 path 等字符串(看 parseSidCookie 方法)。那么该 cookie 默认的 path 自然就是根:/
。
####现象
现象0:用户在还未登录过的状态下,也就是从未保存&设置过 cookie 的状态,先进入 抽奖H5 页面,从 H5 唤醒登录界面,登录之后,返回抽奖页面,无法抽奖,点击没有效果,也就是 H5 认为当前用户并没有登录。即便退出登录,再次进入抽奖,本地和H5的登录状态依然不一致。
现象1:用户进入 app ,二话不说,直接打开登录页面,登录之后,进入抽奖页面,没有任何问题,就是项目想要的效果,并且即便退出app了,再次进入APP,也是没有问题的。
现象2:在 现象1 的基础上,用户已经登录过了,并且进入过抽奖页面。然后用户退出帐号,此时从 抽奖H5 唤醒登录,即便登录之后,也是无法抽奖。事实上,用户一旦浏览了任何和抽奖H5有相同Path的网页,然后,不管是从网页唤醒登录,还是主动登录,都是无法抽奖的。
####分析
cookie 的相关机制:
首先,需要知道的是,不管你是否登录,作为标准浏览器,WebView 只要打开了网页,那么总会产生cookie,并且记录下来,比如你先访问了一个网址: http://www.abc.com/article/yours/1
,产生了浏览器里面的第一个 cookieA0,然后我又访问了另外一个网址,按照规则会有以下情况:
-
如果访问的网址 依然在 /article/yours 这个Path 下,如果 cookieA0 依然有效,也就是用户既没有退出登录,该 cookie 也没有过期,那么服务器也就不会再给浏览器分配 cookie,此时相同 path 下,共用相同的 cookie。
-
但如果你访问了
http://www.abc.com/article/2
,又或者是http://www.abc.com/article/mine/1
,甚至是http://www.abc.com/others/1
,那么浏览器是找不到这些 Path(/article, /article/mine, /others
)相对应的cookie 的,因为浏览器只能找到/article/yours
所对应的cookie,相应的服务器就是为浏览器创造对应的cookie,供浏览器选择。 -
但是如果此时得到cookieA0是你先访问
http://www.abc.com/article/2
该网址所得到的cookie,那么此时你再访问http://www.abc.com/article/yours/1
,在没有/article/yours
对应cookie的情况下,/article
对应的cookieA0就会被找出来。这就是选择最优cookie的意思吧。举个栗子:假如此时浏览器里面保存了两个cookie的值分别是:cookieA1 对应的Path是/article
;cookieA2对应Path/article/yours
,如果我访问的网址是http://www.abc.com/article/yours/...
,那么浏览器找到的cookieA2,其实如果没有cookieA2,cookieA1也是合法的,不过在有更精确cookie值的情况下,自然选择更加精确的cookie值。
####现象分析:
现象0的分析: — 在用户从未登录过,本地没有cookie的情况下,先进入H5抽奖,此时立刻就产生了一个Path是/1bPlus-web
的cookieB0,然后通过该网页唤醒了登录界面,登录成功后通过CookieManager设置了登录接口所返回的cookieB1,代码:
...
url = base url;
//前面有提到,没有设置path的cookie对应的就是根目录 /
cookieB1 = "sid=我就是个hash,没有Path哦亲!"
cookieManager.setCookie(url, cookieB1);
...
在运行完以上的代码后,现在在本地WebView里面,就存在了两个 cookie, 它们分别是对应着Path是/1bPlus-web
的cookieB0,还有对应着根目录/
的cookieB1。
**问题的原因:**在登录之后,服务器返回的它认为的登录合法的cookie是cookieB1,也就是如果我想让服务器也认为我登录了,那么我WebView带过去的cookie也必须是cookieB1。但实际上,从前面cookie的机制中可以了解到,在本地的两个cookie: cookeB0对应的Path是/1bPlus-web
,cookieB1对应的是/
,那么当你访问网址为http://www.abc.com/1bPlus-web/....
的抽奖H5时,所能找到的cookie就只能是cookieB0,和服务器所认为的合法的cookieB1,永远无法对应上!在这种情况下,即便用户退出帐号,重新登录,也毫无作用。
现象1的分析:
用户不打开任何Path包含/1bPlus-web
网址,而直接登录,那么即便本地有很多不同的cookie,但是在此时进入抽奖H5时,能够被WebView找到合法的cookie,只有登录成功后所记录下来,对应Path是根/
的cookie,其他的cookie因为Path无法对应所以也就不合法。此时本地能所能提供的cookie和服务器认为合法的cookie,就是一致的,自然可以抽奖。即便退出app,再次打开,前面我提到过,不管是android还是IOS都会在重新启动app的时候,把cookie重新设置进WebView。在APP退出后,WebView所有cookie会被全部清除(即便不清除,之前本身就只能认定Path是/
的cookie合法),此时设置的cookie所对应的path即便是/
,此时进入H5抽奖,也只能找到/
所对应的cookie,而且此cookie也是合法的未过期的,那么自然没有任何问题。
现象2的分析:
既然现象2是基于现象1的,从现象1的分析中,可以知道,此时状态是正确的,并且本地也就只有一个对应/
的cookie,这里把它称作cookieC0。
当用户退出帐号,前面提到过,并不会对本地的cookie做任何处理,那么在这种情况下,首先服务器会将之前合法的cookieC0,认定为失效不合法的cookie,那么进入H5抽奖(不单单是H5抽奖,任何与抽奖有相同Path的网页都一样),通过网页唤醒登录:此时进入抽奖,找到了本地的cookieC0,但是服务器认为这是不合法的cookie,会为该网页重新分配一个合法的cookieC1,此时cookieC1所对应的Path就是抽奖网址的path — /1bPlus-web
,于是又回到了现象0的情况(可以看现象0的分析),本地的cookie和服务器的cookie不一致!
为什么IOS没有问题?
相同的实现机制,Android出问题了,但是IOS却没有出问题,这是为什么?其实在前面的章节—最初实现描述中已经提到过,对于接口请求中所产生的cookie,Android平台没有做任何处理,直接忽视了;但是IOS却是将其也管理了起来。相同的代码逻辑,保证了两个平台在登录成功后,本地都有了Path为/
的cookie,但是IOS统一管理cookie的机制,却又相当于在本地多了一个Path为1bPlus-web
的cookie(可以参考下前面提到过的重要参数),于是不管怎么操作,IOS平台的WebView永远能够找到合法的cookie。
问题解决
**方法一:**对于没有特殊需求的app,可以将removeSessionCookie()替换成removeAllCookie()
再次粘贴下设置cookie的方法:
public static boolean syncCookie(Context context, String url, String cookie) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager cookieManager = CookieManager.getInstance();
//请关注这个方法 removeSessionCookie
cookieManager.removeSessionCookies(null);
cookieManager.setAcceptCookie(true);
cookieManager.setCookie(url, cookie);
cookieManager.flush();
String newCookie = cookieManager.getCookie(url);
return !TextUtils.isEmpty(newCookie);
} else {
CookieSyncManager.createInstance(context);
CookieManager cookieManager = CookieManager.getInstance();
//请关注这个方法 removeSessionCookie
cookieManager.removeSessionCookie();
cookieManager.setAcceptCookie(true);
cookieManager.setCookie(url, cookie);
String newCookie = cookieManager.getCookie(url);
CookieSyncManager.getInstance().sync();
return !TextUtils.isEmpty(newCookie);
}
}
打开CookieManager,看到对 removeSessionCookie 方法的解释
Removes all session cookies, which are cookies without an expiration
很明显是,这是用来删除没有过期时间的cookie的。其实在更早之前,app本来使用的是removeAllCookie(),从方法名就可以明白,这个方法是清除所有缓存的cookie,如果在登录后,先将所有cookie都清除掉,再设置Path是/
的cookie,那么就没有任何问题了(有些不明白的话,可以看下上面的cookie机制,还有现象分析)。
之后的改动,是为了保证引入的环信客服Web页面(从我3年前开始写代码,尽跟环信打交道了…狻猊很),在关闭该页面后,在app关闭之前,都能保留用户与客服的交流记录。所以为了这一需求,才将removeAllCookie()改成了removeSessionCookie(),前面。提到过,服务器返回的cookie也是有过期时间的,客户端和服务端对于Android平台cookie机制的不理解,以及IOS平台不同cookie管理机制的干扰下,造成了这一费解的bug。
**方法二:**现象分析中,可以看出,其实归根结底就是找不到Path是1bPlus-web
的合法cookie,那么其实仿照IOS平台,Android自己来管理接口cookie,譬如将刷新cookie的方法改造下:
public static boolean syncCookie(Context context, String url, String cookie) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.removeSessionCookies(null);
cookieManager.setAcceptCookie(true);
cookieManager.setCookie(url, cookie);
//敲黑板了,这里是重点
cookieManager.setCookie(url, cookie+";Path=/1bPlus-web");
cookieManager.flush();
String newCookie = cookieManager.getCookie(url);
return !TextUtils.isEmpty(newCookie);
} else {
CookieSyncManager.createInstance(context);
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.removeSessionCookie();
cookieManager.setAcceptCookie(true);
cookieManager.setCookie(url, cookie);
//敲黑板了,这里是重点
cookieManager.setCookie(url, cookie+";Path=/1bPlus-web");
String newCookie = cookieManager.getCookie(url);
CookieSyncManager.getInstance().sync();
return !TextUtils.isEmpty(newCookie);
}
}
于是在运行了上面的代码后,本地就像IOS一样缓存了两个Path下的cookie,并且其实是一样的,完美解决。app现在就是这么改的。
**当然还有个方法三:**其实,如果让后台把cookie改成没有过期时间的,那么removeSessionCookie()这个方法会向removeAllCookie()一样,把本地的所有cookie都清除了,那么就像现象1中所述的一样了,在登录后本地就只剩下对应/
的唯一一个cookie了,啊哈哈哈哈…不言中~不言中~
**Max-Age:**最后还需要注意的就是Max-Age,cookie的过期时间的问题(文章最后我会贴一篇关于cookie的文章)。关于Max-Age Android 平台的试验:在我没有打开过任何网页的情况下,登录后,我将设置cookie的代码改动了,如下:
cookieManager.setCookie(url, cookie+";Path=/1bPlus-web;Max-Age=25");
在过了25S之后,进入抽奖,抽奖异常。所以本地的Max-Age也是有效的。更多的关于Cookie可以看下面的参考链接,足够详细。另外,前面在早期开发描述中提到过,两个平台在关闭APP之后,就清除Cookie,这也是因为没有设置Max-Age,默认是-1。
至此,这个bug算是结束了,这个Bug断断续续折腾了项目挺长时间,后来总结了下原因:
- 对cookie机制的不熟悉
- 对Android&IOS平台cookie的不熟悉
- bug最初发生的时候,没有进行深入的思考
感谢我Leader帮着我分析到晚上8点…提供了相当多的参考想法(解决后我又有些不好意思的问了好几次后台细节问题,然后我Leader不厌其烦的跟我说了两遍,感谢!!!)
最后,文章中如果又错误的地方,请读者留言指出,最好给个相关链接,不胜感激了!
参考文章:
理解Cookie和Session机制