沭阳县文章资讯

Vue + Android WebView实现大文件PDF预览完整解决方案

2026-04-01 19:20:02 浏览次数:2
详细信息
Vue + Android WebView 大文件PDF预览解决方案

一、整体架构设计

1.1 技术栈选择

前端: Vue 3 + TypeScript + Vite
移动端: Android WebView + Kotlin/Java
PDF处理: PDF.js (Web端) + Android PDF库(可选)
文件传输: 分片上传/下载 + 本地缓存

二、前端(Vue)实现方案

2.1 项目结构

src/
├── components/
│   ├── PDFViewer.vue      # PDF预览组件
│   ├── PDFThumbnails.vue  # 缩略图组件
│   └── PDFControls.vue    # 控制面板
├── utils/
│   ├── pdfLoader.ts       # PDF加载器
│   ├── cacheManager.ts    # 缓存管理
│   └── webviewBridge.ts   # WebView通信桥梁
├── views/
│   └── PDFPreview.vue     # 预览页面
└── types/
    └── pdf.ts             # TypeScript类型定义

2.2 PDF预览组件核心实现

<!-- PDFViewer.vue -->
<template>
  <div class="pdf-viewer-container">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading-overlay">
      <div class="progress-container">
        <div class="progress-bar" :style="{ width: `${progress}%` }"></div>
        <span>{{ progress }}%</span>
      </div>
    </div>

    <!-- PDF容器 -->
    <div ref="pdfContainer" class="pdf-container">
      <canvas 
        v-for="page in visiblePages" 
        :key="page.pageNumber"
        :ref="el => setCanvasRef(page.pageNumber, el)"
        class="pdf-page"
      />
    </div>

    <!-- 控制面板 -->
    <PDFControls
      :current-page="currentPage"
      :total-pages="totalPages"
      :scale="scale"
      @zoom-in="zoomIn"
      @zoom-out="zoomOut"
      @go-to-page="goToPage"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { PDFLoader } from '../utils/pdfLoader'
import PDFControls from './PDFControls.vue'

// 初始化PDF.js
pdfjsLib.GlobalWorkerOptions.workerSrc = 
  'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'

interface Props {
  fileUrl: string
  fileSize?: number
}

const props = withDefaults(defineProps<Props>(), {
  fileSize: 0
})

const pdfContainer = ref<HTMLElement>()
const pdfLoader = ref<PDFLoader>()
const loading = ref(false)
const progress = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const scale = ref(1.5)
const visiblePages = ref<Array<{pageNumber: number}>>([])
const canvasRefs = ref<Map<number, HTMLCanvasElement>>(new Map())

// 分页配置
const PAGES_PER_VIEW = 3

const setCanvasRef = (pageNumber: number, el: HTMLCanvasElement | null) => {
  if (el) {
    canvasRefs.value.set(pageNumber, el)
  }
}

// 初始化PDF加载器
const initPDFLoader = () => {
  pdfLoader.value = new PDFLoader({
    url: props.fileUrl,
    fileSize: props.fileSize,
    onProgress: (loaded, total) => {
      progress.value = Math.round((loaded / total) * 100)
    },
    onLoad: async (pdfDocument) => {
      totalPages.value = pdfDocument.numPages
      await renderVisiblePages()
      loading.value = false
    },
    onError: (error) => {
      console.error('PDF加载失败:', error)
      loading.value = false
    }
  })
}

// 渲染可见页面
const renderVisiblePages = async () => {
  if (!pdfLoader.value) return

  const startPage = Math.max(1, currentPage.value - 1)
  const endPage = Math.min(totalPages.value, currentPage.value + PAGES_PER_VIEW - 1)

  visiblePages.value = []
  for (let i = startPage; i <= endPage; i++) {
    visiblePages.value.push({ pageNumber: i })
  }

  // 批量渲染页面
  await Promise.all(
    visiblePages.value.map(async ({ pageNumber }) => {
      await renderPage(pageNumber)
    })
  )
}

