Πειράματα με Server-Driven UIs

Ένα πείραμα server-driven UI για mobile: ένα Rails API περιγράφει τις οθόνες, τα components και τις ενέργειες, και μια εφαρμογή React Native τα αποδίδει.

Δημοσιεύτηκε · Αναθεωρήθηκε · 1.522 λέξεις · Προβολή ως Markdown


Περιεχόμενα
  1. Τι γίνεται με το Mobile;
  2. Συστήνοντας το Tapiful
  3. Το διασκεδαστικό μέρος
  4. Επιλογή τεχνολογιών
  5. Η βιβλιοθήκη
  6. Σχεδιασμός
  7. Μοντελοποιώντας το API με τον server-driven τρόπο
  8. Πλοήγηση
  9. Μοντελοποιώντας τις αλληλεπιδράσεις του χρήστη
  10. Ορίζοντας τα endpoints
  11. Απόδοση και ενημέρωση του UI
  12. Εγγραφή στο NFC tag
  13. Το component bottom sheet
  14. Ώρα για εγγραφή
  15. Τελικές σκέψεις

Τα τελευταία χρόνια, η ιδέα του HTML που αποδίδεται από τον server έχει κερδίσει αρκετό έδαφος στον κόσμο του web development: έχουμε εργαλεία όπως LiveWire, HotWire, Phoenix LiveView, HTMX κ.ά. Μπορούμε με σιγουριά να πούμε ότι το να είναι το backend υπεύθυνο για τη λογική έχει μερικά ωραία πλεονεκτήματα.

Για μερικά χρόνια, ως κλάδος, αναπτύξαμε εργαλεία και διαδικασίες που διευκόλυναν τη μεταφορά λογικής, γνώσης του domain και πολυπλοκότητας στους clients. Αντί να απασχολούμε τους clients με την παρουσίαση, τους δώσαμε έναν τρόπο να κάνουν query στη βάση, με λίγα μόνο επιπλέον βήματα.

Τώρα φαίνεται ότι κλείσαμε τον κύκλο και γυρίσαμε σε ένα πιο
μοντέρνο $('#component-id').html(response).

Τι γίνεται με το Mobile;#

Το mobile, ως σχετικά νεότερο μέσο από το web, δεν έχει ακόμα αγκαλιάσει πλήρως τα server-driven παραδείγματα. Θα μπορούσαν να ισχύσουν οι ίδιες αρχές και εκεί; Ακούμε πολλά για τον διαχωρισμό αρμοδιοτήτων, το όριο του request μοιάζει καλό σημείο για να τραβήξουμε μια διαχωριστική γραμμή, και η ιδέα ενός ενιαίου API που μπορεί να καταναλωθεί από κάθε είδους client βοήθησε προς αυτή την κατεύθυνση.

Στη δουλειά μου προσπαθούμε να ισορροπήσουμε ανάμεσα σε συμπεριφορές που ορίζονται τοπικά και σε αυτές που ορίζονται από τον server, αλλά εδώ και μήνες έχω μια σκέψη στο πίσω μέρος του μυαλού μου: «πόσο μακριά μπορεί να φτάσει αυτή η ιδέα του server-driven UI;» Θα μπορούσαμε να έχουμε την ίδια εμπειρία ανάπτυξης με το HotWire και στην ανάπτυξη native εφαρμογών; Στις χριστουγεννιάτικες διακοπές είχα λίγο χρόνο να πειραματιστώ.

Συστήνοντας το Tapiful#

Το Tapiful είναι μια mobile εφαρμογή που επιτρέπει στους χρήστες να γράφουν σε NFC tags και να διαχειρίζονται αυτά τα tags μέσα από ένα admin panel. Φάνηκε καλός υποψήφιος για πείραμα, επειδή απαιτεί native λειτουργικότητα του κινητού.

Το διασκεδαστικό μέρος#

Επιλογή τεχνολογιών#

Αυτό το άρθρο είναι περισσότερο ένα νοητικό πείραμα παρά ένα τεχνικό κείμενο, ωστόσο έπρεπε να διαλέξουμε εργαλεία. Για την πλευρά του server, εφόσον αυτή τη στιγμή δουλεύω με Rails, έβγαζε νόημα να φτιάξω το API με αυτό. Για την mobile εφαρμογή επέλεξα React Native: έχω κάποια εμπειρία μαζί του και φάνηκε καλή επιλογή για γρήγορο prototyping.

Η βιβλιοθήκη#

Αφού όλα έχουν τη λέξη wire μέσα τους, ας ονομάσουμε τη βιβλιοθήκη μας NativeWire. Είναι μια μικρή συλλογή από ελάχιστα components, ορισμένα τόσο στη native εφαρμογή όσο και στο API μας, που σέβονται το ίδιο contract και τις ίδιες αλληλεπιδράσεις.

