[譯] 深入瞭解 Flutter

DateBro發表於2019-03-02

Flutter 是一種新的框架,可以在短時間內為 iOS 和 Android 構建高質量原生 App。根據我使用 Flutter(作為 Flutter 團隊成員)的經驗,開發速度主要通過以下方式體現:

  • 有狀態的熱過載。Flutter 開發由 Dart 編譯器/ VM 技術提供支援,它允許你在保留應用程式狀態(包括你導航到的位置)的同時將程式碼更改載入到正在執行的應用程式中。點選儲存,你將在不到一秒的時間內看到裝置更改的效果。
  • 響應式程式設計。Flutter 在其定義和更新使用者介面的方法中遵循其他現代框架:兩者都基於介面如何依賴於當前狀態的單個描述。
  • 組成。在 Flutter 中,萬物皆元件,而且通過自由組合漂亮的元件和樂高積木風格,你可以實現任何想要的結果。
  • 程式碼編寫 UI。Flutter 沒有單獨的佈局標記語言。每個元件只在 Dart 中的一個地方編寫,縮減了語法切換和檔案切換的開銷。

有意思的是,上面的最後三個特點形成了對開發速度的挑戰:在你的方式和你的檢視邏輯中深入巢狀的 widget 樹

接下來我會討論為什麼會出現這個問題和我們能做什麼。同時,我會嘗試說明 Flutter 的工作原理。


響應式程式設計

Flutter 的響應式程式設計模型邀請你使用宣告性程式設計來定義您的使用者介面,作為當前狀態的函式:

@override
Widget build(BuildContext context) {
  return // 一些基於當前狀態的元件
}
複製程式碼

元件是使用者介面的不可變描述。我們被要求返回由單個表示式定義的單個元件。沒有用於配置或更新可變檢視的 mutator 命令序列。相反,我們只是呼叫一些元件建構函式。

組成

Widgets are typically simple, each doing one thing well: Text, Icon, Padding, Center, Column, Row, … To achieve any non-trivial outcome, many widgets must be composed. So our single expression easily becomes a deeply nested tree of widget constructor calls:

[譯] 深入瞭解 Flutter

元件除子屬性還有其他屬性,但是你明白的。

程式碼編寫 UI

編寫和編輯深層巢狀的樹需要一個優雅的編輯器和一些練習來提高效率。開發人員似乎在佈局標記(XML,HTML)中比在程式碼中更能容忍深度巢狀,但 Flutter 的 UI-as-code 方法確實意味著深層巢狀 code。無論你在元件樹中有什麼檢視邏輯——條件,轉換,在讀取當前狀態時使用的迭代,用於更改它的事件處理程式——也會深深巢狀。

這就是接下來的挑戰。


挑戰

flutter.io 的 佈局教程 提供了一個說明性的例子——看起來像是——一個湖泊探險家應用程式。

[譯] 深入瞭解 Flutter