// 渲染单个页面
const renderPage = async (pageNumber: number) => {
  if (!pdfLoader.value || !pdfLoader.value.pdfDocument) return

  try {
    const page = await pdfLoader.value.pdfDocument.getPage(pageNumber)
    const canvas = canvasRefs.value.get(pageNumber)
    if (!canvas) return

    const viewport = page.getViewport({ scale: scale.value })
    canvas.width = viewport.width
    canvas.height = viewport.height

    const context = canvas.getContext('2d')
    if (!context) return

    const renderContext = {
      canvasContext: context,
      viewport: viewport
    }

    await page.render(renderContext).promise
  } catch (error) {
    console.error(`渲染第${pageNumber}页失败:`, error)
  }
}

// 控制方法
const zoomIn = () => {
  scale.value = Math.min(scale.value + 0.25, 3)
  renderVisiblePages()
}

const zoomOut = () => {
  scale.value = Math.max(scale.value - 0.25, 0.5)
  renderVisiblePages()
}

const goToPage = (page: number) => {
  currentPage.value = Math.max(1, Math.min(page, totalPages.value))
  renderVisiblePages()
}

// 生命周期
onMounted(async () => {
  loading.value = true
  initPDFLoader()
  await pdfLoader.value?.load()
})

onUnmounted(() => {
  pdfLoader.value?.cleanup()
})

// 监听文件URL变化
watch(() => props.fileUrl, () => {
  if (pdfLoader.value) {
    pdfLoader.value.cleanup()
  }
  initPDFLoader()
  pdfLoader.value?.load()
})
</script>

<style scoped>
.pdf-viewer-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: auto;
  background: #f0f0f0;
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.progress-container {
  width: 80%;
  background: #e0e0e0;
  border-radius: 10px;
  overflow: hidden;
  position: relative;
}

