序言

目前有个uni-app应用内预览Office及PDF文档的需求,Dcloud插件市场:https://ext.dcloud.net.cn/上有比较多的插件,有一些用起来比较省心,但是自己也忍不住想写一个插件,因此本文实现基本的文档预览功能基础上,分享一下如何实现插件。

Dcloud官方的uni-app原生插件开发教程:https://nativesupport.dcloud.net.cn/NativePlugin/course/android

开发环境

JAVA环境 jdk1.8
Android Studio 下载地址:Android Studio官网 OR Android Studio中文社区
App离线SDK下载:请下载2.9.8+版本的android平台SDK

导入uni插件原生项目

UniPlugin-Hello-AS工程请在App离线SDK中查找
点击Android Studio菜单选项File--->New--->Import Project。

创建Android Studio的Module模块

在现有Android项目中创建library的Module,并配置刚创建的Module的build.gradle信息。

apply plugin: 'com.android.library'

android {
    compileSdkVersion 29
    defaultConfig {
        minSdkVersion 16
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"

        // Specifies the ABI configurations of your native
        // libraries Gradle should build and package with your APK.
        ndk {
            abiFilters "armeabi", "armeabi-v7a", "x86", "mips"
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }

}

repositories {
    flatDir {
        dirs 'libs'
    }
}

dependencies {
    compileOnly fileTree(dir: 'libs', include: ['*.jar'])

    compileOnly fileTree(dir: '../app/libs', include: ['uniapp-v8-release.aar'])

    //noinspection GradleCompatible
    compileOnly "com.android.support:recyclerview-v7:28.0.0"
    //noinspection GradleCompatible
    compileOnly "com.android.support:support-v4:28.0.0"
    //noinspection GradleCompatible
    compileOnly "com.android.support:appcompat-v7:28.0.0"
    implementation 'com.alibaba:fastjson:1.1.46.android'
    implementation 'com.facebook.fresco:fresco:1.13.0'

    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
}

创建TestModule类

Module 扩展必须继承 UniModule 类

  • 扩展方法必须加上@UniJSMethod (uiThread = false or true) 注解。UniApp
    会根据注解来判断当前方法是否要运行在 UI 线程,和当前方法是否是扩展方法。
  • UniApp是根据反射来进行调用 Module 扩展方法,所以Module中的扩展方法必须是 public 类型。

    package com.siyue.uni.plugin;
    import android.app.Activity;
    import android.content.Intent;
    import android.util.Log;
    import com.alibaba.fastjson.JSONObject;
    import io.dcloud.feature.uniapp.annotation.UniJSMethod;
    import io.dcloud.feature.uniapp.bridge.UniJSCallback;
    import io.dcloud.feature.uniapp.common.UniDestroyableModule;
    import io.dcloud.feature.uniapp.common.UniModule;
    import io.dcloud.feature.uniapp.utils.UniLogUtils;
    public class SiyueDocumentPreview extends UniDestroyableModule {

    String TAG = "SiyueDocumentPreview";
    public static int REQUEST_CODE = 1000;
    @UniJSMethod (uiThread = true)
    public void documentPreview(JSONObject options){
        if(mUniSDKInstance != null && mUniSDKInstance.getContext() instanceof Activity) {
            Intent intent = new Intent(mUniSDKInstance.getContext(), DocumentPreviewActivity.class);
            intent.putExtra("fileUrl", options.getString("url"));
            intent.putExtra("title", options.getString("title"));
            intent.putExtra("titleHeight", options.getString("titleHeight"));
            intent.putExtra("titleBgColor", options.getString("titleBgColor"));
            intent.putExtra("titleTextColor", options.getString("titleTextColor"));
            intent.putExtra("titleTextSize", options.getString("titleTextSize"));
            intent.putExtra("fileType", options.getString("fileType"));
            intent.putExtra("fileName", options.getString("fileName"));
            intent.putExtra("deleteCacheFile", options.getString("deleteCacheFile"));
            ((Activity)mUniSDKInstance.getContext()).startActivityForResult(intent, REQUEST_CODE);
        }
    }
    @Override
    public void destroy() {
    }

    }

引入腾讯TBS

TBS文档接入地址

将jar放入到插件工程的libs中:

初始化X5内核

创建一个实体类并实现UniAppHookProxy接口,在onCreate函数中添加组件注册相关参数 或 填写插件需要在启动时初始化的逻辑。

package com.siyue.uni.plugin;

import android.app.Application;
import android.content.Intent;
import android.util.Log;

import com.tencent.smtt.export.external.TbsCoreSettings;
import com.tencent.smtt.sdk.QbSdk;
import com.tencent.smtt.sdk.TbsListener;

import java.util.HashMap;

import io.dcloud.feature.uniapp.UniAppHookProxy;


public class SiyueDocument_AppProxy implements UniAppHookProxy {
    @Override
    public void onCreate(Application application) {
        //可写初始化触发逻辑
        Log.e("SiyueDocument_AppProxy", "--初始化触发逻辑   开始--");
        boolean b = QbSdk.canLoadX5(application.getApplicationContext());

        Log.e("SiyueDocument_AppProxy", "是否可以加载X5内核 -->" + b);

        QbSdk.setDownloadWithoutWifi(true);
        QbSdk.setTbsListener(new TbsListener() {
            @Override
            public void onDownloadFinish(int i) {
                Log.e("SiyueDocument_AppProxy", "onDownloadFinish -->下载X5内核完成:" + i);
            }
            @Override
            public void onInstallFinish(int i) {
                Log.e("SiyueDocument_AppProxy", "onInstallFinish -->安装X5内核进度:" + i);
            }
            @Override
            public void onDownloadProgress(int i) {
                Log.e("SiyueDocument_AppProxy", "onDownloadProgress -->安装X5内核进度:" + i);
            }
        });
        QbSdk.initX5Environment(application.getApplicationContext(), cb);

        Log.e("SiyueDocument_AppProxy", "--初始化触发逻辑   结束--");
    }

    QbSdk.PreInitCallback cb = new QbSdk.PreInitCallback() {
        @Override
        public void onCoreInitFinished() {
            //x5内核初始化完成回调接口,此接口回调并表示已经加载起来了x5,有可能特殊情况下x5内核加载失败,切换到系统内核。
            Log.e("SiyueDocument_AppProxy", "onCoreInitFinished-->");
        }
        @Override
        public void onViewInitFinished(boolean b) {
            //x5內核初始化完成的回调,为true表示x5内核加载成功,否则表示x5内核加载失败,会自动切换到系统内核。
            Log.e("SiyueDocument_AppProxy", "onViewInitFinished: 加载X5内核是否成功:" + b);
        }
    };

    @Override
    public void onSubProcessCreate(Application application) {
        //子进程初始化回调

    }
}

文档预览功能

package com.siyue.uni.plugin;

import android.Manifest;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.database.Cursor;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;

import com.tencent.smtt.sdk.QbSdk;
import com.tencent.smtt.sdk.TbsReaderView;
import com.tencent.smtt.sdk.TbsReaderView.ReaderCallback;

import java.io.File;
import java.text.DecimalFormat;

public class DocumentPreviewActivity extends Activity implements ReaderCallback,
        OnClickListener {
    private TextView txtTitle;
    private TbsReaderView mTbsReaderView;
    private TextView txtDownload;
    //rl_tbsView为装载TbsReaderView的视图
    private RelativeLayout rlTbsReaderView;
    private RelativeLayout rlTitleBar;
    private ProgressBar pbDownload;
    private DownloadManager mDownloadManager;
    private long mRequestId;
    private DownloadObserver mDownloadObserver;
    //文件url 由文件url截取的文件名 上个页面传过来用于显示的文件名
    private String mFileUrl = "", mFileName, fileName;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.activity_document_preview);
        findViewById();
        getFileUrlByIntent();
        mTbsReaderView = new TbsReaderView(this, this);
        rlTbsReaderView.addView(mTbsReaderView, new RelativeLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT));
        if ((mFileUrl == null) || (mFileUrl.length() <= 0)) {
            Toast.makeText(DocumentPreviewActivity.this, "获取文件url出错了",
                    Toast.LENGTH_SHORT).show();
            return;
        }
        mFileName = parseName(mFileUrl);
        if (isLocalExist()) {
            txtDownload.setText("打开文件");
            txtDownload.setVisibility(View.GONE);
            displayFile();
        } else {
            if (!mFileUrl.contains("http")) {
                new AlertDialog.Builder(DocumentPreviewActivity.this)
                        .setTitle("温馨提示:")
                        .setMessage("文件的url地址不合法,无法进行下载!")
                        .setCancelable(false)
                        .setPositiveButton("确定", new DialogInterface.OnClickListener() {
                            @Override
                            public void onClick(DialogInterface arg0, int arg1) {
                                return;
                            }
                        }).create().show();
            }
            startDownload();
        }
    }

    /**
     * 将url进行encode,解决部分手机无法下载含有中文url的文件的问题(如OPPO R9)
     *
     * @param url
     * @return
     * @author xch
     */
    private String toUtf8String(String url) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < url.length(); i++) {
            char c = url.charAt(i);
            if (c >= 0 && c <= 255) {
                sb.append(c);
            } else {
                byte[] b;
                try {
                    b = String.valueOf(c).getBytes("utf-8");
                } catch (Exception ex) {
                    System.out.println(ex);
                    b = new byte[0];
                }
                for (int j = 0; j < b.length; j++) {
                    int k = b[j];
                    if (k < 0)
                        k += 256;
                    sb.append("%" + Integer.toHexString(k).toUpperCase());
                }
            }
        }
        return sb.toString();
    }

    private void findViewById() {
        txtDownload = (TextView) findViewById(R.id.tv_download);
        ImageView back_icon = (ImageView) findViewById(R.id.back_icon);
        txtTitle = (TextView) findViewById(R.id.title);
        pbDownload = (ProgressBar) findViewById(R.id.progressBar_download);
        rlTbsReaderView = (RelativeLayout) findViewById(R.id.rl_tbsView);
        rlTitleBar = (RelativeLayout) findViewById(R.id.title_bar);
        back_icon.setOnClickListener(this);
    }

    /**
     * 获取传过来的文件url和文件名
     */
    private void getFileUrlByIntent() {
        Intent intent = getIntent();
        mFileUrl = intent.getStringExtra("fileUrl");
        fileName = intent.getStringExtra("fileName")!=null ? intent.getStringExtra("fileName") : parseName(mFileUrl);

        if(intent.getStringExtra("fileType")!=null){

        }

        if(intent.getStringExtra("titleBgColor")!=null){
            rlTitleBar.setBackgroundColor(Color.parseColor(intent.getStringExtra("titleBgColor")));
        }

        txtTitle.setText(intent.getStringExtra("title")!=null ? intent.getStringExtra("title") : fileName);

        if(intent.getStringExtra("titleTextColor")!=null){
            txtTitle.setTextColor(Color.parseColor(intent.getStringExtra("titleTextColor")));
        }

        if(intent.getStringExtra("titleTextSize")!=null){
            txtTitle.setTextSize(Float.parseFloat(intent.getStringExtra("titleTextSize")));
        }

        android.view.ViewGroup.LayoutParams titleLayout = txtTitle.getLayoutParams();
        if(intent.getStringExtra("titleHeight")!=null){
            titleLayout.height = Integer.parseInt(intent.getStringExtra("titleHeight"));
        }

        txtTitle.setLayoutParams(titleLayout);

    }

    /**
     * 加载显示文件内容
     */
    private void displayFile() {

        if (ContextCompat.checkSelfPermission(DocumentPreviewActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(DocumentPreviewActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
        } else {
            Bundle bundle = new Bundle();
            bundle.putString("filePath", getLocalFile().getPath());
            bundle.putString("tempPath", Environment.getExternalStorageDirectory()
                    .getPath());
            boolean result = mTbsReaderView.preOpen(parseFormat(mFileName), false);
            if (result) {
                mTbsReaderView.openFile(bundle);
            } else {

                File file = new File(getLocalFile().getPath());
                if (file.exists()) {
                    Intent openintent = new Intent();
                    openintent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                    String type = getMIMEType(file);
                    // 设置intent的data和Type属性。
                    openintent.setDataAndType(/* uri */Uri.fromFile(file), type);
                    // 跳转
                    startActivity(openintent);
                    finish();
                }
            }
        }
    }

    private String getMIMEType(File file) {

        String type = "*/*";
        String fName = file.getName();
        // 获取后缀名前的分隔符"."在fName中的位置。
        int dotIndex = fName.lastIndexOf(".");
        if (dotIndex < 0) {
            return type;
        }
        /* 获取文件的后缀名 */
        String end = fName.substring(dotIndex, fName.length()).toLowerCase();
        if (end == "")
            return type;
        // 在MIME和文件类型的匹配表中找到对应的MIME类型。
        for (int i = 0; i < MIME_MapTable.length; i++) {
            if (end.equals(MIME_MapTable[i][0]))
                type = MIME_MapTable[i][8];
        }
        return type;
    }

    private String parseFormat(String fileName) {
        return fileName.substring(fileName.lastIndexOf(".") + 1);
    }

    /**
     * 利用文件url转换出文件名
     *
     * @param url
     * @return
     */
    private String parseName(String url) {
        String fileName = null;
        try {
            fileName = url.substring(url.lastIndexOf("/") + 1);
        } finally {
            if (TextUtils.isEmpty(fileName)) {
                fileName = String.valueOf(System.currentTimeMillis());
            }
        }
        return fileName;
    }

    private boolean isLocalExist() {
        return getLocalFile().exists();
    }

    private File getLocalFile() {
        return new File(
                Environment
                        .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
                mFileName);
    }

    /**
     * 下载文件
     */
    @SuppressLint("NewApi")
    private void startDownload() {
        mDownloadObserver = new DownloadObserver(new Handler());
        getContentResolver().registerContentObserver(
                Uri.parse("content://downloads/my_downloads"), true,
                mDownloadObserver);

        mDownloadManager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
        //将含有中文的url进行encode
        String fileUrl = toUtf8String(mFileUrl);
        try {

            DownloadManager.Request request = new DownloadManager.Request(
                    Uri.parse(fileUrl));
            request.setDestinationInExternalPublicDir(
                    Environment.DIRECTORY_DOWNLOADS, mFileName);
            request.allowScanningByMediaScanner();
            request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN);
            mRequestId = mDownloadManager.enqueue(request);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @TargetApi(Build.VERSION_CODES.GINGERBREAD)
    private void queryDownloadStatus() {
        DownloadManager.Query query = new DownloadManager.Query()
                .setFilterById(mRequestId);
        Cursor cursor = null;
        try {
            cursor = mDownloadManager.query(query);
            if (cursor != null && cursor.moveToFirst()) {
                // 已经下载的字节数
                long currentBytes = cursor
                        .getLong(cursor
                                .getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
                // 总需下载的字节数
                long totalBytes = cursor
                        .getLong(cursor
                                .getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
                // 状态所在的列索引
                int status = cursor.getInt(cursor
                        .getColumnIndex(DownloadManager.COLUMN_STATUS));
                txtDownload.setText("下载中...(" + formatKMGByBytes(currentBytes)
                        + "/" + formatKMGByBytes(totalBytes) + ")");
                // 将当前下载的字节数转化为进度位置
                int progress = (int) ((currentBytes * 1.0) / totalBytes * 100);
                pbDownload.setProgress(progress);

                Log.i("downloadUpdate: ", currentBytes + " " + totalBytes + " "
                        + status + " " + progress);
                if (DownloadManager.STATUS_SUCCESSFUL == status
                        && txtDownload.getVisibility() == View.VISIBLE) {
                    txtDownload.setVisibility(View.GONE);
                    txtDownload.performClick();
                    if (isLocalExist()) {
                        txtDownload.setVisibility(View.GONE);
                        displayFile();
                    }
                }
            }
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    @Override
    public void onCallBackAction(Integer integer, Object o, Object o1) {

    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mTbsReaderView.onStop();
        if (mDownloadObserver != null) {
            getContentResolver().unregisterContentObserver(mDownloadObserver);
        }
    }

    @SuppressLint("Override")
    public void onPointerCaptureChanged(boolean hasCapture) {

    }

    private class DownloadObserver extends ContentObserver {

        private DownloadObserver(Handler handler) {
            super(handler);
        }

        @Override
        public void onChange(boolean selfChange, Uri uri) {
            queryDownloadStatus();
        }
    }

    @Override
    public void onClick(View v) {
        // TODO Auto-generated method stub
        if (v.getId() == R.id.back_icon) {
            finish();
        }
    }

    /**
     * 将字节数转换为KB、MB、GB
     *
     * @param size 字节大小
     * @return
     */
    private String formatKMGByBytes(long size) {
        StringBuffer bytes = new StringBuffer();
        DecimalFormat format = new DecimalFormat("###.00");
        if (size >= 1024 * 1024 * 1024) {
            double i = (size / (1024.0 * 1024.0 * 1024.0));
            bytes.append(format.format(i)).append("GB");
        } else if (size >= 1024 * 1024) {
            double i = (size / (1024.0 * 1024.0));
            bytes.append(format.format(i)).append("MB");
        } else if (size >= 1024) {
            double i = (size / (1024.0));
            bytes.append(format.format(i)).append("KB");
        } else if (size < 1024) {
            if (size <= 0) {
                bytes.append("0B");
            } else {
                bytes.append((int) size).append("B");
            }
        }
        return bytes.toString();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        switch (requestCode) {
            case 1:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {

                } else {
                    Toast.makeText(this, "没有获取到系统权限,无法预览文件!", Toast.LENGTH_SHORT).show();
                }
                break;
            default:
        }
    }

    private final String[][] MIME_MapTable = {
            // {后缀名,MIME类型}
            {".3gp", "video/3gpp"},
            {".apk", "application/vnd.android.package-archive"},
            {".asf", "video/x-ms-asf"},
            {".avi", "video/x-msvideo"},
            {".bin", "application/octet-stream"},
            {".bmp", "image/bmp"},
            {".c", "text/plain"},
            {".class", "application/octet-stream"},
            {".conf", "text/plain"},
            {".cpp", "text/plain"},
            {".doc", "application/msword"},
            {".docx",
                    "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
            {".xls", "application/vnd.ms-excel"},
            {".xlsx",
                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
            {".exe", "application/octet-stream"},
            {".gif", "image/gif"},
            {".gtar", "application/x-gtar"},
            {".gz", "application/x-gzip"},
            {".h", "text/plain"},
            {".htm", "text/html"},
            {".html", "text/html"},
            {".jar", "application/java-archive"},
            {".java", "text/plain"},
            {".jpeg", "image/jpeg"},
            {".jpg", "image/jpeg"},
            {".js", "application/x-javascript"},
            {".log", "text/plain"},
            {".m3u", "audio/x-mpegurl"},
            {".m4a", "audio/mp4a-latm"},
            {".m4b", "audio/mp4a-latm"},
            {".m4p", "audio/mp4a-latm"},
            {".m4u", "video/vnd.mpegurl"},
            {".m4v", "video/x-m4v"},
            {".mov", "video/quicktime"},
            {".mp2", "audio/x-mpeg"},
            {".mp3", "audio/x-mpeg"},
            {".mp4", "video/mp4"},
            {".mpc", "application/vnd.mpohun.certificate"},
            {".mpe", "video/mpeg"},
            {".mpeg", "video/mpeg"},
            {".mpg", "video/mpeg"},
            {".mpg4", "video/mp4"},
            {".mpga", "audio/mpeg"},
            {".msg", "application/vnd.ms-outlook"},
            {".ogg", "audio/ogg"},
            {".pdf", "application/pdf"},
            {".png", "image/png"},
            {".pps", "application/vnd.ms-powerpoint"},
            {".ppt", "application/vnd.ms-powerpoint"},
            {".pptx",
                    "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
            {".prop", "text/plain"}, {".rc", "text/plain"},
            {".rmvb", "audio/x-pn-realaudio"}, {".rtf", "application/rtf"},
            {".sh", "text/plain"}, {".tar", "application/x-tar"},
            {".tgz", "application/x-compressed"}, {".txt", "text/plain"},
            {".wav", "audio/x-wav"}, {".wma", "audio/x-ms-wma"},
            {".wmv", "audio/x-ms-wmv"},
            {".wps", "application/vnd.ms-works"}, {".xml", "text/plain"},
            {".z", "application/x-compress"},
            {".zip", "application/x-zip-compressed"}, {"", "*/*"}};


}

插件调试

本地注册插件将UniPlugin-Hello-AS工程下 “app” Module根目录assets/dcloud_uniplugins.json文件。 在moudles节点下 添加要注册的Module

{
  "nativePlugins": [
    {
      "hooksClass": "com.siyue.uni.plugin.SiyueDocument_AppProxy",
      "plugins": [
        {
          "type": "module",
          "name": "SiyueDocumentPreview",
          "class": "com.siyue.uni.plugin.SiyueDocumentPreview"
        }
      ]
    }
  ]
}

集成uni-app项目测试插件

安装最新HbuilderX 大于等于1.4.0+

创建uni-app工程或在已有的uni-app工程编写相关的.nvue 和.vue文件。使用uni-app插件中的module 或 component。

xxx.vue 示例代码(源码请参考UniPlugin-Hello-AS项目中uniapp示例工程源码文件夹的unipluginDemo工程)

最终测试结果:



最后修改:2021 年 06 月 29 日 03 : 33 PM