教你如何实现 Splash 页面三秒跳转和动态下载最新背景图

本文已授权公众号hongyangAndroid原创首发

最近公司产品大大说我们需要一个动态替换的闪屏页面,like 某猫,某东一样,可以动态替换。
产品大大就是厉害,说一句话我们就需要实现好几个功能:

  1. 创建一个冷启动后的闪屏页面(Splash 页面)
  1. 这个页面默认 3s 倒计时,点击倒计时按钮可以跳转并结束倒计时
  1. 点击图片如果有外链,则跳转应用的 web 页面用来作为活动页面(没错这点和某猫很像)
  1. 动态替换厉害了,我们需要在进入这个页面后去后台请求一下是否有新的图片,如果是新的图片则下载到本地,替换掉原来的图片,下次用户在进入 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 的时候就展示的新的图片了。

这里你需要知道知识有下边几个:

  1. java bean 序列化与反序列化的知识
  2. IntentService 服务的知识
  3. AsycTask 的使用
  4. 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,566评论 25 707
  • 前言 市场上的应用大多的Splash页都添加了动态背景图的功能,不知道这种行为的专业名词是什么,在我们公司里把这个...
    叶林舟阅读 1,441评论 2 11
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,612评论 4 59
  • 我是一个十八岁的对未来满怀热情的少年,对人生的冒险充满好奇与渴望。 在一次偶然的机会,由亲戚介绍,我学习法语,目的...
    桔子kong阅读 310评论 0 0
  • 总有人跟我说,每次过节的时候,都不敢看朋友圈。实在是狗粮吃多了,容易产生心理阴影。每次过节,单身狗们总是会被虐得很...
    折节读书阅读 411评论 0 0