.progress-bar {
  height: 20px;
  background: linear-gradient(90deg, #4CAF50, #8BC34A);
  transition: width 0.3s ease;
}

.progress-container span {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  color: #333;
  font-weight: bold;
}

.pdf-container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.pdf-page {
  margin: 10px 0;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  background: white;
}
</style>

2.3 PDF加载器实现(支持大文件分片加载)

// utils/pdfLoader.ts
import * as pdfjsLib from 'pdfjs-dist'

interface PDFLoaderOptions {
  url: string
  fileSize: number
  onProgress?: (loaded: number, total: number) => void
  onLoad?: (pdfDocument: pdfjsLib.PDFDocumentProxy) => void
  onError?: (error: Error) => void
}

export class PDFLoader {
  private pdfDocument: pdfjsLib.PDFDocumentProxy | null = null
  private options: PDFLoaderOptions
  private loadingTask: pdfjsLib.PDFDocumentLoadingTask | null = null
  private chunkSize: number = 1024 * 1024 // 1MB分片
  private loadedChunks: Uint8Array[] = []
  private totalLoaded: number = 0

  constructor(options: PDFLoaderOptions) {
    this.options = options
  }

  // 分片加载PDF
  async load(): Promise<void> {
    try {
      if (this.options.fileSize <= this.chunkSize * 4) {
        // 小文件直接加载
        await this.loadFullPDF()
      } else {
        // 大文件分片加载
        await this.loadChunkedPDF()
      }
    } catch (error) {
      this.options.onError?.(error as Error)
    }
  }

  private async loadFullPDF(): Promise<void> {
    this.loadingTask = pdfjsLib.getDocument({
      url: this.options.url,
      rangeChunkSize: this.chunkSize,
      disableAutoFetch: true
    })

    this.loadingTask.onProgress = (progress) => {
      this.options.onProgress?.(progress.loaded, progress.total)
    }

    this.pdfDocument = await this.loadingTask.promise
    this.options.onLoad?.(this.pdfDocument)
  }

  private async loadChunkedPDF(): Promise<void> {
    const totalChunks = Math.ceil(this.options.fileSize / this.chunkSize)

    for (let i = 0; i < totalChunks; i++) {
      const start = i * this.chunkSize
      const end = Math.min(start + this.chunkSize, this.options.fileSize) - 1

      const chunk = await this.fetchChunk(start, end)
      this.loadedChunks.push(chunk)
      this.totalLoaded += chunk.length

      this.options.onProgress?.(this.totalLoaded, this.options.fileSize)

      // 当加载到足够数据时开始解析PDF
      if (i === 0 || this.totalLoaded > this.chunkSize * 2) {
        await this.updatePDFDocument()
      }
    }
  }

  private async fetchChunk(start: number, end: number): Promise<Uint8Array> {
    const response = await fetch(this.options.url, {
      headers: {
        'Range': `bytes=${start}-${end}`
      }
    })

    if (!response.ok) {
      throw new Error(`分片加载失败: ${response.status}`)
    }

    const arrayBuffer = await response.arrayBuffer()
    return new Uint8Array(arrayBuffer)
  }

  private async updatePDFDocument(): Promise<void> {
    // 合并已加载的分片
    const totalLength = this.loadedChunks.reduce((sum, chunk) => sum + chunk.length, 0)
    const mergedData = new Uint8Array(totalLength)

    let offset = 0
    for (const chunk of this.loadedChunks) {
      mergedData.set(chunk, offset)
      offset += chunk.length
    }

    // 创建PDF文档
    if (!this.pdfDocument) {
      this.loadingTask = pdfjsLib.getDocument({
        data: mergedData,
        rangeChunkSize: this.chunkSize,
        disableAutoFetch: true
      })

      this.pdfDocument = await this.loadingTask.promise
      this.options.onLoad?.(this.pdfDocument)
    }
  }

  cleanup(): void {
    if (this.pdfDocument) {
      this.pdfDocument.destroy()
      this.pdfDocument = null
    }

    if (this.loadingTask) {
      this.loadingTask.destroy()
      this.loadingTask = null
    }

    this.loadedChunks = []
    this.totalLoaded = 0
  }
}

2.4 WebView通信桥梁

// utils/webviewBridge.ts
interface BridgeMessage {
  type: string
  data: any
  callbackId?: string
}

export class WebViewBridge {
  private callbacks: Map<string, (data: any) => void> = new Map()
  private messageQueue: BridgeMessage[] = []

  constructor() {
    this.setupMessageListener()
  }

  // 发送消息到Android
  sendToAndroid(type: string, data?: any): Promise<any> {
    return new Promise((resolve) => {
      const callbackId = `callback_${Date.now()}_${Math.random()}`

      this.callbacks.set(callbackId, (response) => {
        resolve(response)
        this.callbacks.delete(callbackId)
      })

      const message: BridgeMessage = {
        type,
        data,
        callbackId
      }

      if (window.AndroidBridge) {
        // 直接调用Android接口
        window.AndroidBridge.postMessage(JSON.stringify(message))
      } else {
        // 使用自定义协议(fallback)
        this.sendViaCustomScheme(message)
      }
    })
  }

  // 设置消息监听器
  private setupMessageListener(): void {
    window.addEventListener('message', (event) => {
      try {
        const message: BridgeMessage = typeof event.data === 'string' 
          ? JSON.parse(event.data) 
          : event.data

        if (message.callbackId && this.callbacks.has(message.callbackId)) {
          const callback = this.callbacks.get(message.callbackId)!
          callback(message.data)
        }
      } catch (error) {
        console.error('解析消息失败:', error)
      }
    })
  }

  // 通过自定义协议发送(兼容性方案)
  private sendViaCustomScheme(message: BridgeMessage): void {
    const url = `androidbridge://${encodeURIComponent(JSON.stringify(message))}`
    const iframe = document.createElement('iframe')
    iframe.style.display = 'none'
    iframe.src = url

    document.body.appendChild(iframe)
    setTimeout(() => {
      document.body.removeChild(iframe)
    }, 0)
  }

  // 请求本地文件
  async requestLocalFile(filePath: string): Promise<string> {
    return this.sendToAndroid('GET_LOCAL_FILE', { filePath })
  }

  // 获取设备信息
  async getDeviceInfo(): Promise<DeviceInfo> {
    return this.sendToAndroid('GET_DEVICE_INFO')
  }

  // 下载文件到本地
  async downloadFile(url: string, fileName: string): Promise<string> {
    return this.sendToAndroid('DOWNLOAD_FILE', { url, fileName })
  }

  // 检查文件缓存
  async checkFileCache(fileUrl: string): Promise<CacheInfo> {
    return this.sendToAndroid('CHECK_CACHE', { fileUrl })
  }
}

// 全局声明
declare global {
  interface Window {
    AndroidBridge?: {
      postMessage: (message: string) => void
    }
  }
}

export const bridge = new WebViewBridge()

三、Android端实现

3.1 Android WebView配置

// PDFWebViewActivity.kt
class PDFWebViewActivity : AppCompatActivity() {
    private lateinit var webView: WebView
    private lateinit var progressBar: ProgressBar
    private var downloadManager: DownloadManager? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_pdf_webview)

        webView = findViewById(R.id.webView)
        progressBar = findViewById(R.id.progressBar)

        setupWebView()
        loadPDFViewer()
    }

    private fun setupWebView() {
        // 启用JavaScript
        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true
        webView.settings.setAppCacheEnabled(true)
        webView.settings.cacheMode = WebSettings.LOAD_DEFAULT

        // 设置WebChromeClient支持进度显示
        webView.webChromeClient = object : WebChromeClient() {
            override fun onProgressChanged(view: WebView?, newProgress: Int) {
                progressBar.progress = newProgress
                if (newProgress == 100) {
                    progressBar.visibility = View.GONE
                }
            }
        }

        // 设置WebViewClient拦截请求
        webView.webViewClient = object : WebViewClient() {
            override fun shouldInterceptRequest(
                view: WebView?,
                request: WebResourceRequest
            ): WebResourceResponse? {
                val url = request.url.toString()

                // 拦截PDF文件请求,使用本地缓存
                if (url.endsWith(".pdf")) {
                    return handlePDFRequest(url, request)
                }

                return super.shouldInterceptRequest(view, request)
            }

            override fun onReceivedError(
                view: WebView?,
                request: WebResourceRequest?,
                error: WebResourceError?
            ) {
                // 处理加载错误
                handleLoadError(error)
            }
        }

        // 添加JavaScript接口
        webView.addJavascriptInterface(WebViewBridge(this), "AndroidBridge")
    }

    private fun handlePDFRequest(
        url: String,
        request: WebResourceRequest
    ): WebResourceResponse? {
        return try {
            // 检查本地缓存
            val cachedFile = getCachedFile(url)
            if (cachedFile != null && cachedFile.exists()) {
                // 从缓存返回
                val mimeType = "application/pdf"
                val inputStream = FileInputStream(cachedFile)
                WebResourceResponse(mimeType, "UTF-8", inputStream)
            } else {
                // 下载并缓存文件
                downloadAndCachePDF(url)
                null
            }
        } catch (e: Exception) {
            Log.e("PDFWebView", "处理PDF请求失败", e)
            null
        }
    }

    private fun getCachedFile(url: String): File? {
        val fileName = getFileNameFromUrl(url)
        val cacheDir = getExternalFilesDir("pdf_cache")
        return File(cacheDir, fileName)
    }

    private fun downloadAndCachePDF(url: String) {
        // 使用DownloadManager或OkHttp下载文件
        val request = DownloadManager.Request(Uri.parse(url))
            .setTitle("下载PDF文件")
            .setDescription("正在下载...")
            .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
            .setDestinationInExternalFilesDir(this, "pdf_cache", getFileNameFromUrl(url))

        downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
        downloadManager?.enqueue(request)
    }

    private fun getFileNameFromUrl(url: String): String {
        return try {
            URL(url).path.substringAfterLast('/')
        } catch (e: Exception) {
            "document_${System.currentTimeMillis()}.pdf"
        }
    }

    private fun loadPDFViewer() {
        // 加载本地HTML或远程URL
        webView.loadUrl("file:///android_asset/pdf_viewer.html")
        // 或者加载远程地址
        // webView.loadUrl("https://your-domain.com/pdf-viewer")
    }

    // WebView与JavaScript通信桥梁
    inner class WebViewBridge(private val context: Context) {
        @JavascriptInterface
        fun postMessage(message: String) {
            runOnUiThread {
                handleJavaScriptMessage(message)
            }
        }

        @JavascriptInterface
        fun getDeviceInfo(): String {
            val deviceInfo = mapOf(
                "platform" to "Android",
                "version" to Build.VERSION.RELEASE,
                "model" to Build.MODEL,
                "sdkVersion" to Build.VERSION.SDK_INT
            )
            return Gson().toJson(deviceInfo)
        }

        @JavascriptInterface
        fun downloadFile(url: String, fileName: String): String {
            return try {
                val filePath = downloadFileToCache(url, fileName)
                mapOf("success" to true, "filePath" to filePath).toJson()
            } catch (e: Exception) {
                mapOf("success" to false, "error" to e.message).toJson()
            }
        }
    }
}