這是實現此檢視的原始元件樹:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(title: Text('Top Lakes')),
        body: ListView(
          children: <Widget>[
            Image.asset(
              'images/lake.jpg',
              width: 600.0,
              height: 240.0,
              fit: BoxFit.cover,
            ),
            Container(
              padding: const EdgeInsets.all(32.0),
              child: Row(
                children: <Widget>[
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Container(
                          padding: const EdgeInsets.only(bottom: 8.0),
                          child: Text(
                            'Oeschinen Lake Campground',
                            style: TextStyle(fontWeight: FontWeight.bold),
                          ),
                        ),
                        Text(
                          'Kandersteg, Switzerland',
                          style: TextStyle(color: Colors.grey[500]),
                        ),
                      ],
                    ),
                  ),
                  Row(
                    children: <Widget>[
                      Icon(Icons.star, color: Colors.red[500]),
                      Text('41'),
                    ],
                  ),
                ],
              ),
            ),
            Container(
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: <Widget>[
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Icon(Icons.call, color: Theme.of(context).primaryColor),
                      Container(
                        margin: const EdgeInsets.only(top: 8.0),
                        child: Text(
                          'CALL',
                          style: TextStyle(
                            fontSize: 12.0,
                            fontWeight: FontWeight.w400,
                            color: Theme.of(context).primaryColor,
                          ),
                        ),
                      ),
                    ],
                  ),
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Icon(Icons.near_me,
                          color: Theme.of(context).primaryColor),
                      Container(
                        margin: const EdgeInsets.only(top: 8.0),
                        child: Text(
                          'ROUTE',
                          style: TextStyle(
                            fontSize: 12.0,
                            fontWeight: FontWeight.w400,
                            color: Theme.of(context).primaryColor,
                          ),
                        ),
                      ),
                    ],
                  ),
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Icon(Icons.share, color: Theme.of(context).primaryColor),
                      Container(
                        margin: const EdgeInsets.only(top: 8.0),
                        child: Text(
                          'SHARE',
                          style: TextStyle(
                            fontSize: 12.0,
                            fontWeight: FontWeight.w400,
                            color: Theme.of(context).primaryColor,
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            Container(
              padding: const EdgeInsets.all(32.0),
              child: Text(
                'Lake Oeschinen lies at the foot of the Blüemlisalp in the '
                    'Bernese Alps. Situated 1,578 meters above sea level, it '
                    'is one of the larger Alpine Lakes. A gondola ride from '
                    'Kandersteg, followed by a half-hour walk through pastures '
                    'and pine forest, leads you to the lake, which warms to '
                    '20 degrees Celsius in the summer. Activities enjoyed here '
                    'include rowing, and riding the summer toboggan run.',
                softWrap: true,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
複製程式碼

這只是一個靜態元件樹,沒有實現任何行為。但是將檢視邏輯直接嵌入到這樣的樹中估計不會是一次愉快的體驗。

接受挑戰。


重新審視程式碼編寫 UI

使用 Flutter 的 UI-as-code 方法時,元件樹就是程式碼。因此,我們可以使用所有常用的程式碼組織工具來改善這種情況。工具箱中最簡單的工具之一就是命名子表示式。這會在語法上將元件樹翻出來。而不是

return A(B(C(D(), E())), F());
複製程式碼

我們可以命名每個子表示式並得到

final Widget d = D();
final Widget e = E();
final Widget c = C(d, e);
final Widget b = B(c);
final Widget f = F();
return A(b, f);
複製程式碼

我們的湖泊應用可以重寫成下面這樣:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Widget imageSection = Image.asset(
      'images/lake.jpg',
      width: 600.0,
      height: 240.0,
      fit: BoxFit.cover,
    );
    final Widget titles = Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Container(
          padding: const EdgeInsets.only(bottom: 8.0),
          child: Text(
            'Oeschinen Lake Campground',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
        ),
        Text(
          'Kandersteg, Switzerland',
          style: TextStyle(color: Colors.grey[500]),
        ),
      ],
    );
    final Widget stars = Row(
      children: <Widget>[
        Icon(Icons.star, color: Colors.red[500]),
        Text('41'),
      ],
    );
    final Widget titleSection = Container(
      padding: const EdgeInsets.all(32.0),
      child: Row(
        children: <Widget>[
          Expanded(child: titles),
          stars,
        ],
      ),
    );
    final Widget callAction = Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Icon(Icons.call, color: Theme.of(context).primaryColor),
        Container(
          margin: const EdgeInsets.only(top: 8.0),
          child: Text(
            'CALL',
            style: TextStyle(
              fontSize: 12.0,
              fontWeight: FontWeight.w400,
              color: Theme.of(context).primaryColor,
            ),
          ),
        ),
      ],
    );
    final Widget routeAction = Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Icon(Icons.near_me, color: Theme.of(context).primaryColor),
        Container(
          margin: const EdgeInsets.only(top: 8.0),
          child: Text(
            'ROUTE',
            style: TextStyle(
              fontSize: 12.0,
              fontWeight: FontWeight.w400,
              color: Theme.of(context).primaryColor,
            ),
          ),
        ),
      ],
    );
    final Widget shareAction = Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Icon(Icons.share, color: Theme.of(context).primaryColor),
        Container(
          margin: const EdgeInsets.only(top: 8.0),
          child: Text(
            'SHARE',
            style: TextStyle(
              fontSize: 12.0,
              fontWeight: FontWeight.w400,
              color: Theme.of(context).primaryColor,
            ),
          ),
        ),
      ],
    );
    final Widget actionSection = Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          callAction,
          routeAction,
          shareAction,
        ],
      ),
    );
    final Widget textSection = Container(
      padding: const EdgeInsets.all(32.0),
      child: Text(
        'Lake Oeschinen lies at the foot of the Blüemlisalp in the '
            'Bernese Alps. Situated 1,578 meters above sea level, it '
            'is one of the larger Alpine Lakes. A gondola ride from '
            'Kandersteg, followed by a half-hour walk through pastures '
            'and pine forest, leads you to the lake, which warms to '
            '20 degrees Celsius in the summer. Activities enjoyed here '
            'include rowing, and riding the summer toboggan run.',
        softWrap: true,
      ),
    );
    final Widget scaffold = Scaffold(
      appBar: AppBar(title: Text('Top Lakes')),
      body: ListView(
        children: <Widget>[
          imageSection,
          titleSection,
          actionSection,
          textSection,
        ],
      ),
    );
    return MaterialApp(
      title: 'Flutter Demo',
      home: scaffold,
    );
  }
}
複製程式碼

