告别广告侵扰:Pi-hole移动管理客户端开发指南

【免费下载链接】pi-hole A black hole for Internet advertisements 【免费下载链接】pi-hole 项目地址: https://gitcode.com/GitHub_Trending/pi/pi-hole

你是否厌倦了在家庭网络中频繁配置Pi-hole(广告拦截器)?每次调整过滤规则都必须登录网页界面,无法在外出时快速响应家人的网络需求?本文将带你从零构建一个功能完备的React Native移动客户端,通过直观的界面实现Pi-hole全功能管理,让网络广告拦截尽在掌控。

读完本文你将获得

  • 完整的Pi-hole API交互方案
  • React Native应用架构设计模板
  • 实时统计数据可视化实现
  • 设备级网络控制权限管理
  • 跨平台部署最佳实践

项目背景与架构设计

Pi-hole工作原理简析

Pi-hole作为网络级广告拦截器,通过以下流程实现全网广告过滤:

mermaid

移动客户端架构设计

采用分层架构确保代码可维护性和扩展性:

mermaid

开发环境搭建

技术栈选择

模块 技术选择 选择理由
前端框架 React Native 0.74+ 跨平台支持,热重载,原生性能
状态管理 Redux Toolkit 简化状态管理,内置不可变更新逻辑
网络请求 Axios + React Query 请求缓存,重试机制,类型安全
UI组件库 React Native Paper Material Design风格,组件丰富
图表展示 Victory Native 高性能SVG图表,动画支持
本地存储 AsyncStorage + Realm 轻量级键值存储+复杂查询支持

环境配置步骤

# 1. 创建新项目
npx react-native init PiholeManager --template react-native-template-typescript

# 2. 安装核心依赖
cd PiholeManager
npm install @reduxjs/toolkit react-redux axios react-query react-native-paper victory-native @react-navigation/native @react-navigation/stack realm

# 3. 安装原生依赖
npx pod-install ios  # iOS环境
# Android环境无需额外操作

# 4. 启动开发服务器
npm start

Pi-hole API交互实现

API认证流程解析

通过分析Pi-hole的api.sh脚本,我们梳理出完整的认证流程:

mermaid

认证实现代码

// services/apiClient.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';

export class PiholeApiClient {
  private api: AxiosInstance;
  private sid: string | null = null;
  private baseUrl: string;
  
  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
    this.api = axios.create({
      timeout: 5000,
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'PiholeManager/1.0.0'
      }
    });
  }
  
  async testAvailability(): Promise<boolean> {
    try {
      const response = await this.api.get('/auth');
      return response.status === 200 || response.status === 401;
    } catch (error) {
      console.error('API可用性检测失败:', error);
      return false;
    }
  }
  
  async login(password: string, totp?: string): Promise<boolean> {
    try {
      const response = await this.api.post('/auth', {
        password,
        totp: totp || null
      });
      
      if (response.data.session?.valid) {
        this.sid = response.data.session.sid;
        this.api.defaults.headers.common['sid'] = this.sid;
        return true;
      }
      return false;
    } catch (error) {
      console.error('登录失败:', error);
      return false;
    }
  }
  
  async logout(): Promise<void> {
    if (this.sid) {
      try {
        await this.api.delete('/auth');
      } finally {
        this.sid = null;
        delete this.api.defaults.headers.common['sid'];
      }
    }
  }
  
  // 通用请求方法
  async request<T>(config: AxiosRequestConfig): Promise<T> {
    try {
      const response = await this.api(config);
      return response.data;
    } catch (error) {
      console.error(`请求失败: ${config.method} ${config.url}`, error);
      throw error;
    }
  }
}

核心API端点封装

// services/piholeService.ts
import { PiholeApiClient } from './apiClient';

export interface PiholeStatus {
  status: 'enabled' | 'disabled';
  gravityLastUpdated: string;
  domainsBeingBlocked: number;
  dnsQueriesToday: number;
  adsBlockedToday: number;
  adsPercentageToday: number;
}

export interface QueryLogEntry {
  timestamp: number;
  type: string;
  domain: string;
  client: string;
  status: string;
}