Σχεδιασμός#

Εφόσον ακολουθούμε την προσέγγιση του server-driven UI, ο σχεδιασμός μοιάζει καλό σημείο εκκίνησης.

Tapiful screens

Μοντελοποιώντας το API με τον server-driven τρόπο#

Το component τίτλου οθόνης#

Screen title component

Το component τίτλου οθόνης αποτελείται από δύο κείμενα με διαφορετικά χρώματα και μεγέθη γραμματοσειράς. Μπορούμε να ονομάσουμε τα αντικείμενα κειμένου WiredText, με τα εξής attributes: type, text & style

Το attribute style

Το component του τίτλου μας έχει όλο το styling ορισμένο τοπικά. Ωστόσο, το attribute style λειτουργεί ως τρόπος να αντικαταστήσουμε ή να ορίσουμε το stylesheet από τον server. Εφόσον δουλεύουμε με React Native, μπορεί να περιέχει την αναπαράσταση σε snake_case των επιλογών του stylesheet, που αργότερα μετατρέπονται και συγχωνεύονται με το τοπικό style.

📝 Δείγμα JSON
JSON
{
    "id": "screen-title",
    "title": {
        "id": 1055186697223747675,
        "type": "text",
        "text": "Hello",
        "style": {}
    },
    "subtitle": {
        "id": -3075408202456109205,
        "type": "text",
        "text": "Awesome user 👋",
        "style": {}
    },
    "type": "title"
}

Το component τίτλου ενότητας#

Section title component

Αυτό το component μοιάζει να αποτελείται από ένα αντικείμενο WiredText που ορίσαμε προηγουμένως. Ωστόσο, έχει και ένα κουμπί, που θα ονομάσουμε WiredButton, με τα εξής attributes: id, type, title (ένα WiredText), link, style

📝 Δείγμα JSON
{
    "id": 5101,
    "title": {
        "id": 158,
        "type": "text",
        "text": "Recent Taps",
        "style": {}
    },
    "subtitle": {
        "id": "button-75",
        "type": "button",
        "title": {
            "id": 5518,
            "type": "text",
            "text": "View all",
            "style": {}
        },
        "link": "",
        "style": {}
    },
    "type": "section_title"
}

Το component κάρτας#

Card compoent

Για το component της κάρτας έχουμε τα εξής attributes:

title (ένα αντικείμενο WiredText), subtitle (ένα αντικείμενο WiredText), icon_left_url, swipe_left (ένα αντικείμενο WiredButton, που ορίζει αν η κάρτα μπορεί να γίνει swipe) & style

📝 Δείγμα JSON
{
    "id": "card-7834",
    "type": "card",
    "title": {
        "id": 6426,
        "type": "text",
        "text": "Stack Overflow",
        "style": {}
    },
    "subtitle": {
        "id": 4804,
        "type": "text",
        "text": "https://stackoverflow.com",
        "style": {}
    },
    "icon_left_url": "https://www.svgrepo.com/show/535483/link.svg",
    "swipe_left": {
        "id": "button-85",
        "type": "button",
        "title": {
            "id": 8070,
            "type": "text",
            "text": "Write",
            "style": {}
        },
        "link": "",
        "style": {}
    }
}

Πλοήγηση#

Navigation component

Το component πλοήγησης θα χρησιμοποιεί έναν πίνακα από αντικείμενα WiredButton. Εδώ, το attribute style δείχνει το επιλεγμένο στοιχείο.

📝 Δείγμα JSON
{
    "bottom_navigation": {
        "items": [
            {
                "id": "home",
                "type": "button",
                "link": "",
                "style": {
                    "color": "white",
                    "background_color": "black"
                },
                "icon_left_url": ""
            },
            {
                "id": "create_tap",
                "type": "button",
                "link": "",
                "style": {
                    "color": "#E4FF5A",
                    "background_color": "black"
                },
                "icon_left_url": ""
            },
            {
                "id": "taps",
                "type": "button",
                "link": "",
                "style": {
                    "color": "black",
                    "background_color": "transparent"
                },
                "icon_left_url": ""
            }
        ]
    }
}

Μοντελοποιώντας τις αλληλεπιδράσεις του χρήστη#

Θέλουμε οι αλληλεπιδράσεις του χρήστη να είναι δυναμικές και να ορίζονται από το API. Γι’ αυτό θα χρησιμοποιήσουμε mobile URIs για να μεταφέρουμε αυτή την πληροφορία. Μπορείς να τα σκέφτεσαι ως το αντίστοιχο των web URLs.

Πλοήγηση ανάμεσα σε οθόνες#

Tapiful deeplink structure