縮排級別現在更合理,我們可以通過引入更多名稱使子樹的縮排級別變得像我們希望的那樣淺。更好的是,通過為各個子樹提供有意義的名稱,我們可以表示每個子樹的作用。所以我們現在可以談談 xxxAction 子樹......並觀察到我們在這裡面有很多重複的程式碼!另一個基本的程式碼組織工具——功能抽象——負責這部分內容:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Widget imageSection = ...
    final Widget titles = ...
    final Widget stars = ...
    final Widget titleSection = ...

    Widget action(String label, IconData icon) {
      final Color color = Theme.of(context).primaryColor;
      return Column(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Icon(icon, color: color),
          Container(
            margin: const EdgeInsets.only(top: 8.0),
            child: Text(
              label,
              style: TextStyle(
                fontSize: 12.0,
                fontWeight: FontWeight.w400,
                color: color,
              ),
            ),
          ),
        ],
      );
    }

    final Widget actionSection = Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          action('CALL', Icons.call),
          action('ROUTE', Icons.near_me),
          action('SHARE', Icons.share),
        ],
      ),
    );
    final Widget textSection = ...
    final Widget scaffold = ...
    return MaterialApp(
      title: 'Flutter Demo',
      home: scaffold,
    );
  }
}
複製程式碼

我們將看到一個簡單功能抽象的替代,它會更具有更 Flutter 風格的。

重新審視組成

接下來是什麼?好吧,build 方法依然很長。也許我們可以提取一些有意義的作品......片斷?元件!Flutter 的元件都是關於組合和重用的。我們用框架提供的簡單元件組成了一個複雜的元件 但是發現結果過於複雜,我們可以選擇把它分解成不太複雜的自定義元件。定製元件是 Flutter 世界中的一等公民,而明確定義的元件具有很大的潛力被重用。讓我們將 action 函式轉換為 Action 元件型別並將其放在自己的檔案中:

import 'package:flutter/material.dart';
import 'src/widgets.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Widget imageSection = ...
    final Widget titles = ...
    final Widget titleSection = ...
    final Widget actionSection = Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Action(label: 'CALL', icon: Icons.call),
          Action(label: 'ROUTE', icon: Icons.near_me),
          Action(label: 'SHARE', icon: Icons.share),
        ],
      ),
    );
    final Widget textSection = ...
    final Widget scaffold = ...
    return MaterialApp(
      title: 'Flutter Demo',
      home: scaffold,
    );
  }
}
複製程式碼
import 'package:flutter/material.dart';

class Action extends StatelessWidget {
  Action({Key key, this.label, this.icon}) : super(key: key);

  final String label;
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    final Color color = Theme.of(context).primaryColor;
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8.0),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12.0,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }
}
複製程式碼

現在我們可以在應用程式的任何位置重用 Action 元件,就像它是由 Flutter 框架定義的一樣。

但是,嘿,頂級的 action 功能不能滿足同樣的需求嗎?

