Android个人中心模块开发实战:布局、列表与登录状态管理
1. 项目背景与“我”的模块整体拆解说到 Android 入门实战很多老开发者的第一反应还是那个经典的“博学谷”系列教学项目。别看它界面朴素、技术栈偏老但它把 Android 开发里最常用的一套东西全串起来了Activity 跳转、ListView 列表、SharedPreferences 本地存储、自定义 Dialog 封装再加上一个典型的“我的”个人中心页面。如果我让你直接上手做一个电商 App 的个人中心你可能一时间不知道怎么下手但跟着博学谷的节奏把这第五期“我”的模块敲完你会突然发现原来那些看起来功能很多的 App 设置页、个人主页底层逻辑都差不多——一个列表加几个界面跳转再加一套登录状态管理。“我”的模块在大学里一般叫用户中心或个人中心放在 App 底部导航的最右侧。它的核心任务是三块展示当前用户的头像、昵称、简介提供我的收藏、我的课程、我的下载这类功能入口处理登录、退出登录和缓存清理。从学习角度来看这个模块最值钱的地方不在于界面有多炫而在于它是全项目里唯一一个强依赖“全局数据状态”的地方——你必须处理好“用户没登录时显示什么”“登录成功后怎么刷新界面”“退出登录后怎么清空本地数据”这一整条链路。这个模块适合谁来参考如果你是刚学完四大组件、对布局还算熟悉、但一碰到“多个界面共享同一份数据”就犯迷糊的初学者这一期内容就是为你准备的。如果你在工作里已经写了两三年业务代码也可以看看这里面的数据管理思路虽然技术细节旧了点但分层思想不过时。我下面会把整个模块拆成界面搭建、数据存储、页面跳转逻辑、踩坑记录四块来讲每一块都会给出我觉得最实用的做法。2. 界面布局设计与实现思路2.1 页面结构分析为什么用“顶部信息区 中部菜单列表”做个人中心页面最忌讳一上来就堆控件。你先想清楚这个页面要呈现几类信息再决定布局结构。“我”的模块信息密度不高核心就两类一是用户身份信息头像、昵称、一句话签名二是功能入口列表收藏、课程、下载、设置等。这两类信息结构差异明显强行塞进同一个 ListView 会让适配器代码变得很啰嗦。我当时采用的是“垂直方向的整体布局”最外层用 LinearLayout 设置垂直排列顶部放一个相对独立的用户信息区下面用一个 ListView 填充功能菜单。用户信息区用 RelativeLayout 来做左侧是头像右侧是昵称和描述文字。这一步看起来简单但有一个细节值得注意头像的圆角效果不要用图片硬裁而是用 Shape Drawable 作为 ImageView 的背景再往里面嵌套一层圆形的图片或占位图。这样做的好处是加载失败时至少有一个灰色的圆底兜底界面不会显得突兀。菜单列表我选择了 ListView。你可能会问现在都推荐 RecyclerView 了为什么还在用 ListView答案是 Eclipse 时代的 Android 教学项目本身就停留在 API 19 左右而且 ListView 的适配器写法更直观——三个方法 getCount、getItem、getView背下来就懂列表原理了。菜单项本身是“图标 标题 右侧箭头”的结构我用一个自定义的 item_menu.xml 布局在 getView 里填充数据。菜单数据用数组或 List 存每一项对应一个名称和一张本地 drawable 图标。2.2 UI 细节处理分割线、点击水波纹、空态占位UI 细节往往决定这个页面“像不像正经产品”。ListView 每个 item 之间默认是没有分割线的你需要给 ListView 设置 android:divider 属性并指定分割线的高度通常用 1dp 的浅灰色线就能达到“若隐若现”的效果。还有一种做法是让 item 自身带一个底边距和背景视觉效果更像是卡片流但老项目里用系统 divider 就足够了。点击反馈也需要单独处理。在 XML 里给 item 根布局设置 android:backgrounddrawable/item_selector这个 selector 文件里定义 pressed 状态下的浅灰色背景和 default 状态下的白色背景。不要小看这个 5 分钟能写完的文件它直接影响整个列表的触感。忘了加的话点击列表项目时没有任何视觉反馈用户体验一下子降到“这是个半成品”的水平。空态占位也是容易被忽视的点。如果“我的”模块在用户未登录时头像区是灰色默认图、昵称显示“点击登录”那还不存在空态问题。但我的收藏、我的下载这类二级页面就要提前做好数据为空的布局。我用的是一个简单的 TextView 居中显示“暂无数据快去看看吧”后来想想其实应该加一个图标视觉上会更有引导性。2.3 登录状态 UI 切换逻辑“我”的模块有一个典型场景未登录时顶部信息区显示默认头像 “点击登录”登录成功后同一个位置要立刻显示用户名、手机号和用户头像。这个切换不能依赖手工 restart Activity那样会闪一下白屏体验很差。我的做法是在 ProfileActivity 里写一个 refreshUserInfo() 方法内部判断本地有没有保存用户信息。如果有就加载头像并设置昵称文字如果没有就显示默认头像占位图和“点击登录”的提示文字。在 onResume() 里调用这个方法保证每次从其他页面返回当前页时都会重新执行一次判断。这个方法名我用了好几年直到现在做 Flutter 项目时还习惯叫 refreshUserInfo思路完全一致。注意Android 的 onResume 是每次界面回到前台都会回调的而 onCreate 只在页面创建时执行一次。凡是“从别的页面回来后状态需要刷新”的场景都要把刷新逻辑放在 onResume而不是 onCreate。这是初学者最容易犯的错误也是这次项目里最容易验证的一个知识点。3. 核心代码实现布局文件与适配器编写3.1 整体布局 activity_profile.xml 关键代码这部分我不贴完整文件只挑核心骨架讲解。整个页面布局如下LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical RelativeLayout android:idid/user_info_area android:layout_widthmatch_parent android:layout_heightwrap_content android:padding20dp android:backgroundcolor/top_bar_blue ImageView android:idid/iv_user_avatar android:layout_width60dp android:layout_height60dp android:layout_alignParentLefttrue android:layout_centerVerticaltrue android:backgrounddrawable/bg_avatar_circle android:srcdrawable/ic_default_avatar/ TextView android:idid/tv_user_name android:layout_widthwrap_content android:layout_heightwrap_content android:layout_toRightOfid/iv_user_avatar android:layout_marginLeft15dp android:layout_centerVerticaltrue android:text点击登录 android:textColor#FFFFFF android:textSize18sp/ TextView android:idid/tv_user_desc android:layout_widthwrap_content android:layout_heightwrap_content android:layout_toRightOfid/iv_user_avatar android:layout_belowid/tv_user_name android:layout_marginLeft15dp android:layout_marginTop8dp android:text登录后享受更多权益 android:textColor#D0E8FF android:textSize13sp/ /RelativeLayout ListView android:idid/lv_menu_list android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:dividercolor/divider_gray android:dividerHeight1dp android:listSelectordrawable/item_selector/ /LinearLayout这里有两个关键点值得展开说。第一个是头像的圆形背景 bg_avatar_circle它本身是一个 XML Shape 文件代码如下shape xmlns:androidhttp://schemas.android.com/apk/res/android android:shapeoval solid android:color#FFFFFF/ /shape这个白色圆底配合灰色默认头像图标视觉效果立刻比一个裸的方形图片舒服很多。如果你要做有描边的圆形头像再加一个 stroke 节点设置 1dp 的浅色描边即可代码量几乎为零。第二个关键点是 ListView 的 layout_weight1。它配合外部 LinearLayout 的垂直方向让 ListView 自动占满用户信息区剩余的全部高度。这里有个初学者容易误解的地方layout_weight 在 LinearLayout 里是“按比例分配剩余空间”你只需要让被分配空间的控件高度设为 0dp然后再给一个权值就行了。这个技巧在底部导航栏、输入框与按钮的布局里到处都能用。3.2 菜单适配器 MenuAdapter 的编写ListView 的适配器思路很简单直接看代码public class MenuAdapter extends BaseAdapter { private Context context; private ListMenuBean menuList; public MenuAdapter(Context context, ListMenuBean menuList) { this.context context; this.menuList menuList; } Override public int getCount() { return menuList null ? 0 : menuList.size(); } Override public Object getItem(int position) { return menuList.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView View.inflate(context, R.layout.item_menu, null); holder new ViewHolder(); holder.ivIcon (ImageView) convertView.findViewById(R.id.iv_menu_icon); holder.tvTitle (TextView) convertView.findViewById(R.id.tv_menu_title); convertView.setTag(holder); } else { holder (ViewHolder) convertView; } MenuBean bean menuList.get(position); holder.ivIcon.setImageResource(bean.getIconResId()); holder.tvTitle.setText(bean.getTitle()); return convertView; } static class ViewHolder { ImageView ivIcon; TextView tvTitle; } }ViewHolder 模式在老项目里是性能优化的核心手段。它的原理是ListView 滚动时系统会把离开屏幕的 item 的 View 缓存到 recycle 中传给新进入屏幕的 item 的 getView 的 convertView 参数。利用 ViewHolder 持有 item 里子控件的引用再用 setTag 存起来下次直接拿引用给控件赋值避免每次都 findViewById。这个优化逻辑放到今天写 RecyclerView 的 ViewHolder 时依然是同一个套路你把它理解了以后看任何列表代码都不发怵。MenuBean 是一个简单的实体类只有两个字段图标资源 id 和标题文字。我当时直接在 ProfileActivity 里写了一个 initMenuData 方法new 出几个 MenuBean 塞进 ArrayList再 setAdapter。菜单项的内容是“我的收藏/我的课程/我的下载/设置/关于”。这几个入口对应的都是空壳 Activity先搭好跳转框架后续再细做。3.3 item_menu.xml 的编写细节菜单每一项都是一个横向布局我用的结构是RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_height50dp android:paddingLeft15dp android:paddingRight15dp android:backgrounddrawable/item_selector ImageView android:idid/iv_menu_icon android:layout_width25dp android:layout_height25dp android:layout_alignParentLefttrue android:layout_centerVerticaltrue/ TextView android:idid/tv_menu_title android:layout_widthwrap_content android:layout_heightwrap_content android:layout_toRightOfid/iv_menu_icon android:layout_centerVerticaltrue android:layout_marginLeft12dp android:textSize15sp/ ImageView android:layout_widthwrap_content android:layout_heightwrap_content android:layout_alignParentRighttrue android:layout_centerVerticaltrue android:srcdrawable/ic_arrow_right/ /RelativeLayout右侧那个箭头图标我一开始用自带的 drawable后来发现不同分辨率的模拟器上显得模糊干脆自己用 XML 画了一个三角形箭头。做法就是放一个 ImageViewsrc 用 shape 做不了带角度的箭头所以我用的是一张很小的 png。如果你手头没有合适的箭头图用一个 TextView 显示 “” 字符也能原地救急。4. 登录状态管理与数据持久化4.1 SharedPreferences 为什么够用用户信息这种轻量级数据根本不需要上 SQLite 数据库。“我”的模块要存的不过是用户名、手机号、头像路径这四五个字段SharedPreferences 以键值对的形式存在应用私有目录下的 XML 文件里读写速度极快API 简单最适合这种场景。我封装了一个 UserInfoUtils 工具类对外暴露三个方法saveUserInfo(Context, UserBean)、getUserInfo(Context)、clearUserInfo(Context)。这三个方法内部统一走 SharedPreferences 的 Editor 提交逻辑。工具类的意义在于将来如果需求升级要改成数据库存储只需要改动这个类内部实现外部调用方完全无感知。public class UserInfoUtils { private static final String PREF_NAME user_info; private static final String KEY_USERNAME username; private static final String KEY_PHONE phone; private static final String KEY_AVATAR avatar; public static void saveUserInfo(Context context, UserBean user) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor sp.edit(); editor.putString(KEY_USERNAME, user.getUsername()); editor.putString(KEY_PHONE, user.getPhone()); editor.putString(KEY_AVATAR, user.getAvatar()); editor.commit(); } public static UserBean getUserInfo(Context context) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); String username sp.getString(KEY_USERNAME, ); String phone sp.getString(KEY_PHONE, ); String avatar sp.getString(KEY_AVATAR, ); if (TextUtils.isEmpty(username) TextUtils.isEmpty(phone)) { return null; } UserBean user new UserBean(); user.setUsername(username); user.setPhone(phone); user.setAvatar(avatar); return user; } public static void clearUserInfo(Context context) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor sp.edit(); editor.clear(); editor.commit(); } }注意上面代码里用的 commit() 是一个同步写盘操作如果在主线程调用当数据量大时会有一点点卡顿。老项目里流行用 commit但更推荐 apply()它是异步写入且不会阻塞主线程。不过 apply 的写入时机不确定如果你马上要读取数据还是 commit 更稳。实际开发中登录和退出这种低频操作用 commit 完全没问题。4.2 登录成功后如何把数据传回来“我”的模块里用户点击顶部头像区域时如果未登录会跳转到 LoginActivity。登录页输入手机号和密码调登录接口成功后需要把用户信息回传给个人中心页。这里有一种“常规但不优雅”的做法LoginActivity 在登录成功后直接把数据写进 SharedPreferences然后调用 finish()。个人中心页在 onResume() 里重新读取 SharedPreferences自然就拿到了最新的用户信息。这种做法为什么可行因为两个页面共享同一个应用上下文里的 SharedPreferences 文件写入方和读取方不需要知道对方存在。很多初学者一开始想用 Intent 携带数据回传方法也是对的但会有个问题如果个人中心页还从其他页面返回比如从设置页返回其他页面并不一定持有用户数据数据流就断了。而 SharedPreferences 是全局的只要登录成功往里面写数据任何页面读取都能拿到。如果你非要体验一下 startActivityForResult 的回传方式也不是不行。登录页 setResult(RESULT_OK, data) 时通过 Intent 的 putExtra 把用户信息带回来个人中心页在 onActivityResult 里解析 Intent 并刷新界面。这个方式代码量稍大而且要求发起跳转的页面能稳定接收返回结果整个链路一旦被中间页打断就容易出问题。我在项目里选择的是“事后再读取”的策略代码少、链路短出 bug 的概率低。4.3 退出登录与缓存清理的实现退出登录按钮我放在底部用一个醒目的灰色按钮。点击后先弹一个确认对话框防止用户误触。确认后执行三步操作清空 SharedPreferences 里的用户信息、清空本地缓存目录、刷新当前页面 UI。private void showLogoutDialog() { AlertDialog.Builder builder new AlertDialog.Builder(this); builder.setTitle(退出登录); builder.setMessage(确定要退出当前账号吗); builder.setPositiveButton(确定, new DialogInterface.OnClickListener() { Override public void onClick(DialogInterface dialog, int which) { UserInfoUtils.clearUserInfo(ProfileActivity.this); clearAppCache(); refreshUserInfo(); Toast.makeText(ProfileActivity.this, 已退出登录, Toast.LENGTH_SHORT).show(); } }); builder.setNegativeButton(取消, null); builder.show(); }清空缓存这个动作有个细节不要直接 FileUtils.deleteDirectory 把整个 cache 目录删了有些系统临时文件删掉可能导致后续功能异常。更好的做法是遍历 cache 目录下的文件只删除文件保留目录结构。图片加载库的缓存通常在 image_cache 子目录如果你用了自研的图片加载也需要单独清理。博学谷这个项目里没有引入第三方图片框架所以我的 clearAppCache 方法就简单遍历了一下 getCacheDir() 下的所有 file逐个 delete碰到目录就递归删。提示退出登录后还要记得把头像和用户名的显示恢复到未登录的默认状态。如果你不清这些 UI 数据就会出现“明明退出了界面还显示着上一个用户的头像和昵称”的尴尬情况。refreshUserInfo() 里要同时处理“有用户”和“无用户”两种分支不要只写一半。5. 页面跳转、数据回传与常见坑点排查5.1 功能菜单的点击跳转与统一入口我在 MenuAdapter 的 onItemClick 里统一处理菜单跳转没有给每个 item 单独设置点击事件。这样做的原因是代码集中好维护。我的写法是listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { Override public void onItemClick(AdapterView? parent, View view, int position, long id) { switch (position) { case 0: startActivity(new Intent(ProfileActivity.this, MyCollectActivity.class)); break; case 1: startActivity(new Intent(ProfileActivity.this, MyCourseActivity.class)); break; case 2: startActivity(new Intent(ProfileActivity.this, MyDownloadActivity.class)); break; default: break; } } });这种实现的局限也很明显如果菜单顺序调整了position 和 Activity 的对应关系就得跟着改不是特别健壮。更合理的做法是给 MenuBean 增加一个字段比如 targetActivity跳转时用反射或者 if-else 根据 class 名字跳转。但老项目里图省事switch(position) 是最直接的面试时能说出它的缺点和改进方向反而显得你有思考。5.2 通过 Intent 传递用户数据的完整示例虽说 SharedPreferences 是主力方案但有一种场景必须用 Intent 传数据编辑资料页要把最新昵称带回个人中心。编辑页拿到用户输入的新昵称后代码是String newNickname etNickname.getText().toString().trim(); Intent resultIntent new Intent(); resultIntent.putExtra(nickname, newNickname); setResult(RESULT_OK, resultIntent); finish();个人中心页预先用 startActivityForResult 跳到编辑页重写 onActivityResultOverride protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode REQUEST_EDIT_PROFILE resultCode RESULT_OK) { if (data ! null) { String newName data.getStringExtra(nickname); // 更新 SharedPreferences 中的昵称字段 // 更新 UI tvUserName.setText(newName); } } }这里有个特别容易踩的坑如果你在编辑页通过 Intent 传回数据但是编辑页在保存昵称时已经提前把数据写进了 SharedPreferences那么回传 Intent 里的数据其实是多余的。两种方案选一种坚持不要混用。混用的后果是某次改需求时只改了存储方案忘了改回传逻辑于是页面上的昵称总是旧值排查半天下不了手。5.3 Eclipse 开发环境下的常见问题排查用 Eclipse 写 Android 项目最让人抓狂的几个问题我在这里一次性说清楚第一R.java 文件不刷新。这种情况通常出现在新增了资源文件后项目报红说找不到 R.id.xxx。解决方法是Project - Clean勾选当前项目清理后 Eclipse 会重新编译并生成新的 R 类。如果 Clean 没用检查资源文件命名是否包含大写字母或中文Android 资源文件名必须全小写且只能用字母、数字和下划线。这个坑我踩过我把一个图片命名为 user_BG.png编译半天不过改成 user_bg.png 立刻好了。第二模拟器里图片显示模糊。Eclipse 时代创建模拟器时默认分辨率比较低如果你只在 drawable-hdpi 里放了图片换到 mdpi 的模拟器上系统找不到对应资源会拿 hdpi 的图硬缩显示效果就发糊。解决办法是把不同分辨率的图放到对应的 drawable 目录里。项目里我用的是“一个图标放三份”的笨办法虽然占空间但兼容性好。第三中文乱码问题。这种情况多出在直接修改了 XML 文件但 Eclipse 的编码格式是默认的 ISO-8859-1保存后中文全部变成问号。项目的全局编码设置统一为 UTF-8Window - Preferences - General - Content Types - Text - Java Properties File把 Default encoding 改成 UTF-8再点 Update。保险起见项目管理规范里也应该注明所有代码和布局文件统一 UTF-8不然团队成员各用各的编码git 上天天都是乱码冲突。第四Activity 重复跳转的问题。点击“我的收藏”菜单如果没有处理快速双击会连续打开两个 MyCollectActivity 堆叠在一起返回时体验很差。我解决这种问题有一个土办法在跳转前加一个 flag用 System.currentTimeMillis() 记录上次点击时间如果间距小于 800 毫秒就忽略本次点击。这个方法不优雅但非常有效任何项目都能用。5.4 从 Eclipse 迁移到 Android Studio 的注意点最后聊聊这个项目的未来——把博学谷项目从 Eclipse 迁移到 Android Studio。如果你学完之后想继续深挖迁移过程本身也是一次很好的进阶练习。Eclipse 的项目是 .project 和 .classpath 文件结构Android Studio 是 Gradle 工程结构两者不能直接共用需要新建工程然后把 src 和 res 目录拷贝过去。拷贝时要注意几个点第一Eclipse 项目里的 libs 目录要手动拷贝到新工程的 app/libs并在 build.gradle 里加 dependencies第二AndroidManifest.xml 里的 package 属性要挪到 build.gradle 里的 applicationId命名空间迁移有讲究不要贪快直接照抄第三项目里如果用了第三方 jar 包在 Android Studio 里通常改成 Gradle 依赖引用方式版本冲突问题更容易暴露。迁移完成后建议先把项目跑起来跑不通就把报错信息一一截图按图索骥解决。这个过程你会把 Gradle 体系的基础概念过一遍比看书高效得多。我个人在实际操作中的体会是Eclipse 年代的项目代码虽然老但那批项目的分层思路和命名习惯反而很踏实——包名按 login、profile、common、utils 分的清清楚楚Activity 命名一看就知道对应哪个页面。反观现在一些新项目动不动就上巨型框架代码里包的划分却一塌糊涂。博学谷这个“我”的模块教给你的不只是写布局和存数据更是一种“先想清楚再动手”的做事习惯。最后再分享一个小技巧当你把整个“我”的模块做完后可以在个人中心页的界面入口上挂一个点击计数。我当年无聊加了一个——连击头像十次弹出一个隐藏的运营位。后来这个功能被 PM 发现了直接变成了他们后续版本的功能需求。任何时候多做一点超出题目本身的小东西都会让你的项目比别人多一个有趣的记忆点。