3.2 大文件处理优化

// PDFCacheManager.kt
class PDFCacheManager(private val context: Context) {
    private val cacheDir: File by lazy {
        File(context.externalCacheDir, "pdf_cache").apply {
            if (!exists()) mkdirs()
        }
    }

    // 分片下载大文件
    suspend fun downloadLargeFile(
        url: String,
        fileName: String,
        chunkSize: Long = 1024 * 1024 // 1MB
    ): File {
        val outputFile = File(cacheDir, fileName)

        return withContext(Dispatchers.IO) {
            val connection = URL(url).openConnection() as HttpURLConnection
            try {
                val totalSize = connection.contentLengthLong
                var downloaded: Long = 0

                // 支持断点续传
                if (outputFile.exists()) {
                    downloaded = outputFile.length()
                    connection.setRequestProperty("Range", "bytes=$downloaded-")
                }

                connection.connect()

                val inputStream = connection.inputStream
                val outputStream = FileOutputStream(outputFile, downloaded > 0)

                val buffer = ByteArray(8192)
                var bytesRead: Int

                while (inputStream.read(buffer).also { bytesRead = it } != -1) {
                    outputStream.write(buffer, 0, bytesRead)
                    downloaded += bytesRead

                    // 发送下载进度
                    sendProgress(downloaded, totalSize)
                }

                outputStream.close()
                inputStream.close()

                outputFile
            } finally {
                connection.disconnect()
            }
        }
    }