一般來說,不能。

  • 許多元件是由其他元件構造的;它們的建構函式有 WidgetList<Widget> 型別的 childchildren 引數。所以 action 函式不能傳遞給任何一個函式。當然,呼叫 action 的結果可以。但是,你將通過在當前構建環境中預先構造的元件樹,而不是 StatelessWidget,它只在必要時才構建子樹,並且是最後在整個樹中定義的上下文中定義的。注意到表示式中在 Action.build 開頭的 Theme.of(context).primaryColor 了嗎?它從父鏈上最近的 Theme 元件中檢索主顏色——在呼叫 action 時,它很可能與最近的 Theme 不同。
  • Action is defined as a StatelessWidget which is little more than a build function turned into an instance method. But there are other kinds of widget with more elaborate behavior. Clients of Action shouldn’t care what kind of widget Action is. As an example, if we wanted to endow Action with an intrinsic animation, we might have to turn it into a StatefulWidget to manage the animation state. The rest of the app should be unaffected by such a change.

重新審視響應式程式設計

狀態管理是開始利用 Flutter 響應式程式設計模型,並讓我們的靜態檢視生動起來的暗示。讓我們定義應用程式的狀態。我們將盡量保持簡單,先假設一個 Lake 業務邏輯類,其唯一可變狀態是使用者是否已加星標:

abstract class Lake {
  String get imageAsset;
  String get name;
  String get locationName;
  String get description;

  int get starCount;
  bool get isStarred;
  void toggleStarring();

  void call();
  void route();
  void share();
}
複製程式碼

然後,我們可以從 Lake 例項動態地構造我們的元件樹,並且同時還可以設定事件處理程式以呼叫其方法。響應式程式設計模型的優點在於我們只需在程式碼庫中執行一次。只要 Lake 例項發生變化,Flutter 框架就會重建我們的元件樹——前提是我們告訴框架。這需要使 MyApp 成為一個 StatefulWidget,這反過來又涉及將元件構建委託給一個相關的 State 物件,然後每當我們在 Lake 上加星標時呼叫 State.setState 方法。

import 'package:flutter/material.dart';
import 'src/lake.dart';
import 'src/widgets.dart';

void main() {
  // 假裝我們從業務邏輯中獲取 Lake 例項。
  final Lake lake = Lake();
  runApp(MyApp(lake));
}

class MyApp extends StatefulWidget {
  final Lake lake;

  MyApp(this.lake);

  @override
  MyAppState createState() => new MyAppState();
}

class MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    final Lake lake = widget.lake;
    final Widget imageSection = Image.asset(
      lake.imageAsset,
      width: 600.0,
      height: 240.0,
      fit: BoxFit.cover,
    );
    final Widget titles = Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Container(
          padding: const EdgeInsets.only(bottom: 8.0),
          child: Text(
            lake.name,
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
        ),
        Text(
          lake.locationName,
          style: TextStyle(color: Colors.grey[500]),
        ),
      ],
    );
    final Widget stars = GestureDetector(
      child: Row(
        children: <Widget>[
          Icon(
            lake.isStarred ? Icons.star : Icons.star_border,
            color: Colors.red[500],
          ),
          Text('${lake.starCount}'),
        ],
      ),
      onTap: () {
        setState(() {
          lake.toggleStarring();
        });
      },
    );
    final Widget titleSection = Container(
      padding: const EdgeInsets.all(32.0),
      child: Row(
        children: <Widget>[
          Expanded(child: titles),
          stars,
        ],
      ),
    );
    final Widget actionSection = Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Action(label: 'CALL', icon: Icons.call, handler: lake.call),
          Action(label: 'ROUTE', icon: Icons.near_me, handler: lake.route),
          Action(label: 'SHARE', icon: Icons.share, handler: lake.share),
        ],
      ),
    );
    final Widget textSection = Container(
      padding: const EdgeInsets.all(32.0),
      child: Text(
        lake.description,
        softWrap: true,
      ),
    );
    final Widget scaffold = Scaffold(
      appBar: AppBar(title: Text('Top Lakes')),
      body: ListView(
        children: <Widget>[
          imageSection,
          titleSection,
          actionSection,
          textSection,
        ],
      ),
    );
    return MaterialApp(
      title: 'Flutter Demo',
      home: scaffold,
    );
  }
}
複製程式碼
import 'package:flutter/material.dart';

class Action extends StatelessWidget {
  Action({Key key, this.label, this.icon, this.handler}) : super(key: key);

  final String label;
  final IconData icon;
  final VoidCallback handler;

