这已经是这个系列第五篇了。前面几篇把整个博学谷项目的框架、主页、课程列表、详情页一路搭了下来,到这一篇终于轮到用户模块——“我的”。这个模块在电商、教育、工具类App里都长得差不多,上面一个头像、中间一堆功能入口、底部再来个退出登录。看起来简单,实际上它涉及登录态、用户信息持久化、头像选择与裁剪、缓存清理、版本更新提示这些杂活,几乎把客户端本地存储和界面交互的边边角角都摸了一遍。
如果你现在打开Android Studio,写个个人中心可能半天就完事。但在Eclipse + ADT那个年代,没有Gradle,没有AndroidX,连依赖包都要自己往libs里扔,稍不注意就是R文件丢失、图片OOM、中文乱码三座大山。这篇就按我当时做博学谷项目“我”模块的完整过程来写,从页面设计、存储方案、核心逻辑到一路踩过的坑,尽量把能“抄作业”的细节都放出来。
1. 模块拆解:这个“我”字背后藏着哪些活儿
1.1 页面上看到的部分
先别急着写布局,把“我的”页面打开,对着线框图一项项列。当时的博学谷项目需求文档里,“我的”模块主要承担三块内容:
- 用户信息区:头像、昵称、个人签名(一句话介绍),点击可以进入个人资料编辑页。
- 功能入口区:我的课程、我的收藏、我的笔记、意见反馈、检查更新、清除缓存、关于我们。
- 账户操作区:退出当前账号。
这几块内容层级差异很大。用户信息区是核心,每个页面几乎都要访问;功能入口区是跳板,点击后进入各自业务页面;账户操作区则涉及登录态的清理。
我最初的方案是把整个页面做成一个ScrollView套各种自定义组合布局,后来发现入口太多、代码太冗余,改成了布局拼装。顶部信息区用线性布局或相对布局直接写在主XML里,下面的入口区用一个主容器,代码里循环生成条目,或者直接在XML里写死十几个TextView——考虑到后续要维护,我推荐用封装好的单条Item布局,反复include,代码清晰得多。
1.2 页面背后那些不可见的逻辑
界面只是表象,“我的”模块真正的复杂度在下面几层:
- 登录态管理。App冷启动时要知道用户是否已登录,才能决定显示“默认头像+点击登录”还是“真实头像+昵称”。
- 用户数据存储。昵称、头像、签名这些信息总不能每次打开App都去服务器拉,需要本地缓存一套。
- 头像选择的跨界面通信。从相册选图、裁剪、上传、回显,每一步都涉及临时文件、权限和Bitmap处理。
- 缓存统计与清理。要统计App缓存大小,还要在清理后把界面数字刷新回来。
很多初学者容易把“我”的模块当成纯静态UI来做,觉得放几个TextView就够了。真要到上线级别,这层皮下面每一行都是逻辑。拿缓存来说,你需要在SD卡和应用私有目录里遍历文件算大小,再递归删除,还要小心别把用户数据库和SharedPreferences文件删了,否则用户要崩溃。
1.3 数据存储方案的取舍
开发前我在用户信息到底存哪这个问题上犹豫过。选项有三个:纯内存、SharedPreferences、SQLite。
纯内存肯定不行,App一重启就丢。SQLite能干,但为了存十个字段专门建库建表写DAO,多少有点小题大做。最终的方案是:用户基本信息(昵称、头像路径、签名、登录Token)放进SharedPreferences,方便随时取;和学习强相关的数据(课程记录、收藏列表)放进SQLite。这个分工在项目里实测下来非常舒服,读用户信息不需要查表,写的时候也是apply一把梭,简单可靠。
SharedPreferences本质是一个XML文件,存在应用的私有目录里,不需要权限就能读写。里面存String、int、boolean这些基础类型完全够用。头像这种图片文件不能直接塞进XML,存的是文件路径,图片本身放到getExternalFilesDir或者缓存目录。这套套路在当时算标准答案,放到现在也还是常规做法。
2. 环境准备与关键技术选型
2.1 Eclipse与ADT的版本搭配
用Eclipse做Android开发,第一道坎是环境。我当时用的Eclipse Luna,ADT插件的版本大概在23左右,配套的SDK Build Tools是23以上。如果你是新装环境,要特别注意JDK版本和Eclipse的位数必须一致,否则“Unrecognized VM option”这类报错能把人整崩溃。
项目结构上,Eclipse的Android工程没有Gradle的module概念,就是一个Android Application Project。src是Java代码,res是资源,gen目录会自动生成R.java,千万别手贱去改。libs里放第三方jar包,然后右键Link Source或者直接放进去,ADT会自动把它打进APK。博学谷项目里没有引入太多第三方库,因为用了很多自定义控件,第三方依赖少反而清爽。
还有一个老生常谈的问题:Eclipse的编码格式默认是GBK,而这个项目里所有文件我都用UTF-8保存。也正因如此,模拟器上中文乱码几乎成了必现问题,后面专门讲怎么处理。
2.2 用户信息存储:SharedPreferences为主、SQLite为辅
前面说过,用户信息我存在SharedPreferences里。这里给出我实际用的几个键名,结构一目了然:
public class UserLocalStore { private static final String SP_NAME = "user_info"; private static final String KEY_LOGIN_STATUS = "is_login"; private static final String KEY_USER_ID = "user_id"; private static final String KEY_NICKNAME = "nickname"; private static final String KEY_SIGNATURE = "signature"; private static final String KEY_AVATAR_PATH = "avatar_path"; private static final String KEY_TOKEN = "token"; }封装一个UserLocalStore类,把读写方法集中管理。比如保存登录信息的方法:
public void saveUserInfo(User user) { SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = sp.edit(); editor.putBoolean(KEY_LOGIN_STATUS, true); editor.putString(KEY_USER_ID, user.getUserId()); editor.putString(KEY_NICKNAME, user.getNickName()); editor.putString(KEY_SIGNATURE, user.getSignature()); editor.putString(KEY_AVATAR_PATH, user.getAvatarPath()); editor.putString(KEY_TOKEN, user.getToken()); editor.apply(); }SharedPreferences的很多坑都出在commit和apply的混用上。apply是异步写盘,效率高;commit是同步写盘,有返回值。我当时在退出登录时用了commit,因为需要立刻拿到true/false来决定要不要跳转登录页。在保存用户信息时用apply就够,别听人说什么apply丢数据——在Activity生命周期结束前调用apply,系统会等写盘完成,这个坑在后来无数项目里基本没再犯过。
2.3 头像与图片加载方案
当时没有Glide,项目里加载图片的方案很原始:ImageView直接setImageBitmap,或者用BitmapFactory.decodeFile加载本地文件。
第一步是头像来源,我做了两个入口:拍照和相册选择。拍照需要设一个File的Uri,把拍完的照片存进getExternalCacheDir(外部缓存目录,不需要任何存储读写权限),然后通过Intent传过去。相册选择用Intent.ACTION_PICK,从系统相册拿图。
拿到原始图片之后必须做两件事:压缩和裁剪。系统裁剪Intent是最省事的做法:
Intent cropIntent = new Intent("com.android.camera.action.CROP"); cropIntent.setDataAndType(imageUri, "image/*"); cropIntent.putExtra("crop", "true"); cropIntent.putExtra("aspectX", 1); cropIntent.putExtra("aspectY", 1); cropIntent.putExtra("outputX", 200); cropIntent.putExtra("outputY", 200); cropIntent.putExtra("return-data", true);注意,这个系统裁剪Activity在不同厂商ROM上表现不一样,有的机型会闪退或直接跳过。为了稳定,我后来自己写了个简单的裁剪页,或者退一步用“方形头像不做圆角,直接将相册图片缩放居中显示”的方案。在一套项目里,稳定性优先级远高于功能华丽度。
3. 从零搭出“我”的界面
3.1 整体布局搭建
我把“我的”页面的根布局写成了垂直方向的LinearLayout,好处是层级简单,坏处是顶部信息区、功能入口区、底部退出按钮三块挤在一起时,需要靠权重和滚动配合。
实际项目的布局是:外层一个LinearLayout,内部放一个ScrollView包住内容主体,底部再把退出登录按钮固定在根布局下部。这样内容多了可以滚,退出按钮始终可见,交互上最顺手。
用户信息区用了一个背景为主题色的RelativeLayout,高度大概120dp。里面左侧放头像ImageView,右侧放昵称TextView和签名TextView,整个区域再盖一个透明的点击监听,点击跳个人资料页。头像用BitmapUtils工具类加载,如果读到本地路径不存在,就展示默认头像。
功能入口区我封装了一个Item条目,包含左侧图标、中间标题、右侧“>”箭头,这个条目反复复制使用:
<LinearLayout android:layout_width="match_parent" android:layout_height="50dp" android:orientation="horizontal" android:gravity="center_vertical" android:background="@drawable/item_selector"> <ImageView android:id="@+id/iv_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_marginLeft="15dp"/> <TextView android:id="@+id/tv_title" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginLeft="12dp" android:textSize="14sp" android:textColor="#333333"/> <ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginRight="15dp" android:src="@drawable/arrow_right"/> </LinearLayout>这里要重点说item_selector,这是当时很常见的点击反馈技巧。selector文件放在drawable目录下,默认一个背景,按下状态一个颜色,比如浅灰色。
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_pressed="true"> <color android:color="#F0F0F0"/> </item> <item> <color android:color="#FFFFFF"/> </item> </selector>把这个文件设置为条目的background,点击时自动出现按压反馈,不需要在Java代码里绑监听,性价比极高。
3.2 圆形头像与圆角处理的土办法
圆形头像在现在的App里是标配,但在Eclipse年代,没有现成的CircleImageView可以用,只能自己写一个圆形裁剪,或者用BitmapShader。
我当时用的方案是自定义一个CircleImageView类,核心代码就一段:
@Override protected void onDraw(Canvas canvas) { if (bitmap == null) { super.onDraw(canvas); return; } canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, getWidth() / 2f, paint); }关键是初始化时设置paint的Shader:
BitmapShader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); paint.setShader(shader);这段代码有个前提:bitmap必须是正方形,否则圆会被拉伸变形。所以在设置头像前,必须先把图片裁剪成正方形,再丢给CircleImageView。我写了一个BitmapUtil.cropToSquare()方法,居中截取最长边,简单实用:
public static Bitmap cropToSquare(Bitmap bitmap) { int width = bitmap.getWidth(); int height = bitmap.getHeight(); int side = Math.min(width, height); int x = (width - side) / 2; int y = (height - side) / 2; return Bitmap.createBitmap(bitmap, x, y, side, side); }3.3 列表项点击态与自定义弹窗
功能入口的点击事件,我当时在XML里的条目上都加了一个android:onClick="onItemClick",然后在Activity里写一个统一的onClick方法,通过view.getId()区分条目。相比给每个Item单独setOnClickListener,这样代码集中度更高,也方便后期在同一个方法里埋点统计。
个人资料的修改需要弹窗。Android自带的AlertDialog样式太丑,我自定义了一个Dialog布局,放两个EditText,一个取消一个确定。自定义Dialog要记住设置window的背景为透明,否则四周会有默认白边。关键代码:
Dialog dialog = new Dialog(this); dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); dialog.setContentView(R.layout.dialog_edit_info); Window window = dialog.getWindow(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));窗口的宽度建议设置成屏幕宽度的85%,太宽显得突兀,太窄输入费劲。当时没有PercentRelativeLayout,所以用WindowManager拿到屏幕宽高,再按比例计算:
WindowManager.LayoutParams params = window.getAttributes(); params.width = (int) (screenWidth * 0.85); dialog.getWindow().setAttributes(params);这套方案在Eclipse项目里非常通用,后来我迁移到Android Studio也照样沿用。
4. 核心业务逻辑实现
4.1 登录状态拦截
“我”的模块有一个关键交互逻辑:未登录时,点击头像、我的课程、我的收藏等入口,不能直接进对应页面,而是先跳转登录页;已登录时才能继续。
这个逻辑不只在“我的”页面用,写课、评论、下单前的判断都要。我在基类BaseActivity里写了一个工具方法:
protected boolean checkLogin() { if (UserLocalStore.isLogin(this)) { return true; } else { ToastUtils.showShort(this, "请先登录"); startActivity(new Intent(this, LoginActivity.class)); return false; } }然后在每个需要登录的按钮点击事件里第一行调用:
if (!checkLogin()) return; // 继续执行已登录的逻辑这套写法简单粗暴,但项目里用得很顺手。唯一要注意的是,登录成功后回到原页面,原页面的数据不会自动刷新。必须在Activity的onResume里重新加载一遍用户数据,或者使用startActivityForResult并在onActivityResult里处理。我在项目里两种都用了,建议组内统一用onResume刷新,不容易漏。
4.2 数据回显与二次校验
用户信息区的数据加载,在onResume里统一做:
private void initUserInfo() { boolean isLogin = UserLocalStore.isLogin(this); if (isLogin) { nickname = UserLocalStore.getNickname(this); signature = UserLocalStore.getSignature(this); avatarPath = UserLocalStore.getAvatarPath(this); tvNickname.setText(nickname); tvSignature.setText(TextUtils.isEmpty(signature) ? "这个人很懒,什么都没留下" : signature); if (!TextUtils.isEmpty(avatarPath)) { Bitmap bitmap = BitmapUtil.loadLocalBitmap(avatarPath); if (bitmap != null) { ivAvatar.setImageBitmap(bitmap); } } } else { tvNickname.setText("点击登录"); tvSignature.setText("登录后享受更多服务"); ivAvatar.setImageResource(R.drawable.ic_default_avatar); } }这段代码里我吃过的亏有两个。第一,签名和昵称从SharedPreferences取出来可能是空字符串,直接setText会导致UI上留下一块空白,所以要判断TextUtils.isEmpty并设置默认文案。第二,头像路径可能指向一个已经被删除的临时文件,此时BitmapFactory.decodeFile返回null,整个ImageView就会变空白。必须先判空,再setImageBitmap。
4.3 修改资料与头像上传
修改资料包括昵称、签名、头像三者。
昵称和签名我用同一个自定义Dialog处理。Dialog弹出时,把当前值放在EditText里,确定后先做非空校验,通过后调用保存方法。
这里有个细节:接口那边要求昵称不能为空,但签名可以。校验逻辑要区分字段。我当时封装了一个TextUtil.isEmpty,然后分别针对两个EditText判断。
头像上传是个大工程。流程是:点击头像 → 判断登录 → 弹出选择框(拍照/相册)→ 得到图片Uri → 裁剪 → 压缩 → 保存到本地文件 → 更新SharedPreferences里的avatar_path → 刷新ImageView。
压缩这步最容易漏。手机拍出来的照片动辄3MB以上,不压缩就加载到内存里,200dp的ImageView用不了那么多像素,白白导致OOM。我的压缩函数大致是这样:
public static Bitmap getCompressedBitmap(String filePath, int reqWidth, int reqHeight) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(filePath, options); int width = options.outWidth; int height = options.outHeight; int inSampleSize = 1; while (width / inSampleSize > reqWidth || height / inSampleSize > reqHeight) { inSampleSize *= 2; } options.inSampleSize = inSampleSize; options.inJustDecodeBounds = false; return BitmapFactory.decodeFile(filePath, options); }这个inSampleSize采样压缩是当时解决OOM的通用套路,先解析宽高,再按比例抽样,最后一次性加载到内存。reqWidth和reqHeight我传的是200,配合系统裁剪输出尺寸,一张头像内存占用能控制在200KB以内,在低端模拟器上也能流畅显示。
4.4 意见反馈与检查更新
功能入口里,“意见反馈”和“检查更新”是我认为最能体现工程完整度的两个页面。
意见反馈页面,我给用户提供了一个EditText输入框和提交按钮。提交时先做空判断,再弹一个“提交成功”的Toast,关闭页面。实际项目中应该接网络接口,但因为博学谷的后端模拟接口没有完全就绪,我先用了本地保存的方式,把用户反馈写到SharedPreferences里,等之后的版本再接网络。
检查更新逻辑当时也做了本地模拟。点击检查更新,如果本地SP中保存过“最新版本号”,和当前版本号对比,版本号不同则弹出对话框提示有新版本;否则Toast提示已经最新版本。虽然没接真实的更新下载接口,但完整的流程和状态判断已经有了,后面接服务器时只需要替换那一个网络请求方法。
4.5 清除缓存计算与退出登录的起于止
清除缓存这个功能,很多App里都做得很虚,点击后干脆弹个Toast“缓存已清理”就走了。这次项目里我把它做实了。
计算缓存大小的核心是递归遍历目录:
public static long getCacheSize(File dir) { long size = 0; File[] files = dir.listFiles(); if (files == null) return 0; for (File file : files) { if (file.isDirectory()) { size += getCacheSize(file); } else { size += file.length(); } } return size; }这里要遍历的目录一般有两个:getCacheDir()和getExternalCacheDir()。遍历的时候千万小心,别把getFilesDir()里的数据库文件也删了。做完遍历之后,在UI里显示:
String sizeDesc = Formatter.formatFileSize(this, totalSize);Formatter是Android自带的,自动把字节数转成KB、MB,比手写好用。
清理的操作就是递归删除文件。但deleteRecursively()这个方法当时 Java 7 还没普及,我用自己写的deleteAllFiles递归删。
退出登录在代码层面要做的就三件事:清空用户信息相关SP、跳转登录页、关闭当前页面。我在项目里加了一个小细节:清空SharedPreferences之前,先确认用户真的想退出,弹一个AlertDialog确认框。这个确认框别用默认的,稍微自定义样式,整个App的调性更统一。
5. 异常排查与避坑笔记
5.1 R文件彻底消失和switch-case失效
Eclipse开发里最经典的坑莫过于R.java突然不见。通常发生在两种情况下:有资源文件命名非法(比如文件名带了大写字母、数字开头),或者res目录下有文件被误删导致资源索引不合法。
解决办法很简单:先看res目录里有没有报红叉的资源文件,逐个修正;然后Project → Clean,勾选需要清理的项目,等ADT自动重建R.java。如果Clean完还没有,大概率是某个XML里引用了不存在的资源,这时候看Problems视图,一条条改。
第二个坑是ADT 22之后,R类里的字段不再是常量了,switch-case里不能直接引用R.id.xxx,会报case表达式必须是常量的错误。我一开始很纳闷,后来才明白这是Google官方坑。解决方法也简单,把switch改成if-else:
int id = view.getId(); if (id == R.id.btn_edit_info) { // ... } else if (id == R.id.btn_my_course) { // ... }别想着用IDE自动修复,它不会帮你改这个,老老实实手写。
5.2 头像加载图片OOM
个人中心最容易OOM的地方就是头像。相册里选一张1080P的大图,直接decode进内存,手机RAM稍微小点就崩。除了前面讲的inSampleSize压缩,还要注意Bitmap用完之后的回收。在切换头像或页面销毁时,回收旧Bitmap:
if (oldBitmap != null && !oldBitmap.isRecycled()) { oldBitmap.recycle(); }不过这里有坑:如果一个Bitmap被多个ImageView引用,回收后其他ImageView再draw就会崩溃。我的经验是:在个人资料页头像处加载头像时,每次都用cropToSquare返回一个新Bitmap,旧Bitmap先recycle;不要直接复用同一个全局Bitmap对象给多个页面,否则回收时机很难控制。
5.3 中文乱码与Eclipse编码设置
做“我”模块时,很多人会直接在Java代码里写中文字符串,比如“退出登录”“我的收藏”,结果模拟器上全变成乱码。
根因是Eclipse默认工作空间编码是GBK,而项目文件是UTF-8。我当时的解决办法分三步:
- 右键项目 → Properties → Resource → Text file encoding,选择UTF-8。
- 如果已经乱码的文件,先全选内容复制出来,再把文件编码改成UTF-8粘贴回去。
- 打开Window → Preferences → General → Workspace,把Text file encoding改成UTF-8,让以后新文件默认就是UTF-8。
字符串资源我干脆全部抽到res/values/strings.xml里,不在Java代码里直接写字面量。这样即使编码出问题,改字符串一个文件就够,不用翻遍所有类。
5.4 模拟器性能问题与真机调试
Eclipse自带的Android模拟器启动慢、响应慢是出了名的,尤其是跑“我的”模块这种图片操作密集的页面。我建议直接拿真机调试,开发机开启USB调试,用adb install直接装,效率翻倍。
真机调试唯一要注意的是同一个App在Debug签名和Release签名状态下不能直接覆盖安装。我当时切换签名调试时,ADB经常报“INSTALL_FAILED_UPDATE_INCOMPATIBLE”,处理办法是卸载旧包重新装。如果不想反复卸载,可以在Eclipse的Run Configuration里指定Release签名覆盖Debug签名,但一般没必要,开发阶段Debug足矣。
5.5 列表条目复用导致的数据错乱
我的入口区虽然XML写死,但“我的课程”列表页里使用了ListView,item复用是绕不开的坑。在Adapter的getView里,如果不给ImageView设默认图、不给TextView设默认文本,滑动时就会看到错乱的数据。
我在博学谷项目里遇到过典型场景:第一行是“已购买”状态,第二行是“未购买”状态,滑动到后面,未购买的行却显示成了已购买。原因就是item复用了,convertView还在,但position变了,holder里绑定的数据没清零。解决方案是在getView开头把所有控件重置:
TextView tvState = holder.tvState; tvState.setText(""); ivCover.setImageResource(R.drawable.default_cover);先把状态清空,再根据数据赋值,这样无论item怎么复用都不会出现残留问题。
我最后想补充的一点实操心得
“我的”模块虽然处在App的边缘位置,却是最能体现一个开发者对状态管理、生命周期和异常处理理解深度的模块。如果你真想做透,不要只停留在布局和点击事件上,试着把头像加载、缓存清理、登录态切换这些细节全部真实跑通一次,收获远比写一百个“Hello World”快。
这个模块未来要扩展的方向也很明确:加入消息提醒红点、用户等级、签到积分、多语言切换入口,都是在现有功能入口区上加条目的事。只要布局和逻辑分层做得足够清晰,后续加需求会非常从容。
做完博学谷的“我”模块,我对Eclipse这个老古董的怨气消了不少。很多问题都是自己不注意编码规范、不熟悉资源管理机制带来的,跟IDEA本身没多大关系。工具是旧,但Android开发的根基逻辑一点都没变。到现在写新项目,我依然会想起当年在Eclipse里一遍遍Clean、重启、找R文件的时光——那时候没有gradle帮忙,每一行路径配置都靠手工,也正是那段经历,让我对Android工程结构的理解比后来直接上手Studio的开发者扎实得多。