    // 文件分片读取
    fun readFileChunks(file: File, chunkSize: Int = 1024 * 1024): Flow<ByteArray> {
        return flow {
            val buffer = ByteArray(chunkSize)
            val inputStream = FileInputStream(file)

            var bytesRead: Int
            while (inputStream.read(buffer).also { bytesRead = it } != -1) {
                val chunk = if (bytesRead == chunkSize) {
                    buffer
                } else {
                    buffer.copyOf(bytesRead)
                }
                emit(chunk)
            }

            inputStream.close()
        }.flowOn(Dispatchers.IO)
    }

    // 清理过期缓存
    fun cleanCache(maxAgeDays: Int = 7) {
        val cutoffTime = System.currentTimeMillis() - (maxAgeDays * 24 * 60 * 60 * 1000)

        cacheDir.listFiles()?.forEach { file ->
            if (file.lastModified() < cutoffTime) {
                file.delete()
            }
        }
    }

    private fun sendProgress(downloaded: Long, total: Long) {
        // 可以通过EventBus或回调发送进度
        val progress = (downloaded.toFloat() / total * 100).toInt()
        // 发送进度更新
    }
}

四、性能优化方案

4.1 前端优化策略

// utils/performanceOptimizer.ts
export class PDFPerformanceOptimizer {
  private static instance: PDFPerformanceOptimizer
  private renderQueue: Map<number, Function> = new Map()
  private isRendering = false

  static getInstance(): PDFPerformanceOptimizer {
    if (!PDFPerformanceOptimizer.instance) {
      PDFPerformanceOptimizer.instance = new PDFPerformanceOptimizer()
    }
    return PDFPerformanceOptimizer.instance
  }

