Maui Blazor 中文社区 QQ群:645660665

MAUI 嵌入式 Web 架构实战(九)PicoServer + PWA 离线系统:构建真正的本地 Web App

MAUI 嵌入式 Web 架构实战(九)

PicoServer + PWA 离线系统:构建真正的本地 Web App

源码地址:
https://github.com/densen2014/MauiPicoAdmin

在前面的系列文章中,我们已经逐步构建了一个完整的 PicoServer 本地 Web Admin 系统

  • MAUI 内嵌 Web Server
  • REST API 架构
  • Web Admin 管理后台
  • WebSocket 实时通信
  • Controller 自动发现与插件化

现在系统已经具备:

Web UI
↓
REST API
↓
MAUI 本地服务
↓
SQLite / 设备

但仍然存在一个问题:

如果浏览器离线,系统还能运行吗?

答案是:可以。

通过 PWA(Progressive Web App)+ Service Worker,我们可以让 Web Admin 具备:

  • 离线运行
  • 本地缓存
  • 后台同步
  • 自动更新

最终实现:

PicoServer + PWA = 本地 Web 应用平台


一、系统整体架构

完整架构如下:

            Browser / WebView
                   │
             Service Worker
                   │
       ┌───────────┴───────────┐
       │                       │
   Cache Storage          IndexedDB
       │                       │
       │                       │
       └───────────┬───────────┘
                   │
               PicoServer
                   │
                REST API
                   │
                 MAUI
                   │
             SQLite / Device

说明:

Cache Storage

用于缓存:

HTML
JS
CSS
Images

IndexedDB

用于缓存:

商品列表
订单
离线单据
同步队列

Service Worker

负责:

请求拦截
离线缓存
后台更新

二、PWA 基础组件

PWA 主要由三个部分组成。

1 Manifest

manifest.json:

{
  "name": "Pico Admin",
  "short_name": "PicoAdmin",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1976d2",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}

作用:

  • 允许网页 安装为 App
  • 定义图标
  • 定义启动方式

2 注册 Service Worker

在前端入口:

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
        .then(reg => {
            console.log("SW registered");
        });
}

三、缓存策略设计

在离线系统中,不同资源需要不同策略。

资源类型 策略
HTML Network First
JS / CSS Cache First
图片 Cache First
商品 API Stale While Revalidate

说明:

Cache First

优先读取缓存:

cache → network

适用于:

JS
CSS
图片

Network First

优先访问网络:

network → cache

适用于:

HTML
API

Stale While Revalidate

先返回缓存,再后台更新:

cache → network update

适用于:

商品列表
配置数据

四、完整 Service Worker 实现

sw.js:

const STATIC_CACHE = "pico-static-v1";
const API_CACHE = "pico-api-v1";

const STATIC_FILES = [
    "/",
    "/index.html",
    "/app.js",
    "/style.css"
];

self.addEventListener("install", event => {

    event.waitUntil(
        caches.open(STATIC_CACHE)
            .then(cache => cache.addAll(STATIC_FILES))
    );

});

self.addEventListener("activate", event => {

    event.waitUntil(
        caches.keys().then(keys => {
            return Promise.all(
                keys.filter(k => k !== STATIC_CACHE && k !== API_CACHE)
                    .map(k => caches.delete(k))
            );
        })
    );

});

五、拦截请求

Service Worker 可以拦截所有请求。

fetch → Service Worker
      → Cache
      → Network

实现:

self.addEventListener("fetch", event => {

    const url = new URL(event.request.url);

    // 商品 API
    if (url.pathname.startsWith("/api/products")) {

        event.respondWith(cacheProductApi(event.request));
        return;

    }

    // 静态资源
    event.respondWith(cacheFirst(event.request));

});

六、商品 API 缓存示例

商品接口:

GET /api/products

缓存策略:

Stale While Revalidate

代码:

async function cacheProductApi(request) {

    const cache = await caches.open(API_CACHE);

    const cached = await cache.match(request);

    const networkFetch = fetch(request)
        .then(response => {

            cache.put(request, response.clone());

            return response;

        })
        .catch(() => cached);

    return cached || networkFetch;

}

说明:

流程:

1 返回缓存商品
2 后台请求最新商品
3 更新缓存

用户不会感知延迟。


七、静态资源缓存

async function cacheFirst(request) {

    const cache = await caches.open(STATIC_CACHE);

    const cached = await cache.match(request);

    if (cached) return cached;

    const response = await fetch(request);

    cache.put(request, response.clone());

    return response;

}

适用于:

js
css
images

八、定时刷新商品列表

在前端页面可以定时更新商品缓存。

例如:

setInterval(async () => {

    try {

        await fetch("/api/products");

        console.log("products refreshed");

    } catch (e) {

        console.log("offline");

    }

}, 300000);

5分钟刷新一次商品列表

这样:

缓存商品
↓
后台更新
↓
离线可用

九、离线数据架构

浏览器数据库:

IndexedDB

典型结构:

IndexedDB
 ├─ products
 ├─ orders
 ├─ syncQueue

作用:

商品缓存

products

离线订单

orders

同步队列

syncQueue

同步流程:

离线下单
↓
IndexedDB
↓
网络恢复
↓
POST /api/orders
↓
同步完成

十、PicoServer + PWA 的优势

普通 PWA:

Browser
↓
Internet
↓
Remote Server

PicoServer 架构:

Browser
↓
PicoServer (本地)
↓
MAUI
↓
SQLite

优势:

1 本地 API

http://127.0.0.1/api

延迟:

< 1ms

2 完全离线

系统组件:

UI
API
DB

全部本地运行。


3 跨平台

MAUI 支持:

Windows
Android
iOS
Mac

十一、最终系统结构

完整系统:

        PWA Web Admin
              │
       Service Worker
              │
     ┌────────┴────────┐
     │                 │
 Cache Storage     IndexedDB
     │                 │
     └────────┬────────┘
              │
          PicoServer
              │
            API
              │
            MAUI
              │
          SQLite

一句话总结:

PicoServer + PWA = 本地 Web 应用平台

十二、其他代码

因篇幅有限, 部分代码并未完整贴出, 请参考工程同步源码: https://github.com/densen2014/MauiPicoAdmin

MauiPicoAdmin\Resources\Raw\wwwroot\search.html 部分代码

<input id="productIdInput" type="text" class="form-control form-control-sm" style="width:120px;" placeholder="输入商品ID" oninput="loadProducts(this.value)" autocomplete="off">
<div class="card-body">
<table id="table">
<tbody></tbody>
</table>
<script>
        async function loadProducts(id) {
            id = (id || '').trim();
            if (!id) {
                document.querySelector("#table tbody").innerHTML = "";
                return;
            }
            document.getElementById("spinner").style.display = "block";
            try {
                let res = await fetch(`/api/product/detail?id=${encodeURIComponent(id)}`);
                let json = await res.json();
                let p = json.data || [];
                let tbody = document.querySelector("#table tbody");
                tbody.innerHTML = "";
                let row = `
                            <tr>
                                <td>${p.id}</td>
                                <td>${p.name}</td>
                                <td>${p.price}</td>
                            </tr>
                        `;
                tbody.innerHTML += row;
            } catch (e) {
                document.querySelector("#table tbody").innerHTML = `<tr><td colspan='3' class='text-danger'>加载失败</td></tr>`;
            }
            document.getElementById("spinner").style.display = "none";
        } 
</script> 

下一篇预告

下一篇总结:

完整 App Web Shell 架构

posted @ 2026-03-16 08:50  AlexChow  阅读(164)  评论(0)    收藏  举报