本文已授权公众号hongyangAndroid原创首发
最近公司产品大大说我们需要一个动态替换的闪屏页面,like 某猫,某东一样,可以动态替换。
产品大大就是厉害,说一句话我们就需要实现好几个功能:
- 创建一个冷启动后的闪屏页面(Splash 页面)
- 这个页面默认 3s 倒计时,点击倒计时按钮可以跳转并结束倒计时
- 点击图片如果有外链,则跳转应用的 web 页面用来作为活动页面(没错这点和某猫很像)
效果图
- 动态替换厉害了,我们需要在进入这个页面后去后台请求一下是否有新的图片,如果是新的图片则下载到本地,替换掉原来的图片,下次用户在进入 Splash 就会看到一个崭新的图片。
一、布局实现
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/sp_bg"
android:src="@mipmap/icon_splash"
android:scaleType="centerCrop"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<Button
android:visibility="invisible"
android:gravity="center"
android:textSize="10sp"
android:textColor="@color/white"
android:id="@+id/sp_jump_btn"
android:background="@drawable/btn_splash_shape"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_alignParentRight="true"
android:layout_alignParentTop="true"
android:layout_marginRight="20dp"
android:layout_marginTop="20dp"/>
</RelativeLayout>
布局文件文件相对来说还是比较简单,就需要一个 ImageView 和 Button 即可,Button 的背景是一个自定义的 shape,透明度颜色啥的,根据UI妹砸说的算就好了。
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#99c4c4c4"/>
<corners android:radius="20dp"/>
<stroke
android:width="0.7dp"
android:color="#7fffffff"/>
</shape>
二、倒计时功能实现
实现倒计时的功能方法有很多,最基本的你可以使用 Handler 来实现吧,还可以是用 Timer 吧。
但是由于之前写验证码倒计时的时候发现 android.os 中有一个神奇的类叫 CountDownTimer 的类,此类神奇之处就在于你完全不需要理会那些线程交互他都给你处理好了,你只管在回调中处理时间设置跳转逻辑就好了。
但是有一个不足的地方就它的第一秒的倒计时有时候会不可见,所以我们将倒计时总时间设置为 3200ms 。
private CountDownTimer countDownTimer = new CountDownTimer(3200, 1000) {
@Override
public void onTick(long millisUntilFinished) {
mSpJumpBtn.setText("跳过(" + millisUntilFinished / 1000 + "s)");
}
@Override
public void onFinish() {
mSpJumpBtn.setText("跳过(" + 0 + "s)");
gotoLoginOrMainActivity();
}
};
最后需要在有闪屏页面的情况下,进入开启倒计时:
private void startClock() {
mSpJumpBtn.setVisibility(View.VISIBLE);
countDownTimer.start();
}
三、下载功能实现点击跳转功能实现
上边说了我们 APP 点击图片需要可以跳转,下面代码给出了背景点击跳转的逻辑:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_splash);
ButterKnife.bind(this);
checkSDCardPermission();
}
@OnClick({R.id.sp_bg, R.id.sp_jump_btn})
public void onViewClicked(View view) {
switch (view.getId()) {
case R.id.sp_bg:
gotoWebActivity();
break;
case R.id.sp_jump_btn:
gotoLoginOrMainActivity();
break;
}
}
跳转逻辑可以根据实际的项目需求来规定,下面的代码中 Splash 为本地序列化的 model 用来存储网络下载的闪屏页面信息,稍后会有详细的序列化过程,此刻我们只需要关注跳转逻辑:
private Splash mSplash;
private void gotoWebActivity() {
if (mSplash != null && mSplash.click_url != null) {
Intent intent = new Intent(this, BannerActivity.class);
intent.putExtra("url", mSplash.click_url);
intent.putExtra("title", mSplash.title);
intent.putExtra("fromSplash", true);
intent.putExtra("needShare", false);
startActivity(intent);
finish();
}
}
机智的你可能看出来我们并没有在离开页面的时候结束掉 timer,其实我们是复写了 onDestroy 方法。
@Override
protected void onDestroy() {
super.onDestroy();
if (countDownTimer != null)
countDownTimer.cancel();
}
其实跳转以后还有一个坑就是,从 web 页面返回的时候,因为闪屏页面是你应用的第一个页面,而跳转到 web 页面的是你 finish 掉了该页面,那么从 web 页返回的时候不做处理,用户就直接退出了 app 这样当然是不允许的。
所以请在 web 页面中添加以下逻辑:
//此方法是toolbar 的返回事件调用的方法 mFromSplash 为启动页面传递过来的参数
@Override
protected void onLeftClick(View view) {
if (mFromSplash) {
gotoLoginOrMainActivity();
} else {
super.onLeftClick(view);
}
}
// 此方法为系统返回键的监听
@Override
public void onBackPressed() {
if (mWebView.canGoBack()) {
mWebView.goBack();
} else if (mFromSplash) {
gotoLoginOrMainActivity();
} else {
super.onBackPressed();
}
}
// 下面是跳转逻辑
private void gotoLoginOrMainActivity() {
if (UserCenter.getInstance().getToken() == null) {
gotoLoginActivity();
} else {
gotoMainActivity();
}
}
.... gotoLoginActivity,gotoMainActivity 太长了,不给了自己写 (*^__^*) 嘻嘻……
四、下载网络图片以及序列化本地
上边说了我们有这样一个需求,就是如果后台的接口返回的图片与本地序列化的图片不同,我们需要将新的图片下载到本地,然后下次进入 Splash 的时候就展示的新的图片了。
这里你需要知道知识有下边几个:
- java bean 序列化与反序列化的知识
- IntentService 服务的知识
- AsycTask 的使用
- 6.0 以上权限申请 EasyPermissions 的使用。
以上不熟悉的同学,看到下边的代码可能会引起适量身体不适
其实这里更好的操作,我们可以将图片下载到内存中,这样并不需要申请sdk权限。这里当时实现的时候有点欠考虑了。如果您们保存图片的地址在内存中,就可以跳过这一步。
1. 权限管理
首先我们注意到已进入 Splash 页面我们就进行权限检查,因为我们需要下载最新的闪屏到本地,并取出序列化的对象,来展示对应的内容。
其中 checkSDCardPermission
涉及到 6.0 以上下载最新图片的逻辑,这里采用的是 官方的 EasyPermissions 来处理,关于 EasyPermissions 的使用这里就不多说了,需要了解的请移步 EasyPermissions;
public static final int RC_PERMISSION = 123;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@AfterPermissionGranted(RC_PERMISSION)
private void checkSDCardPermission() {
if (EasyPermissions.hasPermissions(this, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)) {
initSplashImage();
startImageDownLoad();
} else {
EasyPermissions.requestPermissions(this, "需要您提供【**】App 读写内存卡权限来确保应用更好的运行", RC_PERMISSION, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
}
简单来说在 EasyPermissions.hasPermissions
的回调中我们就可以正确的做我们下载图片的工作了。
private void initSplashImage() {
mSplash = getLocalSplash();
//如果取出本地序列化的对象成功 则进行图片加载和倒计时
if (mSplash != null && !TextUtils.isEmpty(mSplash.savePath)) {
Logcat.d("SplashActivity 获取本地序列化成功" + mSplash);
Glide.with(this).load(mSplash.savePath).dontAnimate().into(mSpBgImage);
startClock();//加载成功 开启倒计时
} else {
// 如果本地没有 直接跳转
mSpJumpBtn.setVisibility(View.INVISIBLE);
mSpJumpBtn.postDelayed(new Runnable() {
@Override
public void run() {
gotoLoginOrMainActivity();
}
}, 400);
}
}
// 取出本地序列化的 Splash
private Splash getLocalSplash() {
Splash splash = null;
try {
File serializableFile = SerializableUtils.getSerializableFile(Constants.SPLASH_PATH, Constants.SPLASH_FILE_NAME);
splash = (Splash) SerializableUtils.readObject(serializableFile);
} catch (IOException e) {
Logcat.e("SplashActivity 获取本地序列化闪屏失败" + e.getMessage());
}
return splash;
}
2. 创建本地序列化对象 Splash Entity
Splash 内容如下:
public class Splash implements Serializable {
private static final long serialVersionUID = 7382351359868556980L;//这里需要写死 序列化Id
public int id;
public String burl;//大图 url
public String surl;//小图url
public int type;//图片类型 Android 1 IOS 2
public String click_url; // 点击跳转 URl
public String savePath;//图片的存储地址
public String title;//图片的存储地址
public Splash(String burl, String surl, String click_url, String savePath) {
this.burl = burl;
this.surl = surl;
this.click_url = click_url;
this.savePath = savePath;
}
@Override
public String toString() {
return "Splash{" +
"id=" + id +
", burl='" + burl + '\'' +
", surl='" + surl + '\'' +
", type=" + type +
", click_url='" + click_url + '\'' +
", savePath='" + savePath + '\'' +
'}';
}
}
3. 序列化反序列话的工具类 SerializableUtils
由于项目用到序列化地方还有挺多的,所以这里封装了一个序列化工具类SerializableUtils
:
public class SerializableUtils {
public static <T extends Serializable> Object readObject(File file) {
ObjectInputStream in = null;
T t = null;
try {
in = new ObjectInputStream(new FileInputStream(file));
t = (T) in.readObject();
} catch (EOFException e) {
// ... this is fine
} catch (IOException e) {
Logcat.e("e " + e.getMessage());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (in != null) in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return t;
}
public static <T extends Serializable> boolean writeObject(T t, String fileName) {
ObjectOutputStream out = null;
try {
out = new ObjectOutputStream(new FileOutputStream(fileName));
out.writeObject(t);
Logcat.d("序列化成功 " + t.toString());
return true;
} catch (IOException e) {
e.printStackTrace();
Logcat.d("序列化失败 " + e.getMessage());
return false;
} finally {
try {
if (out != null) out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static File getSerializableFile(String rootPath, String fileName) throws IOException {
File file = new File(rootPath);
if (!file.exists()) file.mkdirs();
File serializable = new File(file, fileName);
if (!serializable.exists()) serializable.createNewFile();
return serializable;
}
}
经过上边的努力我们已经完成了从本地反序列化内容,然后加载图片的工作了,剩下的需要做的就是下载最新图片的工作。
4. 请求接口下载最新的闪屏信息和图片
这里经过考虑,我决定采用服务去下载,因为这样可以少很多麻烦,也不影响程序的正常运行。但是绝不是你们要采用这样的方法,你们也可以单独写个工具类内部去开线程做这件事。
项目中使用开启
IntentServie
来下载图片,关于这中服务的最大的好处就是,我们不需要关注服务是否执行完任务,当他执行完 onHandleIntent 方法后他就自己挑用 stop 方法了。我们只需要关注下载逻辑和序列化逻辑就好。
checkSDCardPermission
中调用的 startImageDownLoad()
方法:
private void startImageDownLoad() {
SplashDownLoadService.startDownLoadSplashImage(this, Constants.DOWNLOAD_SPLASH);
}
SplashDownLoadService 内容,IntentService 在调用了 startService 后会执行 onHandleIntent
方法,在这方法中我们去请求服务器最新的数据即 loadSplashNetDate
:
public SplashDownLoadService() {
super("SplashDownLoad");
}
public static void startDownLoadSplashImage(Context context, String action) {
Intent intent = new Intent(context, SplashDownLoadService.class);
intent.putExtra(Constants.EXTRA_DOWNLOAD, action);
context.startService(intent);
}
@Override
protected void onHandleIntent(@Nullable Intent intent) {
if (intent != null) {
String action = intent.getStringExtra(Constants.EXTRA_DOWNLOAD);
if (action.equals(Constants.DOWNLOAD_SPLASH)) {
loadSplashNetDate();
}
}
}
由于是公司项目,请求方法就不给出了,但是需要讲下请求数据后如何判断是否需要执行下载任务:
mScreen = common.attachment.flashScreen;
Splash splashLocal = getSplashLocal();
if (mScreen != null) {
if (splashLocal == null) {
Logcat.d("splashLocal 为空导致下载");
startDownLoadSplash(Constants.SPLASH_PATH, mScreen.burl);
} else if (isNeedDownLoad(splashLocal.savePath, mScreen.burl)) {
Logcat.d("isNeedDownLoad 导致下载");
startDownLoadSplash(Constants.SPLASH_PATH, mScreen.burl);
}
} else {//由于活动是一段时间,等活动结束后我们并不需要在进入闪屏页面,这个时候我们就需要将本地文件删除,下次在进来,本地文件为空,就会直接 finish 掉 Splash 页面,进入主页面。
if (splashLocal != null) {
File splashFile = SerializableUtils.getSerializableFile(Constants.SPLASH_PATH, SPLASH_FILE_NAME);
if (splashFile.exists()) {
splashFile.delete();
Logcat.d("mScreen为空删除本地文件");
}
}
}
由于活动是一段时间,等活动结束后我们并不需要在进入闪屏页面,这个时候我们就需要将本地文件删除,下次在进来,本地文件为空,就会直接 finish 掉 Splash 页面,进入主页面。
getSplashLocal
方法即反序列话本地存储的 Splash Entity 的过程,上边已经给出这里就不细说,主要讲一下判断逻辑 isNeedDownLoad
:
/**
* @param path 本地存储的图片绝对路径
* @param url 网络获取url
* @return 比较储存的 图片名称的哈希值与 网络获取的哈希值是否相同
*/
private boolean isNeedDownLoad(String path, String url) {
// 如果本地存储的内容为空则进行下载
if (TextUtils.isEmpty(path)) {
return true;
}
// 如果本地文件不存在则进行下载,这里主要防止用户误删操作
File file = new File(path);
if (!file.exists()) {
return true;
}
// 如果两者都存在则判断图片名称的 hashCode 是否相同,不相同则下载
if (getImageName(path).hashCode() != getImageName(url).hashCode()) {
return true;
}
return false;
}
分隔 uri 取图片名称的方法:
private String getImageName(String url) {
if (TextUtils.isEmpty(url)) {
return "";
}
String[] split = url.split("/");
String nameWith_ = split[split.length - 1];
String[] split1 = nameWith_.split("\\.");
return split1[0];
}
满足下载条件后则调用 DownLoadTask 下载。
public class DownLoadUtils {
public interface DownLoadInterFace {
void afterDownLoad(ArrayList<String> savePaths);
}
public static void downLoad(String savePath, DownLoadInterFace downLoadInterFace, String... download) {
new DownLoadTask(savePath, downLoadInterFace).execute(download);
}
private static class DownLoadTask extends AsyncTask<String, Integer, ArrayList<String>> {
private String mSavePath;
private DownLoadInterFace mDownLoadInterFace;
private DownLoadTask(String savePath, DownLoadInterFace downLoadTask) {
this.mSavePath = savePath;
this.mDownLoadInterFace = downLoadTask;
}
@Override
protected ArrayList<String> doInBackground(String... params) {
ArrayList<String> names = new ArrayList<>();
for (String url : params) {
if (!TextUtils.isEmpty(url)) {
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
// 获得存储卡的路径
FileOutputStream fos = null;
InputStream is = null;
try {
URL downUrl = new URL(url);
// 创建连接
HttpURLConnection conn = (HttpURLConnection) downUrl.openConnection();
conn.connect();
// 创建输入流
is = conn.getInputStream();
File file = new File(mSavePath);
// 判断文件目录是否存在
if (!file.exists()) {
file.mkdirs();
}
String[] split = url.split("/");
String fileName = split[split.length - 1];
File mApkFile = new File(mSavePath, fileName);
names.add(mApkFile.getAbsolutePath());
fos = new FileOutputStream(mApkFile, false);
int count = 0;
// 缓存
byte buf[] = new byte[1024];
while (true) {
int read = is.read(buf);
if (read == -1) {
break;
}
fos.write(buf, 0, read);
count += read;
publishProgress(count);
}
fos.flush();
} catch (Exception e) {
Logcat.e(e.getMessage());
} finally {
try {
if (is != null) {
is.close();
}
if (fos != null) {
fos.close();
}
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
}
}
return names;
}
@Override
protected void onPostExecute(ArrayList<String> strings) {
super.onPostExecute(strings);
if (mDownLoadInterFace != null) {
mDownLoadInterFace.afterDownLoad(strings);
}
}
}
}
由于下载完成后需要拿到文件存储地址这里写了一个 mDownLoadInterFace.afterDownLoad 的回调在 service 拿到回调后:
public void afterDownLoad(ArrayList<String> savePaths) {
if (savePaths.size() == 1) {
Logcat.d("闪屏页面下载完成" + savePaths);
if (mScreen != null) {
mScreen.savePath = savePaths.get(0);
}
// 序列化 Splash 到本地
SerializableUtils.writeObject(mScreen, Constants.SPLASH_PATH + "/" + SPLASH_FILE_NAME);
} else {
Logcat.d("闪屏页面下载失败" + savePaths);
}
}
写在最后
上边 bb 这么多,我们可以看出产品一句话,我们程序员可能就需要工作一天了,所以我们需要将这个常见的功能记录下,下个公司产品再说实现一个闪屏功能,然后我们就可以说 这功能可能需要 1天时间,然后等他答应了,copy 一下,其他的时间你就可以学习下 Rxjava2 ,kotlin, js 之类的了。哈哈哈哈 我真tm机智。
后记:
这篇文章投稿到掘金和鸿洋大神的公众号后,大家对我的代码提出了许多建议,我感谢大家能帮助我成长。大家普遍要求一个Demo,花了几个小时时间,将其从项目中抽出来。希望大家赏脸 star 或者fork:
项目地址:SplashActivityDemo
网友评论
想问问博主,如果用你这种设计流程,举例:促销期1月1日到1月15日,我在此期间下载了这个app,但第一次打开按你的设计,应该是在splash这个页面什么都没有显示吧。因为你的设计是这次下载下次的图片。我下次打开已经不在促销期内了。这样应该是这个用户错过了一次浏览哪个splash页面的机会吧。不知道博主理不理解我的意思。
.getSplashImage(TYPE_ANDROID)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<Common>() {},new Consumer<Throwable>() {
@Override
public void accept(@NonNull Throwable throwable) throws Exception {
}
});
subscribeOn报错 Cannot resolve method 'subscribeOn(io.reactivex.Scheduler)'怎么处理
import io.reactivex.schedulers.Schedulers;
每次进入App 都会请求后台信息,通过图片名称的hashCode 是否相同,判断是否需要下载新的闪屏信息到本地。