  // 防抖渲染
  debouncedRender(pageNumber: number, renderFn: Function, delay = 100): void {
    // 取消同页面的未执行渲染
    if (this.renderQueue.has(pageNumber)) {
      const oldFn = this.renderQueue.get(pageNumber)!
      this.renderQueue.delete(pageNumber)
    }

    // 添加新的渲染任务
    this.renderQueue.set(pageNumber, () => {
      renderFn()
      this.renderQueue.delete(pageNumber)
    })

    // 执行渲染队列
    if (!this.isRendering) {
      this.isRendering = true
      setTimeout(() => {
        this.executeRenderQueue()
        this.isRendering = false
      }, delay)
    }
  }

  private executeRenderQueue(): void {
    this.renderQueue.forEach(fn => fn())
    this.renderQueue.clear()
  }

  // 图片懒加载
  setupLazyLoading(container: HTMLElement): void {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const canvas = entry.target as HTMLCanvasElement
          const pageNumber = parseInt(canvas.dataset.pageNumber || '1')
          this.triggerRender(pageNumber)
          observer.unobserve(canvas)
        }
      })
    }, {
      root: container,
      threshold: 0.1
    })

    container.querySelectorAll('canvas[data-lazy]').forEach(canvas => {
      observer.observe(canvas)
    })
  }

  private triggerRender(pageNumber: number): void {
    // 触发具体页面的渲染
  }

  // 内存管理
  static cleanupMemory(canvasMap: Map<number, HTMLCanvasElement>): void {
    canvasMap.forEach((canvas, pageNumber) => {
      const context = canvas.getContext('2d')
      context?.clearRect(0, 0, canvas.width, canvas.height)
      canvas.width = 0
      canvas.height = 0
    })
    canvasMap.clear()
  }
}

4.2 缓存策略

// utils/cacheManager.ts
interface CacheConfig {
  maxSize: number
  maxAge: number
  strategy: 'lru' | 'fifo'
}

export class PDFCacheManager {
  private cache: Map<string, CacheItem> = new Map()
  private config: CacheConfig

  constructor(config?: Partial<CacheConfig>) {
    this.config = {
      maxSize: 100 * 1024 * 1024, // 100MB
      maxAge: 24 * 60 * 60 * 1000, // 24小时
      strategy: 'lru',
      ...config
    }
  }

  async getOrLoad<T>(
    key: string,
    loader: () => Promise<T>,
    sizeEstimator?: (data: T) => number
  ): Promise<T> {
    // 检查缓存
    const cached = this.cache.get(key)
    if (cached && !this.isExpired(cached)) {
      // 更新LRU位置
      cached.lastAccessed = Date.now()
      return cached.data as T
    }

    // 加载数据
    const data = await loader()
    const size = sizeEstimator?.(data) || 1

    // 清理空间
    this.makeSpace(size)

    // 存入缓存
    this.cache.set(key, {
      data,
      size,
      createdAt: Date.now(),
      lastAccessed: Date.now()
    })

    return data
  }

  private makeSpace(requiredSize: number): void {
    let currentSize = Array.from(this.cache.values())
      .reduce((sum, item) => sum + item.size, 0)

    if (currentSize + requiredSize <= this.config.maxSize) {
      return
    }

    // 根据策略清理缓存
    const entries = Array.from(this.cache.entries())

    if (this.config.strategy === 'lru') {
      entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
    } else {
      entries.sort((a, b) => a[1].createdAt - b[1].createdAt)
    }

    for (const [key, item] of entries) {
      if (currentSize + requiredSize <= this.config.maxSize) {
        break
      }
      this.cache.delete(key)
      currentSize -= item.size
    }
  }

  private isExpired(item: CacheItem): boolean {
    return Date.now() - item.createdAt > this.config.maxAge
  }
}

interface CacheItem {
  data: any
  size: number
  createdAt: number
  lastAccessed: number
}

五、部署与配置