export class PiholeService {
  private apiClient: PiholeApiClient;
  
  constructor(apiClient: PiholeApiClient) {
    this.apiClient = apiClient;
  }
  
  // 获取Pi-hole状态概览
  async getStatus(): Promise<PiholeStatus> {
    return this.apiClient.request<PiholeStatus>({
      method: 'GET',
      url: 'stats/summary'
    });
  }
  
  // 切换Pi-hole状态
  async toggleStatus(enable: boolean): Promise<boolean> {
    const response = await this.apiClient.request<{ status: string }>({
      method: 'POST',
      url: `enable${enable ? '' : '?disable=300'}`, // 禁用默认5分钟
    });
    return response.status === 'enabled';
  }
  
  // 获取查询日志
  async getQueryLog(limit = 100): Promise<QueryLogEntry[]> {
    return this.apiClient.request<QueryLogEntry[]>({
      method: 'GET',
      url: `logs?limit=${limit}`
    });
  }
  
  // 更多API方法实现...
}

应用核心功能开发

仪表盘界面实现

仪表盘作为应用入口,需要直观展示关键指标和快速操作:

// screens/DashboardScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, RefreshControl } from 'react-native';
import { Text, Card, Button, ActivityIndicator } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPiholeStatus, togglePiholeStatus } from '../store/piholeSlice';
import { VictoryPie, VictoryBar } from 'victory-native';

const DashboardScreen: React.FC = () => {
  const dispatch = useDispatch();
  const { status, stats, loading, error } = useSelector((state) => state.pihole);
  const [refreshing, setRefreshing] = useState(false);
  
  useEffect(() => {
    dispatch(fetchPiholeStatus());
    const interval = setInterval(() => {
      dispatch(fetchPiholeStatus());
    }, 30000); // 每30秒刷新一次
    
    return () => clearInterval(interval);
  }, [dispatch]);
  
  const handleRefresh = async () => {
    setRefreshing(true);
    await dispatch(fetchPiholeStatus());
    setRefreshing(false);
  };
  
  const handleToggleStatus = () => {
    const newStatus = status === 'enabled';
    dispatch(togglePiholeStatus(!newStatus));
  };
  
  if (loading && !stats) {
    return <ActivityIndicator style={styles.loader} size="large" />;
  }
  
  return (
    <View style={styles.container} refreshControl={
      <RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
    }>
      <View style={styles.statusContainer}>
        <Text style={styles.title}>Pi-hole状态</Text>
        <Button 
          mode={status === 'enabled' ? "contained" : "outlined"}
          onPress={handleToggleStatus}
          style={styles.statusButton}
        >
          {status === 'enabled' ? '已启用' : '已禁用'}
        </Button>
      </View>
      
      <View style={styles.statsGrid}>
        <Card style={styles.statCard}>
          <Card.Content>
            <Text style={styles.statLabel}>今日查询</Text>
            <Text style={styles.statValue}>{stats?.dnsQueriesToday.toLocaleString()}</Text>
          </Card.Content>
        </Card>
        
        <Card style={styles.statCard}>
          <Card.Content>
            <Text style={styles.statLabel}>已拦截广告</Text>
            <Text style={styles.statValue}>{stats?.adsBlockedToday.toLocaleString()}</Text>
          </Card.Content>
        </Card>
        
        <Card style={styles.statCard}>
          <Card.Content>
            <Text style={styles.statLabel}>拦截率</Text>
            <Text style={styles.statValue}>{stats?.adsPercentageToday.toFixed(1)}%</Text>
          </Card.Content>
        </Card>
      </View>
      
      <Card style={styles.chartCard}>
        <Card.Content>
          <Text style={styles.chartTitle}>流量分布</Text>
          <View style={styles.chartContainer}>
            <VictoryPie
              data={[
                { x: '已拦截', y: stats?.adsBlockedToday || 0 },
                { x: '已允许', y: (stats?.dnsQueriesToday || 0) - (stats?.adsBlockedToday || 0) }
              ]}
              colors={['#ef5350', '#4caf50']}
              innerRadius={50}
              labelRadius={70}
              style={{ labels: { fontSize: 12 } }}
            />
          </View>
        </Card.Content>
      </Card>
      
      {/* 更多仪表盘内容... */}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: '#f5f5f5',
  },
  statusContainer: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
  },
  statusButton: {
    paddingHorizontal: 20,
  },
  statsGrid: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    justifyContent: 'space-between',
    marginBottom: 20,
  },
  statCard: {
    width: '48%',
    marginBottom: 16,
    elevation: 2,
  },
  statLabel: {
    fontSize: 14,
    color: '#666',
  },
  statValue: {
    fontSize: 24,
    fontWeight: 'bold',
    marginTop: 4,
  },
  chartCard: {
    elevation: 2,
    marginBottom: 20,
  },
  chartTitle: {
    fontSize: 18,
    marginBottom: 16,
    fontWeight: 'bold',
  },
  chartContainer: {
    height: 200,
    alignItems: 'center',
  },
  loader: {
    flex: 1,
    justifyContent: 'center',
  },
});

