構建 F8 2016 App 第三部分:React Native的數據交互

blueberryf 8年前發布 | 84K 次閱讀 ReactNative 移動開發 IOS Android

上一篇: 構建 F8 2016 App 第二部分:設計跨平臺App  

這是為了介紹 React Native 和它的開源生態的一個系列教程,我們將以構建 F8 2016 開發者大會官方應用的 iOS 和 Android 版為主題。

React 和 其擴展 React Native,允許在你構建應用程序,而無需擔心你的數據來自哪里,這樣你就可以專注于創建應用的UI和邏輯。

第一章,我們提到如何采用 Parse Server 來持有數據,在 app 中我們將用 Redux 來處理它。在這一部分,我們將解釋 Redux 在 React Native 應用中如何工作,以及連接 Parse Server 的簡單過程。

在我們討論 Redux 之前,讓我們先看看 React 的數據交互是如何創建 Redux 的。

首先,React 應用如何同數據交互?

在MVC應用程序架構中, React 被經常性的視為 ‘View’ 層,但是這樣的說法顯然欠妥,React 實際上是對 MVC 模式的重新構想。

讓我們先看下 MVC 架構的基本思想:

  • 模型層就是數據層。
  • 視圖層負責整個應用程序數據的展現。
  • 控制層在應用程序中負責提供數據處理的邏輯操作。

React 可以做到當你創建多個組件組合形成一個視圖的時候,每個組件依然可以處理自有邏輯,且只需要提供一個控制器。

class Example extends React.Component {
    render() {
        // Code that renders the view of the existing data, and
        // potentially a form to trigger changes to that data through
        // the handleSubmit function
    }

    handleSubmit(e) {
        // Code that modifies the data, like a controller's logic
    }
};

在一個 React 應用中,每個組件都有兩種不同的數據類型,每一個都有不同的角色:

  • props 當一個組件被創建的時候, props 會被作為參數傳遞給該組件。如果你有一個按鈕,默認的 prop 將會是該按鈕的文本。組件并不能改變它們的 props (即它們是不可變的)。

  • state 是一種可以被任意組件改變任意次數的數據。如果上面的按鈕是登錄/注銷按鈕,那么該 state 將記錄用戶當前的登錄狀態,并且該按鈕也可以訪問它,修改它當用戶點擊了按鈕改變其狀態。

為了讓 React 應用 減少重復, state 被設計為組件庫中的最高級父組件所擁有,即前面提到過的container 組件。換句話說,在上個按鈕組件例子中,實際上該按鈕并沒有擁有該屬性,你可以在該按鈕的父視圖中擁有它,然后使用 props 傳遞相關的 state 數據給子組件。正因為如此,數據僅僅流向任何給定的應用,這會使得 React 更快和模塊化。

如果你愿意,你可以從 thinking-in-react 中閱讀更多關于制定這些決策的背后想法和原因。

存儲狀態

為了進一步解釋在 React 應用中的數據使用技術, 非死book 推薦了 Flux architecture,該架構是一種模式來實現你的應用,而不是一種實際的框架供你使用。我們不在在你的應用中使用 Flux library ,但是我們會使用 Redux 框架,其源于 Flux architecture,所以讓我們深入進去吧。

Flux 通過引入 Stores 概念,應用的 state 對象容器,新的工作流用來修改 state 等來擴展 React 的 數據關系:

  • 在 Flux 應用中每個在 Dispatcher 中注冊過的 Store 都有一個回調方法。
  • Views (基于 React 組件) 可以觸發 Actions ,基本上每個對象,都包含了一堆剛剛發生事情的數據(例如,可能它包括一些將會被輸入到應用中的新數據)和 action type (實質上就是 Action 動作的描述類型常量)。
  • Action 被發送到 Dispatcher 。
  • Dispatcher 傳遞該 Action 到所有 Store 的注冊回調。
  • 如果 Store 能告知其被一個 Action影響(因為 action 類型和數據相關),其會自動更新,當然其包含的 state 也會被更新。一旦更新,其會發出變化事件。
  • 特殊的視圖被叫做 Controller Views(container components的優雅術語)將會監聽這些變化事件,當其抓獲某事件,它們知道應該獲取新的 Store 數據。
  • 一旦獲取到新數據后,它們會調用 setState() ,其會致使在該視圖里的組件重新 render 。

