• Flutter 借助SearchDelegate实现搜索页面,实现搜索建议、搜索结果,解决IOS拼音问题


    搜索界面使用Flutter自带的SearchDelegate组件实现,通过魔改实现如下效果:

    1. 搜素建议
    2. 搜索结果,支持刷新和加载更多
    3. IOS中文输入拼音问题

    界面预览#

    拷贝源码#

    将SearchDelegate的源码拷贝一份,修改内容如下:

    import 'package:flutter/material.dart';
    import 'package:flutter/services.dart';
    
    /// 修改此处为 showMySearch
    Future showMySearch({
      required BuildContext context,
      required MySearchDelegate delegate,
      String? query = '',
      bool useRootNavigator = false,
    }) {
      delegate.query = query ?? delegate.query;
      delegate._currentBody = _SearchBody.suggestions;
      return Navigator.of(context, rootNavigator: useRootNavigator)
          .push(_SearchPageRoute(
        delegate: delegate,
      ));
    }
    
    /// https://juejin.cn/post/7090374603951833118
    abstract class MySearchDelegate<T> {
      MySearchDelegate({
        this.searchFieldLabel,
        this.searchFieldStyle,
        this.searchFieldDecorationTheme,
        this.keyboardType,
        this.textInputAction = TextInputAction.search,
      }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);
    
      Widget buildSuggestions(BuildContext context);
    
      Widget buildResults(BuildContext context);
    
      Widget? buildLeading(BuildContext context);
    
      bool? automaticallyImplyLeading;
    
      double? leadingWidth;
    
      List? buildActions(BuildContext context);
    
      PreferredSizeWidget? buildBottom(BuildContext context) => null;
    
      Widget? buildFlexibleSpace(BuildContext context) => null;
    
      ThemeData appBarTheme(BuildContext context) {
        final ThemeData theme = Theme.of(context);
        final ColorScheme colorScheme = theme.colorScheme;
        return theme.copyWith(
          appBarTheme: AppBarTheme(
            systemOverlayStyle: colorScheme.brightness == Brightness.dark
                ? SystemUiOverlayStyle.light
                : SystemUiOverlayStyle.dark,
            backgroundColor: colorScheme.brightness == Brightness.dark
                ? Colors.grey[900]
                : Colors.white,
            iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
            titleTextStyle: theme.textTheme.titleLarge,
            toolbarTextStyle: theme.textTheme.bodyMedium,
          ),
          inputDecorationTheme: searchFieldDecorationTheme ??
              InputDecorationTheme(
                hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,
                border: InputBorder.none,
              ),
        );
      }
    
      String get query => _queryTextController.completeText;
    
      set query(String value) {
        _queryTextController.completeText = value; // 更新实际搜索内容
        _queryTextController.text = value; // 更新输入框内容
        if (_queryTextController.text.isNotEmpty) {
          _queryTextController.selection = TextSelection.fromPosition(
              TextPosition(offset: _queryTextController.text.length));
        }
      }
    
      void showResults(BuildContext context) {
        _focusNode?.unfocus();
        _currentBody = _SearchBody.results;
      }
    
      void showSuggestions(BuildContext context) {
        assert(_focusNode != null,
            '_focusNode must be set by route before showSuggestions is called.');
        _focusNode!.requestFocus();
        _currentBody = _SearchBody.suggestions;
      }
    
      void close(BuildContext context, T result) {
        _currentBody = null;
        _focusNode?.unfocus();
        Navigator.of(context)
          ..popUntil((Route<dynamic> route) => route == _route)
          ..pop(result);
      }
    
      final String? searchFieldLabel;
    
      final TextStyle? searchFieldStyle;
    
      final InputDecorationTheme? searchFieldDecorationTheme;
    
      final TextInputType? keyboardType;
    
      final TextInputAction textInputAction;
    
      Animation<double> get transitionAnimation => _proxyAnimation;
    
      FocusNode? _focusNode;
    
      final ChinaTextEditController _queryTextController = ChinaTextEditController();
    
      final ProxyAnimation _proxyAnimation =
          ProxyAnimation(kAlwaysDismissedAnimation);
    
      final ValueNotifier<_SearchBody?> _currentBodyNotifier =
          ValueNotifier<_SearchBody?>(null);
    
      _SearchBody? get _currentBody => _currentBodyNotifier.value;
      set _currentBody(_SearchBody? value) {
        _currentBodyNotifier.value = value;
      }
    
      _SearchPageRoute? _route;
    
      /// Releases the resources.
      @mustCallSuper
      void dispose() {
        _currentBodyNotifier.dispose();
        _focusNode?.dispose();
        _queryTextController.dispose();
        _proxyAnimation.parent = null;
      }
    }
    
    /// search page.
    enum _SearchBody {
      suggestions,
    
      results,
    }
    
    class _SearchPageRoute<T> extends PageRoute<T> {
      _SearchPageRoute({
        required this.delegate,
      }) {
        assert(
          delegate._route == null,
          'The ${delegate.runtimeType} instance is currently used by another active '
          'search. Please close that search by calling close() on the MySearchDelegate '
          'before opening another search with the same delegate instance.',
        );
        delegate._route = this;
      }
    
      final MySearchDelegate delegate;
    
      @override
      Color? get barrierColor => null;
    
      @override
      String? get barrierLabel => null;
    
      @override
      Duration get transitionDuration => const Duration(milliseconds: 300);
    
      @override
      bool get maintainState => false;
    
      @override
      Widget buildTransitions(
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
        Widget child,
      ) {
        return FadeTransition(
          opacity: animation,
          child: child,
        );
      }
    
      @override
      Animation<double> createAnimation() {
        final Animation<double> animation = super.createAnimation();
        delegate._proxyAnimation.parent = animation;
        return animation;
      }
    
      @override
      Widget buildPage(
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
      ) {
        return _SearchPage(
          delegate: delegate,
          animation: animation,
        );
      }
    
      @override
      void didComplete(T? result) {
        super.didComplete(result);
        assert(delegate._route == this);
        delegate._route = null;
        delegate._currentBody = null;
      }
    }
    
    class _SearchPage<T> extends StatefulWidget {
      const _SearchPage({
        required this.delegate,
        required this.animation,
      });
    
      final MySearchDelegate delegate;
      final Animation<double> animation;
    
      @override
      State createState() => _SearchPageState();
    }
    
    class _SearchPageState<T> extends State<_SearchPage<T>> {
      // This node is owned, but not hosted by, the search page. Hosting is done by
      // the text field.
      FocusNode focusNode = FocusNode();
    
      @override
      void initState() {
        super.initState();
        widget.delegate._queryTextController.addListener(_onQueryChanged);
        widget.animation.addStatusListener(_onAnimationStatusChanged);
        widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
        focusNode.addListener(_onFocusChanged);
        widget.delegate._focusNode = focusNode;
      }
    
      @override
      void dispose() {
        super.dispose();
        widget.delegate._queryTextController.removeListener(_onQueryChanged);
        widget.animation.removeStatusListener(_onAnimationStatusChanged);
        widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
        widget.delegate._focusNode = null;
        focusNode.dispose();
      }
    
      void _onAnimationStatusChanged(AnimationStatus status) {
        if (status != AnimationStatus.completed) {
          return;
        }
        widget.animation.removeStatusListener(_onAnimationStatusChanged);
        if (widget.delegate._currentBody == _SearchBody.suggestions) {
          focusNode.requestFocus();
        }
      }
    
      @override
      void didUpdateWidget(_SearchPage oldWidget) {
        super.didUpdateWidget(oldWidget);
        if (widget.delegate != oldWidget.delegate) {
          oldWidget.delegate._queryTextController.removeListener(_onQueryChanged);
          widget.delegate._queryTextController.addListener(_onQueryChanged);
          oldWidget.delegate._currentBodyNotifier
              .removeListener(_onSearchBodyChanged);
          widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
          oldWidget.delegate._focusNode = null;
          widget.delegate._focusNode = focusNode;
        }
      }
    
      void _onFocusChanged() {
        if (focusNode.hasFocus &&
            widget.delegate._currentBody != _SearchBody.suggestions) {
          widget.delegate.showSuggestions(context);
        }
      }
    
      void _onQueryChanged() {
        setState(() {
          // rebuild ourselves because query changed.
        });
      }
    
      void _onSearchBodyChanged() {
        setState(() {
          // rebuild ourselves because search body changed.
        });
      }
    
      @override
      Widget build(BuildContext context) {
        assert(debugCheckHasMaterialLocalizations(context));
        final ThemeData theme = widget.delegate.appBarTheme(context);
        final String searchFieldLabel = widget.delegate.searchFieldLabel ??
            MaterialLocalizations.of(context).searchFieldLabel;
        Widget? body;
        switch (widget.delegate._currentBody) {
          case _SearchBody.suggestions:
            body = KeyedSubtree(
              key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
              child: widget.delegate.buildSuggestions(context),
            );
          case _SearchBody.results:
            body = KeyedSubtree(
              key: const ValueKey<_SearchBody>(_SearchBody.results),
              child: widget.delegate.buildResults(context),
            );
          case null:
            break;
        }
    
        late final String routeName;
        switch (theme.platform) {
          case TargetPlatform.iOS:
          case TargetPlatform.macOS:
            routeName = '';
          case TargetPlatform.android:
          case TargetPlatform.fuchsia:
          case TargetPlatform.linux:
          case TargetPlatform.windows:
            routeName = searchFieldLabel;
        }
    
        return Semantics(
          explicitChildNodes: true,
          scopesRoute: true,
          namesRoute: true,
          label: routeName,
          child: Theme(
            data: theme,
            child: Scaffold(
              appBar: AppBar(
                leadingWidth: widget.delegate.leadingWidth,
                automaticallyImplyLeading:
                    widget.delegate.automaticallyImplyLeading ?? true,
                leading: widget.delegate.buildLeading(context),
                title: TextField(
                  controller: widget.delegate._queryTextController,
                  focusNode: focusNode,
                  style: widget.delegate.searchFieldStyle ??
                      theme.textTheme.titleLarge,
                  textInputAction: widget.delegate.textInputAction,
                  keyboardType: widget.delegate.keyboardType,
                  onSubmitted: (String _) => widget.delegate.showResults(context),
                  decoration: InputDecoration(hintText: searchFieldLabel),
                ),
                flexibleSpace: widget.delegate.buildFlexibleSpace(context),
                actions: widget.delegate.buildActions(context),
                bottom: widget.delegate.buildBottom(context),
              ),
              body: AnimatedSwitcher(
                duration: const Duration(milliseconds: 300),
                child: body,
              ),
            ),
          ),
        );
      }
    }
    
    class ChinaTextEditController extends TextEditingController {
      ///拼音输入完成后的文字
      var completeText = '';
    
      @override
      TextSpan buildTextSpan(
          {required BuildContext context,
          TextStyle? style,
          required bool withComposing}) {
        ///拼音输入完成
        if (!value.composing.isValid || !withComposing) {
          if (completeText != value.text) {
            completeText = value.text;
            WidgetsBinding.instance.addPostFrameCallback((_) {
              notifyListeners();
            });
          }
          return TextSpan(style: style, text: text);
        }
    
        ///返回输入样式,可自定义样式
        final TextStyle composingStyle = style?.merge(
          const TextStyle(decoration: TextDecoration.underline),
        ) ?? const TextStyle(decoration: TextDecoration.underline);
        return TextSpan(style: style, children: [
          TextSpan(text: value.composing.textBefore(value.text)),
          TextSpan(
            style: composingStyle,
            text: value.composing.isValid && !value.composing.isCollapsed
                ? value.composing.textInside(value.text)
                : "",
          ),
          TextSpan(text: value.composing.textAfter(value.text)),
        ]);
      }
    }
    
    

    实现搜索#

    创建SearchPage继承MySearchDelegate,修改样式,实现页面。需要重写下面5个方法

    • appBarTheme:修改搜索样式
    • buildActions:搜索框右侧的方法
    • buildLeading:搜索框左侧的返回按钮
    • buildResults:搜索结果
    • buildSuggestions:搜索建议
    import 'package:e_book_clone/pages/search/MySearchDelegate.dart';
    import 'package:flutter/src/material/theme_data.dart';
    import 'package:flutter/src/widgets/framework.dart';
    
    class Demo extends MySearchDelegate {
    
      @override
      ThemeData appBarTheme(BuildContext context) {
        // TODO: implement appBarTheme
        return super.appBarTheme(context);
      }
      
      @override
      List? buildActions(BuildContext context) {
        // TODO: implement buildActions
        throw UnimplementedError();
      }
    
      @override
      Widget? buildLeading(BuildContext context) {
        // TODO: implement buildLeading
        throw UnimplementedError();
      }
    
      @override
      Widget buildResults(BuildContext context) {
        // TODO: implement buildResults
        throw UnimplementedError();
      }
    
      @override
      Widget buildSuggestions(BuildContext context) {
        // TODO: implement buildSuggestions
        throw UnimplementedError();
      }
    }
    

    修改样式#

    @override
    ThemeData appBarTheme(BuildContext context) {
      final ThemeData theme = Theme.of(context);
      final ColorScheme colorScheme = theme.colorScheme;
      return theme.copyWith( // 使用copyWith,适配全局主题
        appBarTheme: AppBarTheme( // AppBar样式修改
          systemOverlayStyle: colorScheme.brightness == Brightness.dark
              ? SystemUiOverlayStyle.light
              : SystemUiOverlayStyle.dark,
          surfaceTintColor: Theme.of(context).colorScheme.surface,
          titleSpacing: 0, // textfield前面的间距
          elevation: 0, // 阴影
        ),
        inputDecorationTheme: InputDecorationTheme(
          isCollapsed: true,
          hintStyle: TextStyle( // 提示文字颜色
              color: Theme.of(ToastUtils.context).colorScheme.inversePrimary),
          filled: true,  // 填充颜色
          contentPadding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 15.w),
          fillColor: Theme.of(context).colorScheme.secondary, // 填充颜色,需要配合 filled
          enabledBorder: OutlineInputBorder( // testified 边框
            borderRadius: BorderRadius.circular(12.r),
            borderSide: BorderSide(
              color: Theme.of(context).colorScheme.surface,
            ),
          ),
          focusedBorder: OutlineInputBorder( // testified 边框
            borderRadius: BorderRadius.circular(12.r),
            borderSide: BorderSide(
              color: Theme.of(context).colorScheme.surface,
            ),
          ),
        ),
      );
    }
    
    @override
    TextStyle? get searchFieldStyle => TextStyle(fontSize: 14.sp); // 字体大小设置,主要是覆盖默认样式
    
    

    按钮功能#

    左侧返回按钮,右侧就放了一个搜索文本,点击之后显示搜索结果

    @override
    Widget? buildLeading(BuildContext context) {
      return IconButton(
        onPressed: () {
          close(context, null);
        },
        icon: Icon(
          color: Theme.of(context).colorScheme.onSurface,
          Icons.arrow_back_ios_new,
          size: 20.r,
        ),
      );
    }
    
    @override
    List? buildActions(BuildContext context) {
      return [
        Padding(
          padding: EdgeInsets.only(right: 15.w, left: 15.w),
          child: GestureDetector(
            onTap: () {
              showResults(context);
            },
            child: Text(
              '搜索',
              style: TextStyle(
                  color: Theme.of(context).colorScheme.primary, fontSize: 15.sp),
            ),
          ),
        )
      ];
    }
    

    搜索建议#

    当 TextField 输入变化时,就会调用buildSuggestions方法,刷新布局,因此考虑使用FlutterBuilder管理页面和数据。

    final SearchViewModel _viewModel = SearchViewModel();
    
    @override
    Widget buildSuggestions(BuildContext context) {
      if (query.isEmpty) {
        // 这里可以展示热门搜索等,有搜索建议时,热门搜索会被替换成搜索建议
        return const SizedBox();
      }
      return FutureBuilder(
        future: _viewModel.getSuggest(query),
        builder: (BuildContext context, AsyncSnapshot<List> snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            // 数据加载中
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            // 数据加载错误
            return Center(child: Text('Error: ${snapshot.error}'));
          } else if (snapshot.hasData) {
            // 数据加载成功,展示结果
            final List searchResults = snapshot.data ?? [];
            return ListView.builder(
                padding: EdgeInsets.all(15.r),
                itemCount: searchResults.length,
                itemBuilder: (context, index) {
                  return GestureDetector(
                    onTap: () {
                      // 更新输入框
                      query = searchResults[index].text ?? query;
                      showResults(context);
                    },
                    child: Container(
                      padding: EdgeInsets.symmetric(vertical: 10.h),
                      decoration: BoxDecoration(
                        border: BorderDirectional(
                          bottom: BorderSide(
                            width: 0.6,
                            color: Theme.of(context).colorScheme.surfaceContainer,
                          ),
                        ),
                      ),
                      child: Text('${searchResults[index].text}'),
                    ),
                  );
                });
          } else {
            // 数据为空
            return const Center(child: Text('No results found'));
          }
        },
      );
    }
    

    实体类代码如下:

    class Suggest {
      Suggest({
        this.id,
        this.url,
        this.text,
        this.isHot,
        this.hotLevel,
      });
    
      Suggest.fromJson(dynamic json) {
        id = json['id'];
        url = json['url'];
        text = json['text'];
        isHot = json['is_hot'];
        hotLevel = json['hot_level'];
      }
    
      String? id;
      String? url;
      String? text;
      bool? isHot;
      int? hotLevel;
    }
    

    ViewModel代码如下:

    class SearchViewModel {
      Future<List> getSuggest(String keyword) async {
        if (keyword.isEmpty) {
          return [];
        }
        return await JsonApi.instance().fetchSuggestV3(keyword);
      }
    }
    

    搜索结果#

    我们需要搜索结果页面支持加载更多,这里用到了 SmartRefrsh 组件

    flutter pub add pull_to_refresh
    

    buildResults方法是通过调用showResults(context);方法刷新页面,因此为了方便数据动态变化,新建search_result_page.dart页面

    import 'package:e_book_clone/components/book_tile/book_tile_vertical/my_book_tile_vertical_item.dart';
    import 'package:e_book_clone/components/book_tile/book_tile_vertical/my_book_tile_vertical_item_skeleton.dart';
    import 'package:e_book_clone/components/my_smart_refresh.dart';
    import 'package:e_book_clone/models/book.dart';
    import 'package:e_book_clone/models/types.dart';
    import 'package:e_book_clone/pages/search/search_vm.dart';
    import 'package:e_book_clone/utils/navigator_utils.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter_screenutil/flutter_screenutil.dart';
    import 'package:provider/provider.dart';
    import 'package:pull_to_refresh/pull_to_refresh.dart';
    
    class SearchResultPage extends StatefulWidget {
      final String query; // 请求参数
      const SearchResultPage({super.key, required this.query});
    
      @override
      State createState() => _SearchResultPageState();
    }
    
    class _SearchResultPageState extends State<SearchResultPage> {
      final RefreshController _refreshController = RefreshController();
      final SearchViewModel _viewModel = SearchViewModel();
      void loadOrRefresh(bool loadMore) {
        _viewModel.getResults(widget.query, loadMore).then((_) {
          if (loadMore) {
            _refreshController.loadComplete();
          } else {
            _refreshController.refreshCompleted();
          }
        });
      }
    
      @override
      void initState() {
        super.initState();
        loadOrRefresh(false);
      }
    
      @override
      void dispose() {
        _viewModel.isDispose = true;
        _refreshController.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return ChangeNotifierProvider.value(
          value:  _viewModel,
          builder: (context, child) {
            return Consumer(
              builder: (context, vm, child) {
                List? searchResult = vm.searchResult;
                // 下拉刷新和上拉加载组件
                return MySmartRefresh(
                  enablePullDown: false,
                  onLoading: () {
                    loadOrRefresh(true);
                  },
                  controller: _refreshController,
                  child: ListView.builder(
                    padding: EdgeInsets.only(left: 15.w, right: 15.w, top: 15.h),
                    itemCount: searchResult?.length ?? 10,
                    itemBuilder: (context, index) {
                      if (searchResult == null) {
                        // 骨架屏 
                        return MyBookTileVerticalItemSkeleton(
                            width: 80.w, height: 120.h);
                      }
                      // 结果渲染组件
                      return MyBookTileVerticalItem(
                        book: searchResult[index],
                        width: 80.w,
                        height: 120.h,
                        onTap: (id) {
                          NavigatorUtils.nav2Detail(
                              context, DetailPageType.ebook, searchResult[index]);
                        },
                      );
                    },
                  ),
                );
              },
            );
          },
        );
      }
    }
    

    MySmartRefresh组件代码如下,主要是对SmartRefresher做了进一步的封装

    import 'package:flutter/material.dart';
    import 'package:pull_to_refresh/pull_to_refresh.dart';
    
    class MySmartRefresh extends StatelessWidget {
      // 启用下拉
      final bool? enablePullDown;
    
      // 启用上拉
      final bool? enablePullUp;
    
      // 头布局
      final Widget? header;
    
      // 尾布局
      final Widget? footer;
    
      // 刷新事件
      final VoidCallback? onRefresh;
    
      // 加载事件
      final VoidCallback? onLoading;
    
      // 刷新组件控制器
      final RefreshController controller;
    
      final ScrollController? scrollController;
    
      // 被刷新的子组件
      final Widget child;
    
      const MySmartRefresh({
        super.key,
        this.enablePullDown,
        this.enablePullUp,
        this.header,
        this.footer,
        this.onLoading,
        this.onRefresh,
        required this.controller,
        required this.child,
        this.scrollController,
      });
    
      @override
      Widget build(BuildContext context) {
        return _refreshView();
      }
    
      Widget _refreshView() {
        return SmartRefresher(
          scrollController: scrollController,
          controller: controller,
          enablePullDown: enablePullDown ?? true,
          enablePullUp: enablePullUp ?? true,
          header: header ?? const ClassicHeader(),
          footer: footer ?? const ClassicFooter(),
          onRefresh: onRefresh,
          onLoading: onLoading,
          child: child,
        );
      }
    }
    

    SearchViewModel 代码如下:

    import 'package:e_book_clone/http/spider/json_api.dart';
    import 'package:e_book_clone/models/book.dart';
    import 'package:e_book_clone/models/query_param.dart';
    import 'package:e_book_clone/models/suggest.dart';
    import 'package:flutter/material.dart';
    
    class SearchViewModel extends ChangeNotifier {
      int _currPage = 2;
      bool isDispose = false;
      List? _searchResult;
    
      List? get searchResult => _searchResult;
    
      Future<List> getSuggest(String keyword) async {
        if (keyword.isEmpty) {
          return [];
        }
        return await JsonApi.instance().fetchSuggestV3(keyword);
      }
    
      Future getResults(String keyword, bool loadMore, {VoidCallback? callback}) async {
        if (loadMore) {
          _currPage++;
        } else {
          _currPage = 1;
          _searchResult?.clear();
        }
    
        // 请求参数
        SearchParam param = SearchParam(
          page: _currPage,
          rootKind: null,
          q: keyword,
          sort: "defalut",
          query: SearchParam.ebookSearch,
        );
        // 请求结果
        List res = await JsonApi.instance().fetchEbookSearch(param);
    	
        // 加载更多,使用addAll
        if (_searchResult == null) {
          _searchResult = res;
        } else {
          _searchResult!.addAll(res);
        }
    
        if (res.isEmpty && _currPage > 0) {
          _currPage--;
        }
        // 防止Provider被销毁,数据延迟请求去通知报错
        if (isDispose) return;
        notifyListeners();
      }
    }
    

    buildResults方法如下:

    @override
    Widget buildResults(BuildContext context) {
      if (query.isEmpty) {
        return const SizedBox();
      }
      return SearchResultPage(query: query);
    }
    

    显示搜索界面#

    注意调用的是我们自己拷贝修改的MySearchDelegate中的方法

    onTap: () {
      showMySearch(context: context, delegate: SearchPage());
    },
    

    更多内容见#

  • 相关阅读:
    数据挖掘神器Orange初步使用
    前端面试宝典React篇08 列举一种你了解的 React 状态管理框架
    Elasticsearch 字段别名 field-alias
    仿上海学校网站学生网页设计作品 dreamweaver作业静态HTML网页设计模板 旅游景点网页作业制作
    ctf工具之:mitmproxy实践测试
    QT调用OpenCV绘制直线、矩形、椭圆、圆、不规则曲线、文本
    sql常用语法记录
    KT148A语音芯片按键版本一对一触发播放功能描述_V4
    数字化转型接力赛接棒 金融壹账通迎“新帅”
    浏览器缓存机制
  • 原文地址:https://www.cnblogs.com/sw-code/p/18257792