Στην περίπτωσή μας, αντί να χρησιμοποιούμε το link για να δείξουμε σε μια συγκεκριμένη οθόνη, μπορούμε να περάσουμε το endpoint που πρέπει να καλέσει η εφαρμογή για να εμφανίσει την οθόνη, κάπως έτσι: tapiful://screen?value=our-end-point, το οποίο αργότερα μεταφράζεται στην αντίστοιχη στρατηγική λειτουργίας.

Δείγμα Javascript
export default class OpenScreen {
  constructor(uri) {
    this.uri = uri;
  }

  execute() {
    navigate('Screen', {
      url: this.uri,
    });
  }
}

Το component εισαγωγής κειμένου#

Τώρα που έχουμε έναν τρόπο να δίνουμε εντολή στην εφαρμογή μας να εκτελεί λειτουργίες που ορίζονται από τον server, μπορούμε να φτιάξουμε το input component μας. Θα ακολουθήσουμε το ίδιο μοτίβο με τα υπόλοιπα components, αλλά εδώ θέλουμε να κάνουμε ένα API call και να αποδώσουμε τα αποτελέσματα ενημερώνοντας το state μας. Έτσι, θα δημιουργήσουμε ένα νέο link που το κάνει αυτό. Θέλουμε να εκτελείται αυτή η λειτουργία κάθε φορά που ενημερώνεται το input μας.

Δείγμα Javascript
export default class UpdateCurrentScreen {
  constructor(uri) {
    this.uri = uri;
  }

  execute({text, screen}) {
    let endpoint = new URL(this.uri);
    endpoint.searchParams.append('text', text);

    // ...
    // Make the request and update the current screen
  }
}

🎥 Βίντεο αναζήτησης μέσω server


{
    "id": "search-input",
    "type": "input",
    "placeholder": "Search",
    "icon_url": "https://www.svgrepo.com/show/532555/search.svg",
    "style": {},
    "value": "",
    "on_change_link": "tapiful://update?type=screen&value=end-point"
}

Δυναμικές οθόνες#

Για την πλοήγηση ανάμεσα στις οθόνες χρησιμοποίησα τη βιβλιοθήκη React Navigation.

Ένας «περιορισμός» είναι ότι η πλοήγησή μας απαιτεί να έχει οριστεί τουλάχιστον ένα route πριν αποδοθεί οποιαδήποτε οθόνη. Ωστόσο, η πληροφορία για το component των bottom tabs έρχεται μέσα στην απάντηση της οθόνης.

Ένας τρόπος θα ήταν να φτιάξουμε ένα splash screen και να μεταφέρουμε το φόρτωμα των στοιχείων πλοήγησης σε ένα bootstrap call. Πολύ κόπος για ένα πείραμα 😅

Έτσι επέλεξα να βάλω την αρχική οθόνη στο initial state της bottom navigation, το οποίο ενημερώνεται αργότερα όταν φορτωθούν τα υπόλοιπα στοιχεία πλοήγησης

// Redux initial state
const initialState = {
  bottom_navigation: {
    items: [
      {
        id: "home",
        initialParams: {
          url: "",
          link: "",
          id: "home",
        },
      },
    ],
  },
};

Ορίζοντας τα endpoints#

Αυτό ήταν το εύκολο κομμάτι: μέσω των links λέμε στην εφαρμογή μας ποια endpoints να καλέσει, οπότε κάτι σαν api/screens/:our_sreen κάνει τη δουλειά προς το παρόν.

Απόδοση και ενημέρωση του UI#

Ωραία, τώρα φτάσαμε στο σημείο όπου μπορούμε να δώσουμε εντολή στην εφαρμογή να κάνει ένα request μέσω ενός link, και είναι ώρα να κάνουμε κάτι με την απάντηση.

Αν θέλουμε οι αλλαγές στο UI να καθοδηγούνται από το API μας, το state μοιάζει με το σωστό μέρος για να περάσουν.

Η ροή του κώδικα είναι η εξής:

  1. Το component Screen αρχικοποιείται με ένα endpoint
  2. Εκτελεί ένα GET request
  3. Η απάντηση μετασχηματίζεται και αποθηκεύεται στο Redux
  4. Στη συνέχεια περνά από ένα component Builder, που είναι υπεύθυνο για την κατασκευή των components.
🎥 Βίντεο πλοήγησης μέσω server


Εγγραφή στο NFC tag#

Ήρθε η ώρα να κάνουμε αυτό που υποτίθεται ότι κάνει η εφαρμογή: να γράφουμε URLs σε NFC tags.

Υπάρχουν δύο τρόποι για να γράψει ο χρήστης ένα URL

  1. Κάνοντας swipe στην κάρτα και πατώντας το κουμπί Write, για URLs που έρχονται από το API
  2. Πατώντας το εικονίδιο συν και προσθέτοντας το URL με το χέρι

Προς το παρόν θα εξερευνήσουμε το 2ο, καθώς είναι πιο σύνθετο και ενδιαφέρον.