5.1 Vue项目打包配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  base: './', // 使用相对路径
  build: {
    outDir: 'dist',
    assetsDir: 'assets',
    rollupOptions: {
      output: {
        manualChunks: {
          'pdfjs': ['pdfjs-dist'],
          'vendor': ['vue', 'vue-router']
        },
        chunkFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
      }
    },
    // 优化配置
    chunkSizeWarningLimit: 1000,
    cssCodeSplit: true,
    sourcemap: false
  },
  server: {
    proxy: {
      '/api': {
        target: 'http://your-backend.com',
        changeOrigin: true
      }
    }
  }
})

5.2 Android WebView配置清单

<!-- AndroidManifest.xml 部分配置 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
    android:allowBackup="true"
    android:hardwareAccelerated="true"
    android:largeHeap="true">

    <activity
        android:name=".PDFWebViewActivity"
        android:configChanges="orientation|screenSize|keyboardHidden"
        android:windowSoftInputMode="adjustResize">
        <intent-filter>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.DEFAULT" />
            <data android:mimeType="application/pdf" />
        </intent-filter>
    </activity>

    <!-- 文件提供者 -->
    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
</application>

六、错误处理与监控

// utils/errorHandler.ts
export class PDFErrorHandler {
  private static errors: ErrorLog[] = []

  static handleError(error: Error, context?: string): void {
    const errorLog: ErrorLog = {
      message: error.message,
      stack: error.stack,
      context,
      timestamp: Date.now(),
      userAgent: navigator.userAgent
    }

    this.errors.push(errorLog)

    // 发送到监控系统
    this.reportToServer(errorLog)

    // 用户友好提示
    this.showUserMessage(error)
  }

  static handleWebViewError(errorCode: number, description: string): void {
    const errorMap: Record<number, string> = {
      400: '请求参数错误',
      403: '访问被拒绝',
      404: '文件不存在',
      500: '服务器错误',
      503: '服务不可用'
    }

    const userMessage = errorMap[errorCode] || '加载失败,请重试'
    this.showUserMessage(new Error(userMessage))
  }

  private static showUserMessage(error: Error): void {
    // 显示友好的错误提示
    const message = this.getFriendlyMessage(error)

    // 使用Toast或模态框显示
    if (typeof window.AndroidBridge !== 'undefined') {
      window.AndroidBridge.showToast?.(message)
    } else {
      alert(message)
    }
  }

  private static getFriendlyMessage(error: Error): string {
    const message = error.message.toLowerCase()

    if (message.includes('network')) {
      return '网络连接失败,请检查网络设置'
    } else if (message.includes('timeout')) {
      return '请求超时,请重试'
    } else if (message.includes('format')) {
      return '文件格式错误'
    } else if (message.includes('permission')) {
      return '没有文件访问权限'
    } else {
      return '加载失败,请重试'
    }
  }

  private static reportToServer(errorLog: ErrorLog): void {
    // 发送错误日志到服务器
    fetch('/api/error-log', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(errorLog)
    }).catch(console.error)
  }
}

interface ErrorLog {
  message: string
  stack?: string
  context?: string
  timestamp: number
  userAgent: string
}

七、最佳实践建议

7.1 性能优化

分片加载:大文件采用分片加载,优先加载前几页 内存管理:及时清理不可见的Canvas元素 缓存策略:实现LRU缓存,避免重复下载 懒加载:页面滚动时动态加载可见区域

7.2 用户体验

进度显示:显示下载和渲染进度 错误处理:友好的错误提示和重试机制 离线支持:支持离线查看已缓存文件 手势支持:双指缩放、滑动翻页

7.3 安全考虑

文件校验:验证PDF文件完整性 沙箱隔离:WebView运行在独立进程 权限控制:最小化权限申请 XSS防护:严格过滤用户输入

总结

这个解决方案结合了Vue的前端渲染能力和Android WebView的本地功能,实现了:

大文件支持:通过分片加载技术处理GB级PDF文件 性能优化:懒加载、内存管理、缓存策略 完整功能:缩放、翻页、缩略图、搜索等 离线可用:完善的缓存机制 跨平台:可扩展为iOS和其他平台

实际部署时,需要根据具体业务需求调整配置参数,并进行充分的测试。

相关推荐