export default DashboardScreen;

实时数据同步实现

使用React Query实现数据获取和缓存管理:

// hooks/usePiholeData.ts
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { PiholeService } from '../services/piholeService';

export function usePiholeStatus() {
  return useQuery('piholeStatus', 
    async () => {
      const service = new PiholeService(/* apiClient实例 */);
      return service.getStatus();
    },
    {
      refetchInterval: 5000, // 每5秒刷新一次
      staleTime: 1000,
    }
  );
}

export function useTogglePihole() {
  const queryClient = useQueryClient();
  
  return useMutation(
    async (enable: boolean) => {
      const service = new PiholeService(/* apiClient实例 */);
      return service.toggleStatus(enable);
    },
    {
      onSuccess: () => {
        // 切换成功后刷新状态数据
        queryClient.invalidateQueries('piholeStatus');
      }
    }
  );
}

设备管理功能

实现网络中设备的识别和单独控制:

// screens/DevicesScreen.tsx
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList } from 'react-native';
import { Text, Card, Switch, List } from 'react-native-paper';
import { useQuery } from 'react-query';
import { PiholeService } from '../services/piholeService';

interface Device {
  id: string;
  name: string;
  ip: string;
  mac: string;
  blocked: boolean;
  queryCount: number;
}

const DevicesScreen: React.FC = () => {
  const { data: devices, isLoading, refetch } = useQuery<Device[]>(
    'piholeDevices',
    async () => {
      const service = new PiholeService(/* apiClient实例 */);
      return service.getClients();
    }
  );
  
  const handleDeviceToggle = async (deviceId: string, blocked: boolean) => {
    const service = new PiholeService(/* apiClient实例 */);
    await service.setClientBlockStatus(deviceId, !blocked);
    refetch();
  };
  
  const renderDeviceItem = ({ item }: { item: Device }) => (
    <List.Item
      title={item.name || item.ip}
      description={`IP: ${item.ip} | 查询: ${item.queryCount}`}
      right={() => (
        <Switch
          value={item.blocked}
          onValueChange={() => handleDeviceToggle(item.id, item.blocked)}
        />
      )}
    />
  );
  
  if (isLoading) {
    return <Text>加载中...</Text>;
  }
  
  return (
    <View style={styles.container}>
      <FlatList
        data={devices}
        renderItem={renderDeviceItem}
        keyExtractor={item => item.id}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
});

export default DevicesScreen;

高级功能与优化

离线数据同步

使用Realm数据库实现查询日志的本地存储和离线访问:

// services/databaseService.ts
import Realm from 'realm';

// 定义数据模型
class QueryLogSchema extends Realm.Object {
  static schema = {
    name: 'QueryLog',
    primaryKey: 'id',
    properties: {
      id: 'string',
      timestamp: 'date',
      domain: 'string',
      client: 'string',
      type: 'string',
      status: 'string',
    },
  };
}

export class DatabaseService {
  private realm: Realm | null = null;
  
  async init() {
    this.realm = await Realm.open({
      schema: [QueryLogSchema],
      schemaVersion: 1,
    });
  }
  