  @override
  Widget build(BuildContext context) {
    final Color color = Theme.of(context).primaryColor;
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        IconButton(
          icon: Icon(icon, color: color),
          onPressed: handler,
        ),
        Text(
          label,
          style: TextStyle(
            fontSize: 12.0,
            fontWeight: FontWeight.w400,
            color: color,
          ),
        ),
      ],
    );
  }
}
複製程式碼

這有用,但效率不高。最初的挑戰是深度巢狀的元件樹。那個樹仍然在那裡,如果不在我們的程式碼中,那麼就在執行時。重建所有這些只是為了切換切換星標完全是一種浪費。當然,Dart 的實現可以非常有效地處理短壽命物件,但如果你反覆重建,Dart 也會耗盡你的電池——特別是涉及動畫的地方。一般來說,我們應該將重建限制在實際改變的子樹上。

你有沒有抓住這個矛盾?元件樹是使用者介面的不可變描述。如何在不從根重構的情況下重建其中的一部分?實際上,元件樹不是具有從父元件到子元件,從根到葉的引用的物化樹結構。特別是 StatelessWidgetStatefulWidget,它們沒有子引用。他們提供的是 build 方法(在有狀態的情況下,通過相關的 State 例項)。Flutter 框架遞迴地呼叫那些 build 方法,同時生成或更新實際的執行時樹結構,不是元件,而是引用元件的 Element 例項。元素樹是可變的,並由 Flutter 框架管理。

那麼當你在 State 例項 s 上呼叫 setState 時會發生什麼?Flutter 框架標記了以 s 對應元素為根的子樹,用於重建。當下一幀到期時,該子樹將根據 sbuild 方法返回的元件樹進行更新,而後者依賴於當前的應用程式狀態。

我們對程式碼的最終嘗試提取了一個有狀態的 LakeStars 元件,將重建限制在一個非常小的子樹中。而 MyApp又變回無狀態。

import 'package:flutter/material.dart';
import 'src/lake.dart';
import 'src/widgets.dart';

void main() {
  // 假裝我們從業務邏輯中獲取 Lake 例項。
  final Lake lake = Lake();
  runApp(MyApp(lake));
}

class MyApp extends StatelessWidget {
  const MyApp(this.lake);

  final Lake lake;

  @override
  Widget build(BuildContext context) {
    final Widget imageSection = ...
    final Widget titles = ...
    final Widget titleSection = Container(
      padding: const EdgeInsets.all(32.0),
      child: Row(
        children: <Widget>[
          Expanded(child: titles),
          LakeStars(lake: lake),
        ],
      ),
    );
    final Widget actionSection = ...
    final Widget textSection = ...
    final Widget scaffold = ...
    return MaterialApp(
      title: 'Flutter Demo',
      home: scaffold,
    );
  }
}
複製程式碼
import 'package:flutter/material.dart';
import 'lake.dart';

class LakeStars extends StatefulWidget {
  LakeStars({Key key, this.lake}) : super(key: key);

  final Lake lake;

  @override
  State createState() => LakeStarsState();
}

class LakeStarsState extends State<LakeStars> {
  @override
  Widget build(BuildContext context) {
    final Lake lake = widget.lake;
    return GestureDetector(
      child: Row(
        children: <Widget>[
          Icon(
            lake.isStarred ? Icons.star : Icons.star_border,
            color: Colors.red[500],
          ),
          Text('${lake.starCount}'),
        ],
      ),
      onTap: () {
        setState(() {
          lake.toggleStarring();
        });
      },
    );
  }
}

class Action extends StatelessWidget { ... }
複製程式碼

將一個普遍適用的 Stars 元件與 Lake 概念分離開似乎是正確的,但我把它作為給讀者的練習。

在成功將檢視邏輯新增到程式碼中之後,巢狀深度仍然是便於管理的,我認為我們已經對深度巢狀的挑戰做出了合理的解決。


我們可以設想幾個有趣的技術解決方案,來解決在巢狀的元件樹中 Flutter 檢視邏輯丟失的問題。其中一些可能需要修改 Flutter 框架、IDE 和其他工具,甚至可能需要修改 Dart 的語法。

不過,你現在已經可以做一些很強大的事情了,只需將問題的原因——程式碼編寫 UI、元件的組合和響應式程式設計——轉變為你的優勢。擺脫深度巢狀的語法只是邁向可讀、可維護和高效的移動應用程式碼之旅的開始。

開心地使用 Flutter 吧!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章