你可以看到在 React 應用中 Flux 如何幫助 React 強制執行 one-way flow 策略 ,并且其會使得 React 的數據部分更加優雅和有結構。

我們現在不使用 Flux ,所以在這篇文章里不會再有更多該細節,但是如果你想了解更多,這兒有一些在 Flux 網站的教程可以閱讀。

所以 Redux 關 Flux 什么事,在我們應用中實際使用框架 Redux 呢?

Flux 到 Redux

Redux 是 Flux 架構的框架實現,但是其也可以剝離開來,react-redux package 提供的 official bindings provided 可以讓其和 React 應用更方便的融合。

在 Redux 中沒有 dispatcher ,并且針對整個應用程序只有一個 Store。

在 Redux中,數據流如何工作,在后續我們有更詳細的解釋,但是這兒我們先介紹幾個基本概念:

  • React 組件可以導致 Actions 被觸發,例如按鈕的點擊。
  • Actions 是一個通過 dispatch 方法 被發送到 Store 的對象(包括一個 type 標簽和 Action相關的其他數據)。
  • 然后 Store 承載著相關 Action ,同當前 state 樹( state 樹是一個單獨的對象包含了所有的 state 數據)將其發送到 Reducers。
  • Reducer 是一個純函數,它維持著之前的狀態和動作,然后返回一個基于任何變化的動作的 state。 Redux 應用只能包含一個 Reducer,但是大部分應用最后都會有幾個,并且每個都會處理 state 的不同部分(我們將討論這個)
  • Store 接收到新的 state ,然后替換掉舊的。這里值得注意的是,當我們討論 state 更新時,這其實是技術上的取代。
  • 當 state 發生變化時, Store 觸發變化事件
  • 任何訂閱了該變化事件的 React 組件都會收到來自 Store 的新 state
  • 組件隨 state 而更新。

該工作流可以通過下圖簡單總結:

![redux_flowchart](http://makeitopen.com/static/images/redux_flowchart.png)

你可以看到數據是如何遵循一個明確的單向通道,沒有重疊或相反的方向流動。這也說明 Redux 可以將 app 的每一部分細分。 Store 只關注 state ,視圖中的組件只關注展示數據和觸發事件, Actions 只關注 state 的變化和其內部的數據。 Reducers 只關注融合舊的 state 和 actions 到新的 state 。當你閱讀器源代碼并理解它時,你會發現一切都是模塊化,優雅和具有明確目的的。

Flux 有一些其他的好處:

  • Actions 是觸發 state 改變的唯一方式,且該過程不經過 UI 組件,并且因為 Reducer ,讓其很有秩序,遠離競爭。
  • state 變得不可變,并且有一系列的 state 。每一個都因為代一個獨特的變化而被創建。這讓你在應用程序中能更簡單和清晰的追蹤 state 的狀態。

將它們融合在一起

所以現在,我們已經討論完抽象的數據流,現在讓我們來看看我們的 React Native 應用如何使用它們,以及在這個過程中我們學到的東西。

Store

Redux 文檔 非常好的解釋了如何創建簡單的 Store,所以我們將會將設您已經閱讀那里的基本知識,并跳過一點點內容,包括 Store 的幾個額外參數。

Store 的離線同步

我們討論過本地離線存儲的必需性,這樣應用程序才可以在低信號或無信號條件下工作(在技術會議上尤為重要)。幸運的是,因為我們正在使用 Redux,在我們的 app 中我們可以使用一些很簡單的模塊,其被叫做 Redux Persist 。

在我們的 Store 中我們也會用到 中間件 ,我們將更多地討論一些在測試階段我們用的一些東西。但是總的來說,中間件讓你能夠在 Action 被分發和到達 Reducer 之前添加一些額外的邏輯操作(這對日志,崩潰報告,異步 APIs 等很有幫助)。

/* js/store/configureStore.js */
var createF8Store = applyMiddleware(...)(createStore);

function configureStore(onComplete: ?() => void) {
  const store = autoRehydrate()(createF8Store)(reducers);
  persistStore(store, {storage: AsyncStorage}, onComplete);
  ...
  return store;
}

在這里,嵌套函數的語法可能有些讓人迷惑(其中的一些函數返回將另一個函數作為參數),所以在這里擴展下:

/* js/store/configureStore.js */

// 1
var middlewareWrapper = applyMiddleware(...);
// 2
var createF8Store = middlewareWrapper(createStore(reducers));

function configureStore(onComplete: ?() => void) {
  // 3
  const rehydrator = autoRehydrate();
  const store = rehydrator(createF8Store);
  // 4
  persistStore(store, {storage: AsyncStorage}, onComplete);
  ...
  return store;
}

在第一行代碼中,我們使用 Redux 的 appluMiddleware() 啟動中間件(如果你想知道更多,請閱讀 Redux appluMiddleware 文檔 該函數用于實例 Store 對象)。

所以在第二行代碼中,我們我們將 Redux的 createStore() 方法包裹在了 middlewareWrapper . createStore() 方法返回一個 Store 對象 ,middlewareWrapper() 通過中間件擴展它,最終結果保存在 createF8Store 中。

然后我們配置我們的 Store 對象。 Persist's autoRehydrate() 是另外一個擴展 Store方法(和 applyMiddleware() 一樣,其返回一個函數),我們更新現有的 Store 對象(第三行代碼)。 autoRehydrate() 方法事先將一個 Store 對象保存到本地,然后根據 state 自動更新其狀態。

第四行的 Persist package's persistStore() (我們使用到了 React Native 的 異步存儲系統) 函數是實際的將 app 的 Store 存儲到本地。 autoRehydrate() 和 persistStore() 函數就是我們需要離線同步功能的全部代碼。

現在,不管何時應用程序斷開網絡連接,Store 最近的副本依然會被存儲到本地(包括通過 API 來抓取解析的數據),從用戶角度來看,我們的應用仍然可以正常工作。 關于更多信息,你可以閱讀 technical details of how the Redux Persist package works ,但本質上我們已經完成了構建我們的 Store 。

Reduers

在上一節,我們解釋了 Redux ,我們也因此引入了一個 Reducer 對象。然而在每個應用中,會有很多個 Reducers 對象。每個對象都對應著 state 的不同部分。舉個例子,在一個可評論的應用中,你可能需要一個 reducer 關聯到登錄狀態,其他的關聯到其他的評論數據。

在 F8 應用中,我們將 reducers 存儲在 js/reducers 中。這是 user.js 的簡寫:

/* js/reducers/user.js */
...
import type {Action} from '../actions/types';
...

// 1
const initialState = {
  isLoggedIn: false,
  hasSkippedLogin: false,
  sharedSchedule: null,
  id: null,
  name: null,
};

// 2
function user(state: State = initialState, action: Action): State {
  // 3
  if (action.type === 'LOGGED_IN') {
    // 6
    let {id, name, sharedSchedule} = action.data;
    if (sharedSchedule === undefined) {
      sharedSchedule = null;
    }
    return {
      isLoggedIn: true,
      hasSkippedLogin: false,
      sharedSchedule,
      id,
      name,
    };
  }
  if (action.type === 'SKIPPED_LOGIN') {
    return {
      isLoggedIn: false,
      hasSkippedLogin: true,
      sharedSchedule: null,
      id: null,
      name: null,
    };
  }
  // 4
  if (action.type === 'LOGGED_OUT') {
    return initialState;
  }
  // 5
  if (action.type === 'SET_SHARING') {
    return {
      ...state,
      sharedSchedule: action.enabled,
    };
  }
  if (action.type === 'RESET_NUXES') {
    return {...state, sharedSchedule: null};
  }
  return state;
}

module.exports = user;

正如你所知, 這個 reducer 包含了 登錄/登出 操作,以及特定的用戶參數修改。讓我們一點一點分析。

注意:在第六段,我們使用了 ES2015 的 destructuring assignment ,其將左邊的變量分配給 action.data

  1. 初始狀態

在我們定義初始狀態之前,這符合流動式命名習慣(我們將會在 React Native 應用的測試中詳細解釋)。 initialState 定義了 state 的一部分值,該值被 Reducer 處理,應用會首次加載它或者以前在其上的任何同步存儲。

  1. Reducer 方法

然后,我們編寫 Reducer 的實例,其實相對來說還是很簡單。 state 和 Action (之后我們會討論) 被作為參數,initialState 作為 state 的默認值(我們將參數使用流式類型注解,同樣的,我們會在 React Native 應用的測試部分提到)。然后,我們使用接收到的 Action ,具體為 ‘type’ 標簽,返回一個新的改變了的 state 。

舉個例子,如果第四段的 LOGGED_OUT Action 被分派(由于用戶點擊了登出按鈕),我們將會重置 state 的一部分值為initialState 。 如果第三段 LOGGED_IN Action 發生,你會看到應用將會使用余下數據,返回新的 state ,該 state 和 常規變化例如 isLoggedIn,或者用戶輸入數據變化 例如 name 都將沖突。

還有個方法我們也可以看下,這便是第五段的 SET_SHARING Action 類型。 其非常有趣因為 ...state 注解被使用。在 React 中,注解讓對象的分配更具兼容性和可讀性,并且其會創建一個對象,然后拷貝已經存在的 state ,最后單獨更新sharedSchedule 的值。

你可以看到 Reducer 的結構是如此的簡單和可讀 - 定義一個 initialState ,構建一個傳入 state 和 Action ,返回一個新的state 的函數。

reducers 還有一個大的規則,我們引用 Redux 文檔的內容:

"記住,reducer 必須是單純的。提供相同的參數,其應該計算下一個狀態然后返回它。沒有意外,沒有副作用,沒有 API 調用,沒有突變,只有計算。"

另外一件事需要注意的是:看看 js/reducers/notifications.js ,其有針對 LOGGED_OUT 動作類型的另一個引用。我們之前提到過,但是現在依然需要重復下 - 每一個 reducer 通常是在一個動作被派發后被調用,所以多個 reducers 可能會基于相同的動作來更新不同的 state 。

  • Actions

讓我們仔細看下登錄相關的動作,看看它們的代碼位置:

/* from js/actions/login.js */
function skipLogin(): Action {
  return {
    type: 'SKIPPED_LOGIN',
  };
}

這是一個簡單的Action creator (這個 creator 函數返回的對象實際上是一個 Action),但是這讓你看到了最基本的結構 - 每個動作可以簡單的視為一個包含了 type 標簽的對象。然后 reducers 可以使用該 type 來更新 state 。

同樣,我們可以為 type 添加一些數據:

/* from js/actions/filter.js */
function applyTopicsFilter(topics): Action {
  return {
    type: 'APPLY_TOPICS_FILTER',
    topics,
  };
}

在這兒,動作創造者接收了一個參數,并且將其插入到了動作對象中。

然后,我們有一些動作制造者會執行額外的邏輯以及返回 Action 對象。在這個例子中,我們也會使用到一個叫做 ThunkAction 的 動作(Redux 建議你創建點類似這樣的東西來減少模板) - 這種特殊類型的動作制造者返回的是一個函數而不是一個動作。在這種情況下,登出動作制造者返回一個執行一些登出相關的邏輯函數,然后分配到一個 Action 。

/* from js/actions/login.js */
function logOut(): ThunkAction {
  return (dispatch) => {
    Parse.User.logOut();
    非死bookSDK.logout();
    ...

    return dispatch({
      type: 'LOGGED_OUT',
    });
  };
}

(注意,在這個例子中,我們也會使用到 Arrow function 語法)

異步動作

舉個例子,如果你同任何 APIs 交互,你需要一些異步的動作制造者。 Redux 在這方面有個相當復雜的方式來實現異步,但是由于我們在使用 React Native , 我們可以使用到 ES7 的 await 函數,這極大簡化了異步過程:

/* from js/actions/config.js */
async function loadConfig(): Promise<Action> {
  const config = await Parse.Config.get();
  return {
    type: 'LOADED_CONFIG',
    config,
  };
}

在這里,我們使用 一個 API 調用去解析去抓取一些應用程序的配置參數。任何像這樣的 API 調用獲取網絡資源都需要耗費一定的時間。動作不會被立即分配,動作創造者首先等待 API 調用的結果,然后一旦數據有效,動作對象(負載著 API 數據)會被立即返回。

其異步調用的一個好處是,由于我們在等待 Parse.Config 調用的結果,其他的異步操作可以做其自己的工作,這樣我們可以同時又多個操作,其會自動提高效率。

綁定到組件

現在,在我們應用的 setup 函數中鏈接 Redux 邏輯到 React :

/* from js/setup.js */
function setup(): React.Component {
  // ... other setup logic

  class Root extends React.Component {
    constructor() {
      super();
      this.state = {
        isLoading: true,
        store: configureStore(() => this.setState({isLoading: false})),
      };
    }
    render() {
      if (this.state.isLoading) {
        return null;
      }
      return (
        // 1
        <Provider store={this.state.store}>
          <F8App />
        </Provider>
      );
    }
  }

  return Root;
}

我們使用官方的 React 和 Redux 綁定, 因此我們可以使用 組件 。 這個 Provider 讓我們的 Store 可以和任何我們創建的組件通信:

/* from js/F8App.js */
var F8App = React.createClass({
  ...
})

// 1
function select(state) {
  return {
    notifications: state.notifications,
    isLoggedIn: state.user.isLoggedIn || state.user.hasSkippedLogin,
  };
}

// 2
module.exports = connect(select)(F8App);

在上面,我們展示了一部分 組件的代碼 - 我們整個應用的父組件。

上面第一個方被用來獲取 Redux Store,然后從中獲取一些數據,針對我們的 <F8App> 組件插入到 props。在這種情況下,我們希望通知的數據和用戶登錄狀態的數據成為組件的 props,這樣其會根據每一次 Store 的改變而更新狀態。

我們可以使用 React-Redux 的 connect() 方法來實現它 - connect() 有一個參數叫做 mapStateToProps,其會負載一個方法,當一個 Store 更新,該方法就會被調用。

所以當我們應用的 Store 更新時,select() 將會和被作為參數的新 state 一起被調用。select()返回一個包含了我們想從新state中獲取的數據(自在這個例子中,是notificationsisLoggedIn),然后 第二段的connect() 融合數據到 <F8App> 組件的 props

/* from js/F8App.js */
var F8App = React.createClass({
  ...
  componentDidMount: function() {
    ...
    // 1
    if (this.props.notifications.enabled && !this.props.notifications.registered) {
      ...
    }
    ...
  },
  ...
})

現在我們有個 <F8App> 組件,當任何新的已經通過 select() 方法訂閱了的 state數據,都會被更新,其可以通過我們的 props 訪問。但是我們如何從一個組件里分派動作?

從組件中分派動作

為了知道我們如何連接動作到組件,讓我們看看 <GeneralScheduleView> 相關部分:

/* from js/tabs/schedule/GeneralScheduleView.js */
class GeneralScheduleView extends React.Component {
  props: Props;

  constructor(props) {
    super(props);
    this.renderStickyHeader = this.renderStickyHeader.bind(this);
    ...
    this.switchDay = this.switchDay.bind(this);
  }

  render() {
    return (
      <ListContainer
        title="Schedule"
        backgroundImage={require('./img/schedule-background.png')}
        backgroundShift={this.props.day - 1}
        backgroundColor={'#5597B8'}
        data={this.props.data}
        renderStickyHeader={this.renderStickyHeader}
        ...
      />
    );
  }

  ...

  renderStickyHeader() {
    return (
      <View>
        <F8SegmentedControl
          values={['Day 1', 'Day 2']}
          selectedIndex={this.props.day - 1}
          selectionColor="#51CDDA"
          onChange={this.switchDay}
        />
        ...
      </View>
    );
  }

  ...

  switchDay(page) {
    this.props.switchDay(page + 1);
  }
}

// 1

module.exports = GeneralScheduleView;

同樣,這段代碼已經為了便于學習很大程度上簡化了,但是我們現在可以在第一段添加和修改一些代碼來連接這個 容器組件到 Redux store:

/* from js/tabs/schedule/GeneralScheduleView.js */
function select(store) {
  return {
    day: store.navigation.day,
    data: data(store),
  };
}

function actions(dispatch) {
  return {
    switchDay: (day) => dispatch(switchDay(day)),
  }
}

module.exports = connect(select, actions)(GeneralScheduleView);

這一次有一些不同 - 我們提供了 React-Redux 的 connect() 方法。總的來說,執行 actions() 內部的動作創造者到組件的 props,然后將其包裹在 dispatch()方法,這樣它們才能立即分發一個 Action 。

如何工作

讓我們看看實際的組件:

利用 ’Day 1' 在 renderStickyHeader() 中觸發 onChange 事件,然后在組件內部的 switchDay() 方法被調用,該方法分發this.props.switchDay() 動作創造者。在我們的動作文件內,我們可以看到這樣一個動作創造者:

  /* from js/actions/index.js */
  switchDay: (day): Action => ({
    type: 'SWITCH_DAY',
    day,
  })

在導航 Reducer 內部,我們可以看到一個修改的 day 值產生一個新的 state 樹:

/* from js/reducers/navigation.js */
  if (action.type === 'SWITCH_DAY') {
    return {...state, day: action.day};
  }

這個 Reducer(以及任何其他那些可能監視 SWITCH_DAY 動作的 Reducers )返回新的 state 到 Store中,其更新自己并且發送一個改變事件。

而由于通過連接 <GeneralScheduleView> 連接到 Redux Store,我們還訂閱了 Store 的變化狀態。

什么是解析服務器

在這個教程中,你希望你能夠獲取到大量新的信息,所以讓我們快速展示我們如何連接 React Native 應用到我們的解析服務器數據后端,以及相關 API:

Parse.initialize(
    'PARSE_APP_ID',
    'PARSE_JAVASCRIPT_KEY'
  );
  Parse.serverURL = 'http://exampleparseserver.com:1337/parse'

就這樣,在 React Native 中我們通過 解析 API 建立連接。

是的,因為我們現在使用的是 Parse + React SDK ,我們有非常簡單的SDK接入。

解析和動作

當然,我們希望能夠查詢(例如,在我們的Actions中)......很多的查詢。對于那些動作創造者來說沒什么特別的,它們是我們之前提到的相同的異步操作。然而,因為有非常多的簡單的 Parse API 查詢需要初始化應用,我們想大量減少樣板。在我們的基本動作文件中,我們創建基本的動作構建者:

/* from js/actions/index.js */
function loadParseQuery(type: string, query: Parse.Query): ThunkAction {
  return (dispatch) => {
    return query.find({
      success: (list) => dispatch(({type, list}: any)),
      error: logError,
    });
  };
}

然后我們就可以簡單的多次復用該代碼:

loadMaps: (): ThunkAction =>
  loadParseQuery('LOADED_MAPS', new Parse.Query(Maps)),

loadMaps() 變成了一個動作創建器,其將會運行一個簡單的解析查詢針對所有存儲的地圖數據,然后將其單獨傳遞當查詢結束時。loadMaps() 以及其他解析數據操作的動作可以在整個應用的 componentDidMount() 方法中找到,這意味著當應用第一次打開時,其需要拉去所有的解析數據。

解析和 Reducers

我們已經減少了重復的動作,但是同時我們也想減少在我們 Reducers 內部的模板。這些模板將會從 動作負載中收到一些解析 API 的數據,并且必須將其映射到 state。我們針對解析數據創建單個基本 Reducer :

/* from js/reducers/createParseReducer.js */
function createParseReducer<T>(
  type: string,
  convert: Convert<T>
): Reducer<T> {
  return function(state: ?Array<T>, action: Action): Array<T> {
    if (action.type === type) {
      // Flow can't guarantee {type, list} is a valid action
      return (action: any).list.map(convert);
    }
    return state || [];
  };
}

這是個簡單的 Reducer (有大量流式類型注解),但是讓我們看看其如何同基于關閉它的子 Reducers工作。

/* from js/reducers/faqs.js */
const createParseReducer = require('./createParseReducer');

export type FAQ = {
  id: string;
  question: string;
  answer: string;
};

function fromParseObject(map: Object): FAQ {
  return {
    id: map.id,
    question: map.get('question'),
    answer: map.get('answer'),
  };
}

module.exports = createParseReducer('LOADED_FAQS', fromParseObject);

所以不用每次去重復創建一份 createParseReducer 代碼,我們只需要簡單的傳遞一個對象給基本的 Reducer ,其會將 API 數據映射到我們的 state 上。

現在,我們的應用,擁有一個結構良好且易于理解的數據流,可以連接到我們的解析服務器,甚至能夠離線同步我們的 Store 到本地存儲。

下一篇:構建 F8 2016 App (四):測試

來源:pockry

 本文由用戶 blueberryf 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!