  async saveQueryLogs(logs: any[]) {
    if (!this.realm) return;
    
    this.realm.write(() => {
      logs.forEach(log => {
        this.realm!.create('QueryLog', {
          id: `${log.timestamp}-${log.domain}`,
          timestamp: new Date(log.timestamp * 1000),
          domain: log.domain,
          client: log.client,
          type: log.type,
          status: log.status,
        }, 'modified');
      });
    });
  }
  
  getLocalQueryLogs(days = 7): any[] {
    if (!this.realm) return [];
    
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - days);
    
    return this.realm.objects('QueryLog')
      .filtered('timestamp >= $0', cutoffDate)
      .sorted('timestamp', true);
  }
  
  // 更多数据库操作方法...
}

应用主题与设置

实现深色/浅色主题切换和个性化设置:

// components/SettingsScreen.tsx
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { 
  Text, Switch, List, 
  DarkTheme, DefaultTheme,
  Provider as PaperProvider
} from 'react-native-paper';
import AsyncStorage from '@react-native-async-storage/async-storage';

const SettingsScreen: React.FC = () => {
  const [darkMode, setDarkMode] = useState(false);
  const [notifications, setNotifications] = useState(true);
  
  useEffect(() => {
    // 从存储加载设置
    const loadSettings = async () => {
      const savedDarkMode = await AsyncStorage.getItem('darkMode');
      if (savedDarkMode !== null) {
        setDarkMode(savedDarkMode === 'true');
      }
    };
    
    loadSettings();
  }, []);
  
  const handleDarkModeToggle = async (value: boolean) => {
    setDarkMode(value);
    await AsyncStorage.setItem('darkMode', value.toString());
    // 主题切换逻辑...
  };
  
  return (
    <View style={styles.container}>
      <List.Section title="外观设置">
        <List.Item
          title="深色模式"
          right={() => (
            <Switch
              value={darkMode}
              onValueChange={handleDarkModeToggle}
            />
          )}
        />
      </List.Section>
      
      <List.Section title="通知设置">
        <List.Item
          title="拦截通知"
          right={() => (
            <Switch
              value={notifications}
              onValueChange={setNotifications}
            />
          )}
        />
      </List.Section>
      
      {/* 更多设置项... */}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

export default SettingsScreen;

应用测试与部署

调试与测试策略

测试类型 工具/方法 重点测试场景
单元测试 Jest + React Native Testing Library API服务、状态管理、工具函数
集成测试 Detox 用户流程、API交互
性能测试 React Native Performance Monitor 列表滚动、图表渲染、网络请求
UI测试 Storybook 组件样式、主题一致性

生产构建与发布

# Android构建
cd android
./gradlew assembleRelease

# iOS构建
cd ios
xcodebuild -workspace PiholeManager.xcworkspace -scheme PiholeManager -configuration Release -archivePath PiholeManager.xcarchive archive
xcodebuild -exportArchive -archivePath PiholeManager.xcarchive -exportPath . -exportOptionsPlist ExportOptions.plist

扩展功能与未来方向

潜在功能扩展

  1. 高级统计分析:实现周/月/年流量趋势分析,异常检测
  2. 自定义过滤规则:允许用户添加/管理自定义域名过滤规则
  3. 家庭共享:多用户权限管理,不同成员不同控制权限
  4. 自动化规则:基于时间、网络条件自动调整过滤策略

技术演进路线

mermaid

总结与资源

通过本文介绍的方法,你已掌握使用React Native开发Pi-hole移动客户端的核心技术。这个项目不仅解决了网络广告拦截的移动管理痛点,也展示了如何将复杂后端服务封装为直观的移动应用。

关键知识点回顾

  • Pi-hole API认证与会话管理
  • React Native状态管理最佳实践
  • 实时数据同步与缓存策略
  • 移动端数据可视化实现
  • 跨平台应用构建与部署

学习资源推荐

立即动手构建属于你的Pi-hole移动客户端,告别广告侵扰,掌控家庭网络!

【免费下载链接】pi-hole A black hole for Internet advertisements 【免费下载链接】pi-hole 项目地址: https://gitcode.com/GitHub_Trending/pi/pi-hole

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