Το component bottom sheet#

Bottom sheet component

Αν θυμάσαι, θέλουμε όλες οι αλλαγές στο UI να περνούν από το store μας. Άρα, για να ανοίξει η εφαρμογή ένα bottom sheet, θέλουμε να το ελέγχουμε μέσω του store. Έχουμε ήδη ένα link που κάνει ένα call και ενημερώνει την τρέχουσα οθόνη μας· αυτή τη φορά θα του πούμε να καλέσει το endpoint components/create_tap.

Αυτό θα απαντήσει με τα components της οθόνης, συμπεριλαμβανομένου του bottom sheet component, το οποίο με τη σειρά του περιέχει έναν πίνακα components παρόμοιο με της οθόνης μας. Χρησιμοποιούμε επίσης τον ίδιο Builder για να κατασκευάσουμε τα περιεχόμενα του bottom sheet.

📝 Δείγμα JSON
{
    "components": [
        // ...other screen components
        {
            "id": "create-tap-bottom-sheet",
            "type": "bottom_sheet",
            "components": [
                {
                    "id": "url-input",
                    "version": "",
                    "value": "",
                    "type": "input",
                    "placeholder": "URL",
                    "icon_url": "https://www.svgrepo.com/show/522220/permalink.svg",
                    "style": {},
                    "on_change_link": ""
                },
                {
                    "id": "btn-id",
                    "type": "button",
                    "title": {
                        "id": 7720,
                        "type": "text",
                        "text": "Write",
                        "style": {}
                    },
                    "link": "",
                    "style": {}
                }
            ]
        }
    ]
}

Ώρα για εγγραφή#

Η ροή εγγραφής σε ένα NFC tag πάει κάπως έτσι

NFC write flow

Αυτό που θέλουμε είναι να

  1. Επικυρώνουμε το URL όσο το πληκτρολογεί ο χρήστης
    1. Να εμφανίζουμε μήνυμα για άκυρο url
    2. Να εμφανίζουμε κουμπί για την εγγραφή του URL
      1. Να εμφανίζουμε animation που προτρέπει τον χρήστη να πλησιάσει το NFC tag
        1. Σε επιτυχία, να εμφανίζουμε animation επιτυχίας
        2. Σε αποτυχία, να εμφανίζουμε animation αποτυχίας

Ας τα μετατρέψουμε σε κώδικα.

Αποστολή της εισαγωγής του χρήστη στον server

Θα χρησιμοποιήσουμε το attribute on_change_link που χρησιμοποιήσαμε στο input αναζήτησης για να στείλουμε την εισαγωγή του χρήστη στον server και να ενημερώσουμε την οθόνη μας, GET components/create_tap?text=https://goo...

Στη συνέχεια, ανάλογα με την τιμή, θα επιστρέφουμε ένα component WiredText που δείχνει το αποτέλεσμα της επικύρωσης.

Μόλις ο χρήστης εισάγει ένα έγκυρο URL, θα επιστρέφουμε ένα WiredButton με link tapiful://screen?value=components/create_tap?text=user-input&url=url-to-write . Έτσι το API μας ξέρει ότι πρέπει να επιστρέψει το lottie animation.

Τώρα είναι ώρα για την πραγματική εγγραφή.

Αφού αποδώσουμε το ενημερωμένο bottom sheet με το animation, θέλουμε επίσης να ξεκινήσει η εγγραφή. Αυτό το πετυχαίνουμε εισάγοντας ένα header After-Render-Link που κρατά το link μας για εγγραφή στο NFC μαζί με ένα callback_link tapiful://write_nfc?callback_link=tapiful://update?type=screen&value=/api/components/create_tap?url=https%3A%2F%[2Fgoogle.com](http://2fgoogle.com/)&type=url&value=https://google.com

🎥 Βίντεο επιτυχημένης εγγραφής στο NFC


Τελικές σκέψεις#

Τα server-driven UIs είναι μια συναρπαστική ιδέα, αλλά έχουν και προκλήσεις:

Ανταπόκριση: Κάθε αλληλεπίδραση εξαρτάται από τον server, κάτι που μπορεί να εισάγει καθυστέρηση. Στρατηγικές caching μπορεί να βοηθήσουν να μετριαστεί.
Μέγεθος payload: Το να ορίζονται όλα στο API μπορεί να φουσκώσει τις απαντήσεις, ειδικά για επαναλαμβανόμενα στοιχεία UI. Η χρήση references για τη μείωση του πλεονασμού θα μπορούσε να βοηθήσει.

Το πείραμά μου με το Tapiful ήταν διαφωτιστικό, αλλά το ερώτημα παραμένει: πόσο μακριά μπορούμε να πάμε τα server-driven UIs στην ανάπτυξη native εφαρμογών; 🤔