Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import 'package:analytics_sdk/entity/analytics_tab.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/utils/widget_bridge.dart';
|
||||
|
||||
/// Tab 解析与 track 分发的包内共享工具。
|
||||
///
|
||||
/// 供 widget 层各 analytics 包装使用,消除跨文件重复逻辑。
|
||||
/// 文件名以下划线开头,表示不对外暴露。
|
||||
|
||||
/// 按索引解析 tab,超出范围时用 tab_N 兜底。
|
||||
AnalyticsTab resolveTab(List<AnalyticsTab> tabs, int index) =>
|
||||
index < tabs.length ? tabs[index] : AnalyticsTab('tab_$index');
|
||||
|
||||
/// 解析 tab 展示名称,未配置时通过 PageNameMapper 自动映射。
|
||||
String resolveTabName(AnalyticsTab tab) =>
|
||||
tab.name ?? PageNameMapper.getPageName(tab.key);
|
||||
|
||||
/// 上报事件:优先使用测试注入的 override,否则走 WidgetBridge.track。
|
||||
void trackWith(void Function(dynamic)? override, Object event) =>
|
||||
(override ?? WidgetBridge.track)(event);
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:analytics_sdk/entity/analytics_tab.dart';
|
||||
import 'package:analytics_sdk/entity/app_page_view_event.dart';
|
||||
import 'package:analytics_sdk/entity/navigation_event.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/widget_bridge.dart';
|
||||
import 'package:analytics_sdk/widget/_tab_helpers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// BottomNavigationBar 的 analytics 包装。
|
||||
///
|
||||
/// 使用 Listener + LayoutBuilder 方案:保持原始 child 完整进入 widget 树,
|
||||
/// 通过指针位移计算 tab 索引,无需拷贝 BottomNavigationBar 的所有属性。
|
||||
/// 这样 Flutter 升级新增属性时,SDK 不需要任何改动。
|
||||
///
|
||||
/// 不应直接使用;通过 [AnalyticsBottomNavExtension.withAnalytics] 调用。
|
||||
class AnalyticsBottomNavWrapper extends StatefulWidget {
|
||||
final BottomNavigationBar child;
|
||||
final List<AnalyticsTab> tabs;
|
||||
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
/// 仅供测试使用;null 时走 AnalyticsSdk.instance.track。
|
||||
@visibleForTesting
|
||||
final void Function(dynamic event)? onTrack;
|
||||
|
||||
const AnalyticsBottomNavWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.tabs,
|
||||
this.onTrack,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnalyticsBottomNavWrapper> createState() =>
|
||||
_AnalyticsBottomNavWrapperState();
|
||||
}
|
||||
|
||||
class _AnalyticsBottomNavWrapperState extends State<AnalyticsBottomNavWrapper> {
|
||||
/// 记录 pointer-down 位置,用于判断是否为 tap(而非拖拽)
|
||||
Offset? _tapDownPosition;
|
||||
|
||||
/// 去重:记录最近一次上报的 tab 索引,防止快速连点重复上报
|
||||
int _lastTrackedIndex = -1;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnalyticsBottomNavWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 父组件以编程方式切换 currentIndex 时同步去重状态
|
||||
if (widget.child.currentIndex != oldWidget.child.currentIndex) {
|
||||
_lastTrackedIndex = widget.child.currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯计算:根据 tap 坐标解析 tab 索引。
|
||||
/// 返回 null 表示无效点击(越界、重复、当前已选中)。
|
||||
int? _indexFromTap(
|
||||
double tapX, double totalWidth, TextDirection textDirection) {
|
||||
final itemCount = widget.child.items.length;
|
||||
if (itemCount == 0 || totalWidth <= 0) return null;
|
||||
|
||||
var index =
|
||||
(tapX / (totalWidth / itemCount)).floor().clamp(0, itemCount - 1);
|
||||
|
||||
// RTL 布局下 BottomNavigationBar items 从右到左排列,镜像索引
|
||||
if (textDirection == TextDirection.rtl) {
|
||||
index = itemCount - 1 - index;
|
||||
}
|
||||
|
||||
if (index == _lastTrackedIndex || index == widget.child.currentIndex) {
|
||||
return null;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/// 根据横坐标、容器宽度和文字方向解析 tab 索引并上报埋点。
|
||||
///
|
||||
/// 通过 [SchedulerBinding.addPostFrameCallback] 在渲染完成后上报
|
||||
/// [AppPageViewEvent](含来路 key)和 [NavigationEvent],
|
||||
/// pageLoadTime 反映从点击到渲染完成的真实耗时,与 [PageLifecycleObserver] 保持一致。
|
||||
void _handleTapAt(
|
||||
double tapX, double totalWidth, TextDirection textDirection) {
|
||||
try {
|
||||
if (widget.tabs.isEmpty) return;
|
||||
|
||||
final index = _indexFromTap(tapX, totalWidth, textDirection);
|
||||
if (index == null) return;
|
||||
_lastTrackedIndex = index;
|
||||
|
||||
final tab = resolveTab(widget.tabs, index);
|
||||
final pageKey = tab.key;
|
||||
final pageName = resolveTabName(tab);
|
||||
|
||||
PageNameMapper.addMapping(pageKey, pageName);
|
||||
final referrerKey = PageLifecycleObserver.currentPageKey;
|
||||
final referrerName = PageNameMapper.getPageName(referrerKey);
|
||||
PageLifecycleObserver.recordNavigation(pageKey);
|
||||
|
||||
// 记录 tap 时刻;在下一帧渲染完成后测量实际耗时,与 PageLifecycleObserver 保持一致
|
||||
final tapTime = DateTime.now();
|
||||
final onTrackSnapshot = widget.onTrack;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
final pageLoadTime = DateTime.now().difference(tapTime).inMilliseconds;
|
||||
try {
|
||||
trackWith(
|
||||
onTrackSnapshot,
|
||||
AppPageViewEvent(
|
||||
userType: WidgetBridge.getUserType(),
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
referrerPageKey: referrerKey,
|
||||
referrerPageName: referrerName,
|
||||
currentPageKey: pageKey,
|
||||
currentPageName: pageName,
|
||||
pageLoadTime: pageLoadTime,
|
||||
pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000,
|
||||
));
|
||||
trackWith(
|
||||
onTrackSnapshot,
|
||||
NavigationEvent(
|
||||
navigationKey: pageKey,
|
||||
navigationName: pageName,
|
||||
));
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('BottomNavWrapper.post-frame 上报异常,已安全处理: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('BottomNavWrapper._handleTapAt 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textDirection = Directionality.of(context);
|
||||
return Listener(
|
||||
// 透传所有指针事件,不干扰 BottomNavigationBar 自身的手势识别
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_tapDownPosition = event.localPosition;
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
final down = _tapDownPosition;
|
||||
_tapDownPosition = null;
|
||||
if (down == null) return;
|
||||
// 位移 > 18px(与 Flutter kTouchSlop 对齐)视为拖拽,不触发 tab 埋点
|
||||
if ((event.localPosition - down).distance > 18) return;
|
||||
_handleTapAt(
|
||||
event.localPosition.dx, constraints.maxWidth, textDirection);
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
_tapDownPosition = null;
|
||||
},
|
||||
// 原始 child 完整保留,onTap 由 BottomNavigationBar 自身调用
|
||||
child: widget.child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/widget_bridge.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 埋点调试条 - 展示设备信息、埋点流程 7 步,便于排查某机型上报问题
|
||||
///
|
||||
/// release 包自动隐藏(无性能影响);debug 包由 WidgetBridge 内的 enableDebugBanner 控制。
|
||||
class AnalyticsDebugBanner extends StatelessWidget {
|
||||
const AnalyticsDebugBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kReleaseMode || !WidgetBridge.enableDebugBanner) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return const _AnalyticsDebugBannerContent();
|
||||
}
|
||||
}
|
||||
|
||||
class _AnalyticsDebugBannerContent extends StatefulWidget {
|
||||
const _AnalyticsDebugBannerContent();
|
||||
|
||||
@override
|
||||
State<_AnalyticsDebugBannerContent> createState() =>
|
||||
_AnalyticsDebugBannerContentState();
|
||||
}
|
||||
|
||||
class _AnalyticsDebugBannerContentState
|
||||
extends State<_AnalyticsDebugBannerContent> {
|
||||
List<Map<String, String>> _steps = [];
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshSteps();
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(seconds: 2), (_) => _refreshSteps());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshSteps() {
|
||||
try {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_steps = WidgetBridge.getDebugSteps();
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
final brand = SdkContext.deviceBrand ?? '';
|
||||
final model = SdkContext.deviceModel ?? '';
|
||||
final deviceInfo = brand.isNotEmpty || model.isNotEmpty
|
||||
? '$brand $model'.trim()
|
||||
: (kIsWeb
|
||||
? 'Web'
|
||||
: defaultTargetPlatform == TargetPlatform.android
|
||||
? 'Android'
|
||||
: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? 'iOS'
|
||||
: defaultTargetPlatform.name);
|
||||
final deviceId = SdkContext.deviceId ?? '';
|
||||
final channel = SdkContext.channel ?? '';
|
||||
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.55),
|
||||
border: const Border(
|
||||
bottom: BorderSide(color: Colors.white, width: 2)),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'【埋点测试包】',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'设备: $deviceInfo',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'deviceId: ${deviceId.isEmpty ? "未获取" : "${deviceId.length > 16 ? deviceId.substring(0, 16) : deviceId}..."}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white70),
|
||||
),
|
||||
Text(
|
||||
'渠道: ${channel.isEmpty ? "未获取" : channel}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'埋点流程(卡在某步=该机型问题)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.yellow),
|
||||
),
|
||||
..._steps.map((s) {
|
||||
final status = s['status'] ?? '';
|
||||
final isOk = status.startsWith('✓');
|
||||
final isFail = status.startsWith('✗');
|
||||
var detail = s['detail'] ?? '';
|
||||
if (detail.toString().length > 35) {
|
||||
detail = '${detail.toString().substring(0, 35)}...';
|
||||
}
|
||||
final detailStr =
|
||||
detail.isNotEmpty && detail != '-' ? ' ($detail)' : '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
'${s['name']}: $status$detailStr',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isOk
|
||||
? Colors.greenAccent
|
||||
: isFail
|
||||
? Colors.orange
|
||||
: Colors.white70,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:analytics_sdk/entity/analytics_tab.dart';
|
||||
import 'package:analytics_sdk/entity/app_page_view_event.dart';
|
||||
import 'package:analytics_sdk/entity/navigation_event.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/widget_bridge.dart';
|
||||
import 'package:analytics_sdk/widget/_tab_helpers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// TabBar 的 analytics 包装。
|
||||
///
|
||||
/// 监听 [TabController],Tab 切换完成时在下一帧渲染后自动上报:
|
||||
/// - [AppPageViewEvent]:含来路页面 key(取自切换前的 [PageLifecycleObserver.currentPageKey])
|
||||
/// - [NavigationEvent]:用于导航行为报表
|
||||
///
|
||||
/// 同时调用 [PageLifecycleObserver.recordNavigation] 更新全局 currentPageKey
|
||||
/// 及栈顶 effectivePageKey,确保后续 page_click 归属正确的子页面;
|
||||
/// 即便从详情页 pop 返回,也能恢复到切换前的子 Tab key。
|
||||
///
|
||||
/// 不应直接使用;通过 [AnalyticsTabBarExtension.withAnalytics] 调用。
|
||||
class AnalyticsTabBarWrapper extends StatefulWidget
|
||||
implements PreferredSizeWidget {
|
||||
final TabBar child;
|
||||
final List<AnalyticsTab> tabs;
|
||||
|
||||
/// 仅供测试使用;null 时走 AnalyticsSdk.instance.track。
|
||||
@visibleForTesting
|
||||
final void Function(dynamic event)? onTrack;
|
||||
|
||||
const AnalyticsTabBarWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.tabs,
|
||||
this.onTrack,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => child.preferredSize;
|
||||
|
||||
@override
|
||||
State<AnalyticsTabBarWrapper> createState() => _AnalyticsTabBarWrapperState();
|
||||
}
|
||||
|
||||
class _AnalyticsTabBarWrapperState extends State<AnalyticsTabBarWrapper> {
|
||||
/// 当前监听的 TabController;在 [didChangeDependencies] / [didUpdateWidget] 中维护。
|
||||
TabController? _controller;
|
||||
|
||||
/// 最近一次上报的 tab 索引,过滤 indexIsChanging 动画期间的重复触发。
|
||||
int _lastTrackedIndex = -1;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
try {
|
||||
_attachController(_resolveController());
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk(
|
||||
'AnalyticsTabBarWrapper.didChangeDependencies 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnalyticsTabBarWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
try {
|
||||
final newController = _resolveController();
|
||||
if (newController != _controller) {
|
||||
_attachController(newController);
|
||||
_lastTrackedIndex = -1; // 新 controller 时重置,防止去重误判
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk(
|
||||
'AnalyticsTabBarWrapper.didUpdateWidget 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先取 widget.child.controller,回退到 [DefaultTabController]。
|
||||
TabController? _resolveController() {
|
||||
final controller = widget.child.controller;
|
||||
if (controller != null) return controller;
|
||||
try {
|
||||
return DefaultTabController.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换监听目标:移除旧 controller 监听后绑定新 controller;controller 未变时直接返回。
|
||||
void _attachController(TabController? controller) {
|
||||
if (controller == _controller) return;
|
||||
_controller?.removeListener(_onControllerChange);
|
||||
_controller = controller;
|
||||
_controller?.addListener(_onControllerChange);
|
||||
}
|
||||
|
||||
/// TabController 索引变化回调。
|
||||
///
|
||||
/// 过滤动画帧(indexIsChanging)和重复索引后,通过 [SchedulerBinding.addPostFrameCallback]
|
||||
/// 在渲染完成后上报 [AppPageViewEvent] 和 [NavigationEvent],
|
||||
/// pageLoadTime 反映从点击到渲染完成的真实耗时。
|
||||
void _onControllerChange() {
|
||||
try {
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null || ctrl.indexIsChanging) return;
|
||||
|
||||
final index = ctrl.index;
|
||||
if (index == _lastTrackedIndex) return;
|
||||
_lastTrackedIndex = index;
|
||||
|
||||
if (widget.tabs.isEmpty) return;
|
||||
|
||||
final tab = resolveTab(widget.tabs, index);
|
||||
final pageKey = tab.key;
|
||||
final pageName = resolveTabName(tab);
|
||||
|
||||
PageNameMapper.addMapping(pageKey, pageName);
|
||||
final referrerKey = PageLifecycleObserver.currentPageKey;
|
||||
final referrerName = PageNameMapper.getPageName(referrerKey);
|
||||
PageLifecycleObserver.recordNavigation(pageKey);
|
||||
|
||||
final tapTime = DateTime.now();
|
||||
final onTrackSnapshot = widget.onTrack;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
final pageLoadTime = DateTime.now().difference(tapTime).inMilliseconds;
|
||||
try {
|
||||
trackWith(
|
||||
onTrackSnapshot,
|
||||
AppPageViewEvent(
|
||||
userType: WidgetBridge.getUserType(),
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
referrerPageKey: referrerKey,
|
||||
referrerPageName: referrerName,
|
||||
currentPageKey: pageKey,
|
||||
currentPageName: pageName,
|
||||
pageLoadTime: pageLoadTime,
|
||||
pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000,
|
||||
));
|
||||
trackWith(
|
||||
onTrackSnapshot,
|
||||
NavigationEvent(
|
||||
navigationKey: pageKey,
|
||||
navigationName: pageName,
|
||||
));
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('TabBarWrapper.post-frame 上报异常,已安全处理: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('TabBarWrapper._onControllerChange 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.removeListener(_onControllerChange);
|
||||
_controller = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/entity/page_click_event.dart';
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||||
import 'package:analytics_sdk/utils/widget_bridge.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GlobalClickWrapper extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const GlobalClickWrapper({
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GlobalClickWrapper> createState() => _GlobalClickWrapperState();
|
||||
}
|
||||
|
||||
class _GlobalClickWrapperState extends State<GlobalClickWrapper> {
|
||||
DateTime? _lastClickTime;
|
||||
Offset? _pointerDownPosition;
|
||||
bool _pointerScrolled = false;
|
||||
_ClickScrollSnapshot? _lastScrollSnapshot;
|
||||
final Map<int, _PointerTouchStart> _touchStarts = <int, _PointerTouchStart>{};
|
||||
final Map<int, Offset> _activeTouchPositions = <int, Offset>{};
|
||||
double? _pinchStartDistance;
|
||||
bool _pinchRecorded = false;
|
||||
|
||||
static const double _swipeThresholdPx = 50;
|
||||
static const double _pinchThresholdPx = 20;
|
||||
static const double _longPressMoveTolerancePx = 10;
|
||||
static const _longPressDuration = Duration(milliseconds: 500);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
_recordScroll(notification);
|
||||
return false;
|
||||
},
|
||||
child: Listener(
|
||||
onPointerDown: (PointerDownEvent event) {
|
||||
_pointerDownPosition = event.position;
|
||||
_pointerScrolled = false;
|
||||
_recordPointerDown(event);
|
||||
},
|
||||
onPointerMove: (PointerMoveEvent event) {
|
||||
if (!_pointerScrolled && _pointerDownPosition != null) {
|
||||
final dist = (event.position - _pointerDownPosition!).distance;
|
||||
if (dist > kTouchSlop) {
|
||||
_pointerScrolled = true;
|
||||
}
|
||||
}
|
||||
_recordPointerMove(event);
|
||||
},
|
||||
onPointerUp: (PointerUpEvent event) {
|
||||
if (!_pointerScrolled) {
|
||||
_handleTap(event);
|
||||
}
|
||||
_recordPointerUp(event);
|
||||
_pointerDownPosition = null;
|
||||
_pointerScrolled = false;
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
_recordPointerCancel(event);
|
||||
_pointerDownPosition = null;
|
||||
_pointerScrolled = false;
|
||||
},
|
||||
// 透传所有事件,不影响原有交互
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(PointerUpEvent event) {
|
||||
try {
|
||||
// 点击节流:限制点击事件上报频率,避免高频点击造成性能问题
|
||||
final now = DateTime.now();
|
||||
if (_lastClickTime != null &&
|
||||
now.difference(_lastClickTime!) < SdkConfig.clickThrottleDuration) {
|
||||
return;
|
||||
}
|
||||
_lastClickTime = now;
|
||||
|
||||
// 允许触摸(移动端)、鼠标(桌面/Web)、触控板(macOS);过滤触控笔等其他设备。
|
||||
final kind = event.kind;
|
||||
if (kind != PointerDeviceKind.touch &&
|
||||
kind != PointerDeviceKind.mouse &&
|
||||
kind != PointerDeviceKind.trackpad) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pageKey = PageLifecycleObserver.currentPageKey;
|
||||
|
||||
if (pageKey.isEmpty || pageKey == 'unknown_page') {
|
||||
return;
|
||||
}
|
||||
|
||||
final pageName = PageNameMapper.getPageName(pageKey);
|
||||
|
||||
// 保存事件位置信息,避免在异步回调中访问已失效的事件对象
|
||||
final clickPosition = event.position;
|
||||
|
||||
// 异步获取 RenderBox,避免在 build 阶段调用 findRenderObject
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
if (!mounted) return;
|
||||
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject == null || renderObject is! RenderBox) return;
|
||||
final renderBox = renderObject;
|
||||
|
||||
final size = renderBox.size;
|
||||
|
||||
final clickX = clickPosition.dx.round();
|
||||
final clickY = clickPosition.dy.round();
|
||||
final localPosition = renderBox.globalToLocal(clickPosition);
|
||||
final clickViewportX = localPosition.dx.round();
|
||||
final clickViewportY = localPosition.dy.round();
|
||||
final viewportWidth = size.width.round();
|
||||
final viewportHeight = size.height.round();
|
||||
final percentX = viewportWidth > 0
|
||||
? (clickViewportX / viewportWidth * 100).round().clamp(0, 100)
|
||||
: 0;
|
||||
final percentY = viewportHeight > 0
|
||||
? (clickViewportY / viewportHeight * 100).round().clamp(0, 100)
|
||||
: 0;
|
||||
final scrollSnapshot = _scrollSnapshotFor(
|
||||
pageKey: pageKey,
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
viewportWidth: viewportWidth,
|
||||
viewportHeight: viewportHeight,
|
||||
);
|
||||
|
||||
Logger.globalClick(
|
||||
'上报点击成功 → $pageKey ($clickX,$clickY) [$percentX%,$percentY%]');
|
||||
|
||||
WidgetBridge.track(PageClickEvent.fromViewport(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
clickPageX: clickX,
|
||||
clickPageY: clickY,
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
viewportWidth: viewportWidth,
|
||||
viewportHeight: viewportHeight,
|
||||
clickDocumentX: scrollSnapshot?.clickDocumentX,
|
||||
clickDocumentY: scrollSnapshot?.clickDocumentY,
|
||||
pageWidth: scrollSnapshot?.pageWidth,
|
||||
pageHeight: scrollSnapshot?.pageHeight,
|
||||
scrollTop: scrollSnapshot?.scrollTop,
|
||||
scrollLeft: scrollSnapshot?.scrollLeft,
|
||||
));
|
||||
} catch (e) {
|
||||
Logger.globalClick('点击事件处理异常,已安全处理: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.globalClick('_handleTap() 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _recordScroll(ScrollNotification notification) {
|
||||
try {
|
||||
final pageKey = PageLifecycleObserver.currentPageKey;
|
||||
if (pageKey.isEmpty || pageKey == 'unknown_page') return;
|
||||
|
||||
final metrics = notification.metrics;
|
||||
final scrollOffset = _roundedNonNegativeFinite(metrics.pixels);
|
||||
final viewportExtent =
|
||||
_roundedNonNegativeFinite(metrics.viewportDimension);
|
||||
final contentExtent = _roundedNonNegativeFinite(
|
||||
metrics.maxScrollExtent + metrics.viewportDimension);
|
||||
if (scrollOffset == null ||
|
||||
viewportExtent == null ||
|
||||
contentExtent == null ||
|
||||
contentExtent <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metrics.axis == Axis.vertical) {
|
||||
SessionBehaviorManager.instance.recordScroll(
|
||||
scrollTop: scrollOffset,
|
||||
viewportHeight: viewportExtent,
|
||||
contentHeight: contentExtent,
|
||||
);
|
||||
_lastScrollSnapshot = _ClickScrollSnapshot(
|
||||
pageKey: pageKey,
|
||||
scrollTop: scrollOffset,
|
||||
scrollLeft: 0,
|
||||
pageWidth: -1,
|
||||
pageHeight: contentExtent,
|
||||
);
|
||||
} else if (metrics.axis == Axis.horizontal) {
|
||||
_lastScrollSnapshot = _ClickScrollSnapshot(
|
||||
pageKey: pageKey,
|
||||
scrollTop: 0,
|
||||
scrollLeft: scrollOffset,
|
||||
pageWidth: contentExtent,
|
||||
pageHeight: -1,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.globalClick('滚动快照记录异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _recordPointerDown(PointerDownEvent event) {
|
||||
try {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
final now = DateTime.now();
|
||||
if (_activeTouchPositions.isEmpty) {
|
||||
SessionBehaviorManager.instance.recordTouchStart(at: now);
|
||||
} else {
|
||||
_pointerScrolled = true;
|
||||
}
|
||||
_touchStarts[event.pointer] = _PointerTouchStart(
|
||||
position: event.position,
|
||||
startedAt: now,
|
||||
);
|
||||
_activeTouchPositions[event.pointer] = event.position;
|
||||
_updatePinchBaseline();
|
||||
} catch (e) {
|
||||
Logger.globalClick('触摸开始行为记录异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _recordPointerMove(PointerMoveEvent event) {
|
||||
try {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
if (!_activeTouchPositions.containsKey(event.pointer)) return;
|
||||
_activeTouchPositions[event.pointer] = event.position;
|
||||
if (_activeTouchPositions.length < 2) return;
|
||||
final distance = _firstTwoTouchDistance();
|
||||
if (distance == null) return;
|
||||
_pinchStartDistance ??= distance;
|
||||
if (!_pinchRecorded &&
|
||||
(distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) {
|
||||
_pinchRecorded = true;
|
||||
SessionBehaviorManager.instance.recordPinch();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.globalClick('触摸移动行为记录异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _recordPointerUp(PointerUpEvent event) {
|
||||
try {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
final now = DateTime.now();
|
||||
final wasSingleTouch = _activeTouchPositions.length == 1;
|
||||
final start = _touchStarts[event.pointer];
|
||||
if (start != null && wasSingleTouch) {
|
||||
_recordFinishedSingleTouch(
|
||||
start: start,
|
||||
endPosition: event.position,
|
||||
endedAt: now,
|
||||
);
|
||||
}
|
||||
_touchStarts.remove(event.pointer);
|
||||
_activeTouchPositions.remove(event.pointer);
|
||||
_updatePinchBaseline();
|
||||
} catch (e) {
|
||||
Logger.globalClick('触摸结束行为记录异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _recordPointerCancel(PointerCancelEvent event) {
|
||||
try {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
_touchStarts.remove(event.pointer);
|
||||
_activeTouchPositions.remove(event.pointer);
|
||||
_updatePinchBaseline();
|
||||
} catch (e) {
|
||||
Logger.globalClick('触摸取消行为记录异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _recordFinishedSingleTouch({
|
||||
required _PointerTouchStart start,
|
||||
required Offset endPosition,
|
||||
required DateTime endedAt,
|
||||
}) {
|
||||
final distance = (endPosition - start.position).distance;
|
||||
if (distance >= _swipeThresholdPx) {
|
||||
SessionBehaviorManager.instance.recordSwipe(at: endedAt);
|
||||
return;
|
||||
}
|
||||
if (endedAt.difference(start.startedAt) >= _longPressDuration &&
|
||||
distance <= _longPressMoveTolerancePx) {
|
||||
SessionBehaviorManager.instance.recordLongPress(at: endedAt);
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePinchBaseline() {
|
||||
if (_activeTouchPositions.length < 2) {
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
return;
|
||||
}
|
||||
_pinchStartDistance = _firstTwoTouchDistance();
|
||||
_pinchRecorded = false;
|
||||
}
|
||||
|
||||
double? _firstTwoTouchDistance() {
|
||||
if (_activeTouchPositions.length < 2) return null;
|
||||
final iterator = _activeTouchPositions.values.iterator;
|
||||
iterator.moveNext();
|
||||
final first = iterator.current;
|
||||
iterator.moveNext();
|
||||
final second = iterator.current;
|
||||
return (first - second).distance;
|
||||
}
|
||||
|
||||
_ClickScrollSnapshot? _scrollSnapshotFor({
|
||||
required String pageKey,
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
required int viewportWidth,
|
||||
required int viewportHeight,
|
||||
}) {
|
||||
final snapshot = _lastScrollSnapshot;
|
||||
if (snapshot == null || snapshot.pageKey != pageKey) return null;
|
||||
return snapshot.resolve(
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
viewportWidth: viewportWidth,
|
||||
viewportHeight: viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
|
||||
int? _roundedNonNegativeFinite(double value) {
|
||||
if (!value.isFinite) return null;
|
||||
return _nonNegative(value.round());
|
||||
}
|
||||
}
|
||||
|
||||
class _PointerTouchStart {
|
||||
final Offset position;
|
||||
final DateTime startedAt;
|
||||
|
||||
const _PointerTouchStart({
|
||||
required this.position,
|
||||
required this.startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
class _ClickScrollSnapshot {
|
||||
final String pageKey;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
|
||||
const _ClickScrollSnapshot({
|
||||
required this.pageKey,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
this.clickDocumentX = -1,
|
||||
this.clickDocumentY = -1,
|
||||
});
|
||||
|
||||
_ClickScrollSnapshot resolve({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
required int viewportWidth,
|
||||
required int viewportHeight,
|
||||
}) {
|
||||
final resolvedPageWidth = pageWidth >= 0 ? pageWidth : viewportWidth;
|
||||
final resolvedPageHeight = pageHeight >= 0 ? pageHeight : viewportHeight;
|
||||
return _ClickScrollSnapshot(
|
||||
pageKey: pageKey,
|
||||
scrollTop: scrollTop,
|
||||
scrollLeft: scrollLeft,
|
||||
pageWidth: resolvedPageWidth,
|
||||
pageHeight: resolvedPageHeight,
|
||||
clickDocumentX: clickViewportX + scrollLeft,
|
||||
clickDocumentY: clickViewportY + scrollTop,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user