搖搖棒,理工男的擇偶權(上)

  • 搖搖棒,理工男的擇偶權(上)

  • 搖搖棒,理工男的擇偶權(中)

  • 搖搖棒,理工男的擇偶權(下)

 

前言

搖搖棒是載有一列LED的棒,通過適當的程序控制,在搖動起來時,由於人眼有視覺暫留現象(persistence of vision,POV),會形成一幅圖像。你可以上淘寶搜索,關注一下搖搖棒的核心參數(賣點)與显示效果。

 

一年多前,我做了一根搖搖棒,16個粉紅色LED,在520那天送給了女朋友。她很喜歡,她的同學和我的同學都很好奇。

那時候我做了兩根,當然不是因為我是渣男。另一根我帶去了高考(高二等級考)考場,內置了“全員A+”的字樣,本來想交給老師來給我們應援的,但是在烈日之下我只能很勉強地看見搖搖棒显示的字,於是就不了了之了。

我不服,又設計了搖搖棒2.0。製作完硬件以後,它就一直堆在我的書桌旁。

 

一年過去了,女朋友丟了,體重增加了,唯一不變的是我還是什麼降分約都沒有——唉,又要參加等級考了(寫作之時已經考完了)。

我想起了搖搖棒。

這一回,搖搖棒是我在高考前夕唯一的樂趣,是我在老師心中瓜皮形象的轉折點,是我作為一個理工男的擇偶權。

 

系列概述

本系列文章分為三篇:上篇介紹單機的搖搖棒,中篇介紹聯機的搖搖棒,下篇介紹圖靈完全的搖搖棒。

本文為上篇。目前進度大概到中篇的一半,但我覺得只有完成了後續(最好是所有)才能更好地審視前面的工作,用沒有回溯的思路整理成一篇博客。

寫文章要照應標題,不過這簡直就是做夢,我還是好好介紹搖搖棒吧,不去想那些有的沒的。

 

先放個效果圖吧(曝光時間0.2s):

 

核心原理

人們對搖搖棒有所好奇,無非是好奇它的核心原理,至於細節與實現,我說出來也沒有人要聽。這也是我開通博客的原因。

 

首先,棒上所有的輸入輸出設備都由程序控制,運行程序的是一塊單片機。

搖動周期是任意的(自適應的),別太誇張就行,所以搖搖棒需要檢測運動周期。用於檢測的硬件是位於棒頂端的水銀開關:

真空、密封的玻璃管中有一滴水銀,一個引腳始終與水銀接觸,另一個只有當水銀位於一端時才接觸。接觸時兩引腳導通,用一個很簡單的電路就可以把導通與否轉換成高低電平被單片機讀取。

改變水銀位置需要施力,搖搖棒運動過程中有加速度,提供了慣性力。然而,水銀開關只能指示加速度的方向,而不是更容易使用的加速度、速度、位置;加速度的方向也不能簡單地認為是一個周期內翻轉兩次——這就需要一個精巧的程序來控制。

我寫的程序能讓單片機知道(意會,別跟我杠什麼單片機沒有意識)它在一個周期中的相對位置,從而知道每一時刻該亮起圖像的哪一部分。哦對了,字符是轉換成點陣圖像存儲的,每一個像素點都是搖搖棒亮燈的依據。

於是,在一個周期中,圖像的每一列都被在對應的位置显示了一會。人眼有視覺暫留現象,這些列一起組成了一幅圖像,它的內容是字符。當然,簡單的圖案也是可以的。

 

硬件

以上為搖搖棒的原理圖,可以分為以下幾個部分:

  • 供電:18650電池座、電源開關、SX1308(B628)升壓、AMS1117-3.3穩壓;

    搖搖棒1.0直接用3.7V鋰離子電池供電,但實際電壓為2.7V到4.2V,亮度差異很大;2.0的供電部分先升壓到5V,為了便於在高亮度下控制亮度。

    藍牙模塊需要3.3V電源,所以加了個LDO。

  • 控制:ATMega328P單片機、晶振、ISP下載接口;

    單片機選擇的是我最擅長的AVR系列中的ATmega328P,與爛大街的Arduino相同(但我沒從那邊抄過哪怕一行代碼)。晶振是20MHz的,官方允許的最高頻率,為了獲得更好的性能。

    下載器接口是我自己定義的ISP接口,比標準的佔用更少空間,但畢竟是非標準的,這是個歷史遺留問題。

  • 輸入:電池電壓檢測、水銀開關、光敏電阻、按鍵×2;

    水銀開關接通時,SWC為低電平;斷開時,由於沒有負載,SWC為高電平;R05稱為上拉電阻。這就是那個很簡單的電路。電容C04本來想用於濾波的,實測反而礙事,拿掉了。兩個按鍵同理,上拉電阻在單片機內部配置。

    光敏電阻R06阻值與光強負相關,與定值電阻R07分壓后的輸出電壓與光強正相關,接到單片機的ADC(模-數轉換器)上,從而檢測環境光強度並調整亮度,深夜寫代碼與陽光下展(liào)示(mèi)都能適配。

  • 輸出:5片74HC595、2個N溝道MOS管、32個藍綠雙色LED、2個RGBW LED;

    595是串行轉并行芯片,MOS是一種三極管,詳見AVR單片機教程——矩陣鍵盤。595輸出串聯排阻後接LED再接到MOS管,連接方式下面細說。

    單片機上DAT1DAT3DAT4CLKSTO引腳控制595,前3個是數據信號。設計3個數據信號是為了加速輸出,不過最快的輸出方式是用SPI,沒有用它是設計上的失誤。

  • 藍牙:藍牙模塊、簡單的電平轉換電路。

    中篇內容,跳過。

 

兩個RGBW共8個燈,剛好對應595的8個輸出。不幸的是,595位於下方RGBW的背面,而另一個RGBW位於頂端,在狹窄的PCB中避開其他元器件和信號線走4根線並不容易,這是PCB布線的難點。也許還有別的難點,只是時間太久遠,我已經忘了。

595輸出串聯電阻後接LED,輸出低電平時LED不亮,高電平時有電流因而亮,電阻起到限流作用。不同顏色的燈串聯不同阻值的電阻是為了平衡亮度,在RGB都點亮時顏色接近白色。

4片595輸出LED0LED31,越上方的編號越小。每個藍綠雙色LED的兩個陽極共同連接一個LEDx信號,綠、藍陰極分別連接到GRNBLU,是兩個MOS管的漏極。當Q的柵極GRNC為高電平時,漏極和連接到GND的源極之間導通,電阻忽略不計,如果此時LEDx為高電平則對應綠燈亮起;低電平時不導通,無論LEDx如何,綠燈一個都不會亮——這段時間留給藍燈。

簡而言之,GRNC為高電平時595控制綠燈,BLUC為高電平時595控制藍燈。如果GRNCBLUC的電平轉換非常快,快到電平變化的一個周期內LED只移動了很小一段距離,看起來就是天藍色的。而事實上,GRNCBLUC的電平變化還沒那麼簡單。

 

PCB渲染圖如上。大致布局是,正面最上方水銀開關和光敏電阻,往下一個RGBW、32個藍綠、一個RGBW,IC和電阻等貼片器件都在反面對應的位置。然後是下載器接口、電源開關、兩個按鍵、電感、晶振,最後是電池,反面有升壓電路、單片機、藍牙模塊等。在手握搖搖棒時這些元器件會被碰到,影響正常工作,所以全部被我蓋了一層熱熔膠:

畢竟圖吧簽到12級。

 

硬件設計決定了搖搖棒功能的上限。比如,它不可能显示紅色的圖像(除非你能搖得快到紅移)。

本篇中搖搖棒能實現的功能有:

  • 以任意的藍綠組合顏色呈現圖像,包括漸變色;

  • 自動根據環境光強調整显示亮度;

  • 用按鍵切換显示圖像、複位周期檢測、調整亮度等。

 

驅動

這個項目不算簡單,所以我要加上驅動層,把底層的寄存器操作封裝成C語言函數,在適當的地方提供回調接口。後面將看到驅動層之上並非直接是應用程序,驅動負責到哪一步也是一個問題。我的想法是,應用程序不需要插入代碼的地方就封裝,否則就留給上層解決;明顯的異步操作用回調。

驅動層主要包括以下接口:

  1. LED,規定數據格式,提供以一定亮度亮燈的函數;

  2. 水銀開關,檢測加速度方向,附帶濾波;

  3. 按鍵,封裝按鍵雙擊、長按等高級事件;

  4. ADC,檢測電源電壓與光強,後者可以異步;

  5. 定時器,程序結構的核心,定時回調與全局時鐘;

  6. 藍牙,依舊跳過。

詳解一下奇數編號的驅動。

 

LED

32個雙色LED加上2個RGBW的模式可以用5個字節表示,我規定第[0]字節的最低位對應最上方的LED,第[3]字節的最高位對應最下方,第[4]字節最低位對應上方RGBW的紅色,最高位對應下方RGBW的白色。這樣就不難寫出驅動5片595的代碼:

uint8_t d0, d1, d2;
d0 = data[0];
d1 = data[2];
for (uint8_t i = 0; i != 8; ++i)
{
    cond_bit(read_bit(d0, 0), PORTC, 0);
    cond_bit(read_bit(d1, 0), PORTC, 1);
    d0 >>= 1;
    d1 >>= 1;
    clock_bit(PORTC, 3);
}
d0 = data[1];
d1 = data[3];
d2 = data[4];
for (uint8_t i = 0; i != 8; ++i)
{
    cond_bit(read_bit(d0, 0), PORTC, 0);
    cond_bit(read_bit(d1, 0), PORTC, 1);
    cond_bit(read_bit(d2, 0), PORTC, 2);
    d0 >>= 1;
    d1 >>= 1;
    d2 >>= 1;
    clock_bit(PORTC, 3);
}
clock_bit(PORTC, 4);

其中的位操作宏定義為:

#define set_bit(r, b) ((r) |= (1u << (b)))
#define reset_bit(r ,b) ((r) &= ~(1u << (b)))
#define read_bit(r, b) ((r) & (1u << (b)))
#define cond_bit(c, r, b) ((c) ? set_bit(r, b) : reset_bit(r, b))
#define flip_bit(r, b) ((r) ^= (1u << (b)))
#define clock_bit(r, b) (flip_bit(r, b), flip_bit(r, b)
#define bit_mask(n, b) (((1u << (n)) - 1) << (b))

配合GRNCBLUC的高低電平可以显示出綠、藍和天藍色,但這還不算完。GRNCBLUC連接到單片機的OC0AOC0B引腳,它們是定時器0的波形輸出引腳,可以產生PWM波。一個PWM周期內一段時間高電平,對應LED亮,低電平時暗,切換快到人眼完全看不出來,從而感覺到亮度是均勻的,與PWM占空比正相關的。一會讓GRNC輸出PWM波,BLUC保持低電平,一會相反,切換依然快到看不出來,於是就實現了任意的藍綠亮度組合。

原先這種設計只是為了解決藍綠亮度不相同的問題,後來漸漸地發展出了漸變色的功能。

typedef enum
{
    COLOR_NONE, COLOR_GREEN, COLOR_BLUE
} color_t;

void led_set(color_t color, uint8_t duty, const uint8_t data[5])
{
    TCCR0A &= ~(bit_mask(2, COM0A0) | bit_mask(2, COM0B0));
    // ...
    uint8_t com0x;
    volatile uint8_t* ocr0x;
    switch (color)
    {
    case COLOR_GREEN:
        com0x = 0b10 << COM0A0;
        ocr0x = &OCR0A;
        break;
    case COLOR_BLUE:
        com0x = 0b10 << COM0B0;
        ocr0x = &OCR0B;
        break;
    default:
        return;
    }
    if (duty == 0)
        return;
    TCCR0A |= com0x;
    *ocr0x = duty - 1;
    TCNT0 = 0xFF;
}

中間省略的是上面那段代碼。

 

按鍵

我一直想寫一個能處理長按、雙擊等事件的按鍵庫,這次正是一個機會。至少在這一篇中,按鍵是控制好搖搖棒的唯一方式。而按鍵一共只有兩個,為了使輸入方式更豐富,就只能在每個按鍵的事件種類上動手腳。

首先要消抖。按鍵在被按下和抬起的過程中,電平並不是直上直下的,可能存在抖動。如果把每一次跳變都算一個事件的話,隨意按一下可能就被算作雙擊了,所以需要消抖。我用的是最簡單的消抖方法:用一個變量記錄按鍵的狀態,當按鍵的電平與原狀態不同且保持10ms不變時,才認為此時按鍵進入新的狀態。水銀開關的消抖也是類似的。

#include <avr/io.h>

#define BUTTON_COUNT 2

static bool pin[BUTTON_COUNT];
static uint8_t filter[BUTTON_COUNT] = {0};

static inline bool button_read(uint8_t which)
{
    switch (which)
    {
    case 0:
        return read_bit(PINB, 1);
    case 1:
        return read_bit(PINB, 2);
    }
    return false;
}

static inline button_event_t button_filter(uint8_t which)
{
    if (which >= BUTTON_COUNT)
        return false;
    bool now = button_read(which);
    if (now == pin[which])
        filter[which] = 0;
    else if (++filter[which] == 50)
    {
        pin[which] = now;
        filter[which] = 0;
        return now ? BUTTON_LEFT_RELEASED : BUTTON_LEFT_PRESSED;
    }
    return BUTTON_NONE;
}

void button_init()
{
    set_bit(PORTB, 1);
    set_bit(PORTB, 2);
    for (uint8_t i = 0; i != BUTTON_COUNT; ++i)
        pin[i] = button_read(i);
}

定義三種模式,最複雜的模式中包括以下事件:

typedef enum
{
    MODE_NONE, MODE_SIMPLE, MODE_ADVANCED
} button_mode_t;

typedef enum
{
    BUTTON_NONE,
    BUTTON_LEFT_PRESSED, BUTTON_LEFT_RELEASED,
    BUTTON_LEFT_SHORT, BUTTON_LEFT_LONG, BUTTON_LEFT_CONT,
    BUTTON_LEFT_DOUBLE,
    BUTTON_RIGHT_PRESSED, BUTTON_RIGHT_RELEASED,
    BUTTON_RIGHT_SHORT, BUTTON_RIGHT_LONG, BUTTON_RIGHT_CONT,
    BUTTON_RIGHT_DOUBLE,
    BUTTON_BOTH
} button_event_t;

BUTTON_LEFT_CONT指左按鍵長按以後保持按下的事件,每100毫秒觸發一次;BUTTON_BOTH是兩個按鍵同時按下的事件。

函數button_get()返回一個button_event_t變量。每次調用只更新一個按鍵,因此不會有多個返回值。該函數需要客戶輪詢。

同時處理這麼多事件的方法是用狀態機:

BOTHFREE的轉移條件為另一個按鍵也處於BOTH狀態。具體timeout值見下面的代碼,代碼中數值除以5得到毫秒數。

比如,0ms時按下,200ms時抬起,400ms時按下,600ms時抬起,狀態轉移過程為:

  1. 0ms,FREEBOTH

  2. 100ms,BOTHSHORT,事件PRESSED

  3. 200ms,SHORTDOUBLE

  4. 400ms,DOUBLEFREE,事件DOUBLE

typedef enum
{
    STATE_FREE, STATE_BOTH, STATE_SHORT, STATE_DOUBLE, STATE_LONG
} state_t;

static button_mode_t mode = MODE_NONE;
static const button_event_t base[BUTTON_COUNT] = {0, BUTTON_RIGHT_PRESSED - BUTTON_LEFT_PRESSED};
static state_t state[BUTTON_COUNT];
static uint16_t count[BUTTON_COUNT];
static uint8_t turn = 0;

void button_mode(button_mode_t m)
{
    if (mode == m)
        return;
    mode = m;
    if (m == MODE_ADVANCED)
        for (uint8_t i = 0; i != BUTTON_COUNT; ++i)
            state[i] = STATE_FREE;
}

button_event_t button_get()
{
    button_event_t result = BUTTON_NONE;
    button_event_t filter = button_filter(turn);
    if (mode == MODE_SIMPLE)
        result = filter;
    else if (mode == MODE_ADVANCED)
    {
        switch (state[turn])
        {
        case STATE_FREE:
            if (filter == BUTTON_LEFT_PRESSED)
            {
                state[turn] = STATE_BOTH;
                count[turn] = 0;
            }
            break;
        case STATE_BOTH:
        {
            uint8_t other = 1 - turn;
            if (state[other] == STATE_BOTH)
            {
                result = BUTTON_BOTH;
                state[turn] = STATE_FREE;
                state[other] = STATE_FREE;
            }
            else if (filter == BUTTON_LEFT_RELEASED)
            {
                result = BUTTON_LEFT_PRESSED;
                state[turn] = STATE_DOUBLE;
                count[turn] = 0;
            }
            else if (++count[turn] == 500)
            {
                result = BUTTON_LEFT_PRESSED;
                state[turn] = STATE_SHORT;
                count[turn] = 0;
            }
            break;
        }
        case STATE_SHORT:
            if (filter == BUTTON_LEFT_RELEASED)
            {
                state[turn] = STATE_DOUBLE;
                count[turn] = 0;
            }
            else if (++count[turn] == 2500)
            {
                result = BUTTON_LEFT_LONG;
                state[turn] = STATE_LONG;
                count[turn] = 0;
            }
            break;
        case STATE_DOUBLE:
            if (filter == BUTTON_LEFT_PRESSED)
            {
                result = BUTTON_LEFT_DOUBLE;
                state[turn] = STATE_FREE;
            }
            else if (++count[turn] == 500)
            {
                result = BUTTON_LEFT_SHORT;
                state[turn] = STATE_FREE;
            }
            break;
        case STATE_LONG:
            if (filter == BUTTON_LEFT_RELEASED)
            {
                result = BUTTON_LEFT_RELEASED;
                state[turn] = STATE_FREE;
            }
            else if (++count[turn] == 500)
            {
                result = BUTTON_LEFT_CONT;
                count[turn] = 0;
            }
            break;
        }
    }
    if (result != BUTTON_NONE && result != BUTTON_BOTH)
        result += base[turn];
    if (++turn == BUTTON_COUNT)
        turn = 0;
    return result;
}

 

廢話兩句。以前上課的時候有人問我單片機按鍵雙擊怎麼寫,當時我心裏還沒底,因為沒寫過,就讓他多加一個按鍵。這時我們老師說,註冊一個回調就可以了呀!

嗯,算你懂得回調。

 

定時器

程序中主循環的周期為0.1ms,但是一個周期中執行指令的時間相比於周期長度而言已經不可忽略,為了精準地控制時間,需要使用定時器。沒錯,這裏的定時器和之前提到的用於產生PWM波的是同一類東西,不同的是之前用的是定時器0,這裏用的是定時器2,兩者互不干擾。

設置定時器2分頻係數為8,匹配值為250,則每2000個CPU時鐘周期產生一个中斷。CPU時鐘頻率為20MHz,因此定時器中斷的間隔為0.1ms。客戶須在每次中斷中調用button_get,這就是除以5得到毫秒數的原理。

定時器中斷有兩項職責,一是維護一個時鐘,每一周期增加1,可重置,主要用於水銀開關周期檢測;二是調用上層的回調函數timer_handler,驅動中僅聲明為extern(另一種方法是通過函數指針註冊回調)。

#include <avr/io.h>
#include <avr/interrupt.h>

static uint16_t tick = 0;

ISR(TIMER2_COMPA_vect)
{
    ++tick;
    timer_handler();
}

void timer_init()
{
    if (0)
        TIMER2_COMPA_vect();
    TCCR2A = 0b10 << WGM20;
    TCCR2B = 0 << WGM22 | 0b010 << CS20;
    OCR2A = 249;
    TIMSK2 = 1 << OCIE2A;
    sei();
}

void clock_reset()
{
    tick = 0;
}

uint16_t clock_get()
{
    return tick;
}

 

應用程序

驅動封裝了硬件操作,而用戶只想關心显示什麼內容,兩者之間還需要插入一層,這一層主要實現運動周期檢測,並在周期中合適的時刻根據用戶提供的數據進行显示。

兩層之間用回調函數和配置信息耦合。回調函數包括定時器回調、按鍵事件回調與圖像更新回調;配置信息定義如下:

typedef struct
{
    uint8_t width;
    uint8_t height_byte;
    const uint8_t* display;
    uint8_t in_flash : 1;
    uint8_t bright;
    uint8_t color;
    uint8_t rgbw;
} Config;

display指向點陣數據,共width * height_byte字節,每height_byte字節表示一列,RGBW另存。

客戶通過set_config函數更新配置,Config參數被立即拷貝到一個特定的位置,但不會立即應用於显示,而是等待當前显示周期(即運動周期)結束,在下次更新中應用,簡而言之配置被緩衝了。

 

在C語言中,即使一個數組聲明為const,它也存放在RAM中,但是ATmega328P只有2k字節RAM,显示的字數很多的話會放不下。AVR編程中可以用PROGMEM宏指定數據存放在flash中,in_flash即表示點陣是否存儲在flash中。

#include <stdint.h>
#include <avr/pgmspace.h>

static const uint8_t jiayou[] PROGMEM =
{
    0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x40, 0x00, 0x20, 0x40, 0x00, 0x18,
    0x40, 0x00, 0x07, 0x40, 0xF8, 0x09, 0xFE, 0x1F, 0x08, 0x40, 0x00, 0x10,
    0x40, 0x00, 0x30, 0x40, 0x00, 0x18, 0xC0, 0xFF, 0x0F, 0xC0, 0x07, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xFF, 0x7F, 0x40, 0x00, 0x08,
    0x40, 0x00, 0x08, 0x40, 0x00, 0x08, 0x40, 0x00, 0x08, 0x40, 0x00, 0x08,
    0xC0, 0xFF, 0x3F, 0x40, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x07, 0x72,
    0x08, 0x06, 0x7F, 0x18, 0xE0, 0x01, 0x10, 0x18, 0x00, 0x00, 0x07, 0x00,
    0xC0, 0x00, 0x00, 0xC0, 0xFF, 0x7F, 0xC0, 0xFF, 0x7F, 0x40, 0x20, 0x10,
    0x40, 0x20, 0x10, 0x40, 0x20, 0x10, 0xFE, 0xFF, 0x1F, 0xFE, 0xFF, 0x1F,
    0x40, 0x20, 0x10, 0x40, 0x20, 0x10, 0x40, 0x20, 0x10, 0xC0, 0xFF, 0x7F,
    0xC0, 0xFF, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};

(點陣數據可用PCtoLCD2002生成;原諒我用拼音命名變量。)

指向flash中數據的指針與普通指針相同,但是不能直接解引用,要先用memcpy_P函數拷貝到RAM中:

memcpy_P(display.current, display.ptr + display.phase * display.height_byte, display.height_byte);

並不是所有點陣數據都放在flash中,比如程序還可以通過藍牙接收數據,把它寫進flash就太麻煩了。

 

程序結構為,先執行初始化,包括硬件與變量,然後進入死循環,保持程序運行。初始化的最後是啟動定時器,隨後定時器會每0.1ms產生一次中斷,所有實際工作都在中斷中完成。

int main()
{
    startup();
    while (1)
        ;
}

 

周期檢測

那時做完第一版發了個朋友圈,就有人問這個問題:

的確,周期檢測是搖搖棒的難點(對於那些問我“把搖搖棒放在桌上不動能不能显示”的人就不是了),是我寫第一版甚至第二版的程序時唯一心慌的地方。雖然免去了為MPU6050寫I²C驅動的煩惱,但5毛錢的水銀開關也自有麻煩之處。讓我們來一探究竟吧!

 

水銀開關電路的輸出信號首先要經過濾波,這是驅動層封裝好的:

typedef enum
{
    MERCURY_NONE, MERCURY_LEFT, MERCURY_RIGHT
} mercury_event_t;

static bool status;
static uint16_t count = 0;

static inline bool mercury_read()
{
    return !read_bit(PINB, 0);
}

void mercury_init()
{
    status = mercury_read();
}

mercury_event_t mercury_get()
{
    bool now = mercury_read();
    if (now == status)
        count = 0;
    else if (++count == 100)
    {
        status = now;
        count = 0;
        return now ? MERCURY_RIGHT : MERCURY_LEFT;
    }
    return MERCURY_NONE;
}

 

然後就是算法的主體部分。算法可以用狀態機描述,只有穩定與不穩定兩個狀態,用stable變量表示,初始值為falseperiod為上一周期的長度,單位為定時器周期即0.1ms,是兩個狀態共用的;計數器count在兩個狀態中有不同的含義,但共用一個變量。

算法只監聽水銀珠從右到左這一事件,大致上是棒從右到左經過中點。定義局部變量uint16_t clock = clock_get();,表示當前周期已經持續的時間。大多數分支都會調用clock_reset複位時鐘,並在使用完clock后把它寫為0,標志著新的周期開始。

在不穩定狀態中,要想進入穩定狀態,必須連續若干次滿足以下條件:本次周期長度大於前一周期的0.5倍並且小於1.5倍。count記錄這一條件成立的次數,一旦某一次條件不成立則清零,並把period更新為當前周期長度。目標次數被設置為2。

在穩定狀態中,根據周期長度分3類討論:

  1. 周期長度大於等於前一周期的0.75倍並且小於1.5倍,這意味着當前周期和上一周期差不多長,用戶在穩定地搖動。把period設為兩個周期的平均值,這樣可以允許周期緩慢變化。

  2. 周期長度小於0.75倍,這可能是噪音導致的,應該忽略,不複位時鐘。但是這種情況連續出現很多次就不對了,用count記錄次數,達到一定值時要進入不穩定狀態。這個值被設置為2。

  3. 周期長度大於等於1.5倍,用戶停止了搖動,直接進入不穩定狀態。事實上停止搖動后LED還會閃一下,因為不免存在抖動,導致程序又判定出一個周期。

測試過程中發現,如果突然把搖動頻率翻倍,由於有第2個分支的存在,算法會把兩個周期判定為一個;有時剛開始搖動就會這樣。解決這個問題需要在分支1和2中動點手腳:用half_flag表示分支1中clock是否是period的一半,具體來講是3/8到5/8;half_count表示連續出現“一周期中進入分支1一次且half_flag為真”的次數。當half_count達到2時就可以認為算法進入了錯誤的狀態,需要減半period以恢復正常。

bool stable;
uint16_t period;
uint8_t count;
bool half_flag;
uint8_t half_count;

void timer_handler()
{
    // ...
    uint16_t clock = clock_get();
    if (mercury_get() == MERCURY_LEFT)
    {
        if (stable)
        {
            if (clock < period * 3 / 4)
            {
                if (++count == 2)
                {
                    stable = false;
                    count = 0;
                }
                if (period * 3 / 8 < clock && clock < period * 5 / 8)
                {
                    half_flag = true;
                }
            }
            else if (clock < period * 3 / 2)
            {
                clock_reset();
                if (count == 1 && half_flag)
                {
                    if (++half_count == 2)
                    {
                        half_count = 0;
                        clock = 0;
                    }
                }
                else
                {
                    half_count = 0;
                }
                period = (period + clock) / 2;
                count = 0;
                half_flag = false;
                clock = 0;
            }
            else
            {
                stable = false;
                count = 0;
            }
        }
        else
        {
            clock_reset();
            if (period / 2 < clock && clock < period * 3 / 2)
            {
                if (++count == 2)
                {
                    stable = true;
                    period = (period + clock) / 2;
                    count = 0;
                    half_flag = false;
                    half_count = 0;
                    clock = 0;
                }
            }
            else
            {
                period = clock;
                count = 0;
            }
        }
    }
    // ...
}

 

知道了周期長度與起始時刻,也就知道了每一時刻在周期中的位置。一個周期的3/8到5/8,也就是從左到右中間的部分,可以显示圖像,显示的列隨clock均勻變化,由於中間段接近勻速,显示的圖像是比較均勻的。

為什麼不在從右到左過程中显示呢?因為周期起始的位置並不精確地是正中間,還受周期、重力和手的影響,取3/8到5/8而不是1/4到3/4就包含對這些因素的考量。如果在相差半個周期的位置也显示的話,兩幅圖像肯定無法重合,即使動態調整位置也無濟於事。

 

性能優化

也許你已經注意到,上面的代碼中從未出現過int,只有uint8_tuint16_t等確定長度的整數類型。這樣做可以帶來可移植性,更重要的是AVR作為8位單片機對整數長度十分敏感,能用8位就不要用16位。

mega系列有雙周期硬件乘法器,但沒有硬件除法器,除數確定的除法編譯器會轉化為乘法來計算,不確定的就只能調用除法路徑了。這種除法偶爾算一次還行,每個定時器周期都算就會嚴重拖慢速度,比如這句判斷是否該切換列的語句:

if (clock == period * 3 / 8 + (uint32_t)period * phase / width / 4)
    // ...

要加uint32_t轉換是因為perioduint16_t類型,整數提升成unsigned intint是16位整型),計算結果為unsigned類型,但實際乘積會溢出,就不得不轉換成更長的long。這下可好,每周期計算32位整數除法,同時觸犯兩條禁忌。

我的性能優化就從這裏入手,逐漸擴展到所有計算過程不太簡單但不常變化的量,它們都存儲在結構體compute中:

struct
{
    uint16_t threshold_low;
    uint16_t threshold_high;
    uint16_t half_low;
    uint16_t half_high;
    uint16_t clock_base;
    uint16_t clock_step;
    uint16_t clock_compare;
    uint16_t green_step;
    uint16_t blue_step;
    uint8_t rgbw_duty;
} compute;

clock開頭的三個變量就是用來優化前述語句的。在显示周期開始,即clock == 0時,先計算:

compute.clock_base = motion.period * 3 / 8;
compute.clock_step = motion.period / display.width;
compute.clock_compare = compute.clock_base;

compute.clock_compare就是if中與clock比較的值。在display.phase增加后,需要重新計算compute.clock_compare的值,其中除以4是可以接受的計算:

compute.clock_compare = compute.clock_base + compute.clock_step * display.phase / 4;

你也許會問,為什麼不把除以4放進compute.clock_step的計算中?考慮誤差較大的情況:motion.period == 2047, display.width == 128compute.clock_step比理想值小了6.2%,圖像的寬度將壓縮為原來的93.8%;如果把除以4放進去,誤差會達到25.0%,這就比較嚴重了。

轉換為uint32_t先乘后除無疑是更加精準的,優化後由於整數除法只能得到整數結果而產生了更大的誤差,因此這裏的性能優化與編譯器優化還不同:編譯器要遵守“as-if”規則,而我是在用可接受的精度下降換取可觀的速度提升。

利用超綱的手段(藍牙),我得知優化前定時器中斷的執行時間超過了定時器周期的80%,優化後下降到了40%以下(都是-O3),性能提升十分明顯。擠出來的計算資源將會在下篇中派上用場。

 

一個相似的例子是漸變色模式中LED亮度(對應PWM占空比)的計算。原來的計算式為:

duty = led.green * phase / width;

優化以後為:

compute.green_step = (led.green << 8) / (display.width - 1);
duty = (compute.green_step * phase) >> 8;

如果不左移8位直接除,因為有整數除法的誤差,显示效果將是瞬變而不是漸變,所以我要先左移8位再右移8位,這與上面的除以4是類似的,只是更加顯式。

我的重點不在移位的藝術性上。請你看看優化后的第一個語句有什麼問題,已知三個變量的類型分別為uint16_tuint8_tuint8_t

點擊展開答案

led.green在移位運算中被提升為int(而不是unsigned),移位運算結果為int類型,除法運算結果亦為int類型。當led.green >= 128時,除法結果為負數,賦給無符號的compute.green_step,變成無符號數與phase相乘再移位。我搞不清楚結果是個什麼東西,反正显示效果不是漸變色。

解決方法很簡單,把led.green轉換成uint16_t再參与運算即可。發現這個問題花了我一個小時,真是成也摳門敗也摳門啊!

 

下期預告

另一端的效果圖見文首。

 

後記

本文中的周期檢測算法能實現其功能,並具有一定容錯與自恢復能力,但是還不完美。

即使算法能從雙倍周期中恢復出來,半速显示仍會持續至少2個理論周期,或4個實際周期。從觀賞者的角度上看,半速显示是相當醜陋的——翻轉、拉伸、邊緣畸變、交疊,可謂集大成者。有趣的是,這種自恢復是我在寫作本文期間才想到並應用的;此前我給用戶提供的對策是按下按鍵以重置算法,然而矛盾的是用戶如果要給別人展示,自己就看不到显示效果,也就無從得知這種錯誤,只會讓觀賞者覺得我是個遜仔。

這個問題也許可以歸結於性能與容錯性的權衡:要允許噪音,就必須接受短暫的半速显示。

權衡歸權衡,真正的缺陷依然存在:算法允許穩定以後周期內出現噪音,但是如果每個周期內都有噪音,也就無法進入穩定狀態,但是信號的周期仍客觀存在。其實噪音很大程度上來源於濾波沒有濾乾淨,但濾波中的時間閾值也不能設置地太高,如果要把這種噪音留到濾波后級去解決,我就不知道該怎麼辦了。

 

和搖搖棒一樣利用POV原理的還有旋轉燈,你可以在淘寶用“旋轉 POV”關鍵字搜索。旋轉燈可以說是升級版的搖搖棒,電機代替了手,無線輸電代替了電池,显示效果也上了有一個檔次,甚至可以柱面、球面显示。不過作為靈魂的水銀開關被磁傳感器替代了,所以我感覺旋轉燈的編碼難度不會高於搖搖棒,難度更偏向於硬件設計。

前两天看到一篇微信推送,視頻里出現4根棒組成的便攜式旋轉燈,甚至有旋轉燈陣列組成的屏幕,評論區直呼看不懂,我直呼羡慕。

旋轉燈局限於面显示(球面也是面),而光立方能增加一個維度,是真正的立體显示。光立方是靜態的,唯一動的部分大概就是動態掃描了,沒有一點難度,只是焊接太累了。正因工作量大且效果花哨,送給女朋友非常合適,這一點我已經驗證過了。

光立方的致命缺陷在於分辨率低,難以提高LED數量的根本原因在於它是三維的。搖搖棒是一維的,動起來以後成為二維,不難想象二維的運動起來可以變成三維——我還真在網上見過把光立方的一個面轉起來的,分辨率與維數兼得。

這些東西記在這裏,給讀者拓寬眼界,也給我自己種棵草。

 

我沒有仔細看過別人的搖搖棒設計,在第二版的設計、裝配、編程過程中甚至沒有以“搖搖棒”為關鍵字搜索過,一方面因為網上大多都是我不會的51,另一方面我不喜歡讀別人的單片機代碼,這與51的擴展語法脫不了干係,更重要的是我覺得那些都是上個世代的代碼——我的第一版搖搖棒的程序竟然是用C++14寫的!更誇張的是,回調用的是std::vector<std::function<void()>>,後來還逐漸演化為C#中event的類似物。事實上,AVR工具鏈並沒有C++的標準庫,這兩個類模板是我自己實現的。

那時年幼無知,不懂得謙虛,包括對人與對單片機。

 

文章寫完了。除了前言和後記差強人意以外,中間的技術介紹完全就是半吊子——有所涉及,卻無法深入。譬如LED的電路,我本應詳細介紹595與PWM及其背後的思想;又譬如周期檢測,我本應帶領讀者一步一步實現這個算法。所以我只能更改本文的目標,把完整清晰地介紹搖搖棒下調為僅供讀者觀賞(如果你有意深入了解我的搖搖棒,可以後台聯繫我),甚至連這個小目標都達不到。

或許搖搖棒的材料更適合用於講座或視頻,文字這一形式對表達有所限制,然而高手不應該被表達形式限制,所以歸根結底是我太菜了。

 

不知怎的,完成了碼量是前一版幾倍的項目外加一篇博客,收穫感甚至比不上許久以前就着別人的博客實現出一個std::function。可能是這個項目對於目前的我過於簡單,這當然是件值得欣喜的事;或者,

是因為高考臨近了吧。

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

※教你寫出一流的銷售文案?

※超省錢租車方案

Java Agent(上)

1、java agent是什麼?

—》對用戶透明,不侵入用戶業務代碼。讓java虛擬機加載agent jar

2、java agent有什麼用?

—>應用場景例如:調用鏈追蹤項目,在用戶無感知的情況下,記錄日誌。目前業內使用該技術的有,SkyWalking,Pinpoint(這個監控的粒度更小)

-各個 Java IDE 的調試功能,例如 eclipse、IntelliJ ;

-熱部署功能,例如 JRebel、XRebel、 spring-loaded;

-各種線上診斷工具,例如 Btrace、Greys,還有阿里的 Arthas;

-各種性能分析工具,例如 Visual VM、JConsole 等

3、java agent的實現原理?

https://zhuanlan.zhihu.com/p/147375268

4、 入門案例

4.1、 如何製造自己第一個java agent jar包

4.1.1、 第一步:我們需要一個插件來幫助我們生成帶特定格式的MAINIFEST.MF的jar

4.1.2、 第二步:在啟動項目的時候,在jvm參數中添加 -javaagent: *\ving-agent-0.0.1-SNAPSHOT.jar (在jvm上先加載agent包)

(偷偷地問)特定格式的MAINIFEST.MF是怎樣的?需要包括下面的內容

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: cn.think.in.java.clazz.loader.asm.agent.PreMainTraceAgent

(再偷偷地問),難度每次都讓我手動去弄這個文件,我覺得很麻煩呀,有沒一個工具能幫我們將agent項目打包成包含MAINIFEST.MF的jar?
—–》對,你猜對了,真的有這個工具。那就是maven插件。(說到打包,肯定要想到maven或者gradle了吧)

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.2</version>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Project-name>${project.name}</Project-name>
                            <Project-version>${project.version}</Project-version>
                            <Premain-Class>com.tuling.agent.Agent</Premain-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                            <Boot-Class-Path>javassist-3.18.1-GA.jar</Boot-Class-Path>
                        </manifestEntries>
                    </archive>
                    <skip>true</skip>
                </configuration>
            </plugin>

插件

打包之後

測試

問題二:如果有一個類已經被虛擬機加載了,那麼我們的agent包裏面的邏輯就不能加載這個類。但是我就是想把這類替換掉,怎麼辦呢?(熱更新,虛擬機不停的情況下,替換成用戶最新的代碼)

插件

agent代碼的修改

重新打包

測試

問題三:
當JVM已裝置某個類,但是我們想重新走一次premain方法,我們怎樣做呢?
插件

修改agent代碼

打包

測試

思考

看到這裏,細心的朋友,可能會帶有疑問,我在實踐的時候,發現這個類的字節碼的生成並不簡單,如果讓我自己去手動去生成那就很麻煩呀?(麻煩–》複雜度高—》容易出錯—-》上線容易出問題—》那就直接不考慮該技術)
—-》為了解決這個問題,java-ssist就出現了。關於java-ssist,請點擊。

https://www.cnblogs.com/vingLiu/p/13193517.html

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※超省錢租車方案

中興通訊每年投逾50億 進軍新能源汽車

中國手機廠商中興通訊推出的自主研發的大功率無線充電系統,已進入公共交通領域。2014年10月,中興通訊攜手蜀都客車共同發佈全球首個無線充電城市微循環解決方案。該方案可以在不專門征地的情況下,在社區附近將任何現有停車位或路面直接改造為安全快捷的充電站。   “目前我們已經與10多個城市簽約,包括四川、河北、雲南、河南等省的城市,與當地政府就公共汽車的無線充電進行合作。”中興通訊副總裁孫枕戈稱,下一步將會把無線充電落實到私家車領域。據孫枕戈介紹,今後新能源汽車有可能將以PPP(政府與私人組織之間合作建設城市基礎設施專案)模式進行運營,而且將獲得中央政府和地方政府的補貼。   孫枕戈表示,從2015年開始,中興通訊每年將投入至少10億元人民幣(約合新臺幣50.6億)進行無線充電設施的商業運作。中興通訊未來的戰略是將通訊技術與各行各業相連,即M-ICT戰略,無線充電項目是其中的一項,該項目是中興與大專院校、研究機構共同合作的結果。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※教你寫出一流的銷售文案?

傳小米電動車已進入量產 售價新台幣 20 萬有找

小米在智慧型手機及資訊領域攻城掠地後,顯然創辦人雷軍並未因此滿足,打從更早之前就一直有傳聞小米想要打造車子,而且還是高技術門檻的電動車,如今又有進一步消息指稱,小米電動車已經進入量產階段。   身為 Tesla 在中國市場的第一批車主,雷軍也曾多次親自拜訪 Tesla 的執行長 Elon Musk,交流許多關於電動車的未來、以及對智慧車輛的看法,近期中國網路上盛傳,內部代號「米斯拉」的小米牌電動車已經開始量產,甚至連其代工合作夥伴為比亞迪等資訊都被揭露。   消息也指出,米斯拉除了會是台電動車外,還會搭載小米自家的 MIUI 系統,將擁有豐富的智慧聯網功能;更驚人的是,這樣的一台高科技智慧電動車,售價僅要 39,999 人民幣,折合新台幣不過 20 萬有找,若此消息為真,小米將會在車界掀起另一波破壞巨浪。    

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司"嚨底家"!

新北清潔公司,居家、辦公、裝潢細清專業服務

※推薦評價好的iphone維修中心

油價大跌 陸新能源汽車銷售目標恐難達成

國際原油每桶已跌破 55 美元,這對大陸正在推動新能源汽車恐怕將踢到鐵板,尤其是明年銷售 50 萬輛的目標很難達陣。大陸正致力推動的新能源政策,也將出現減速的阻力,包括太陽能等替代能源產業,都在這次國際油價重跌後,面臨無以為繼的結果。   《南方周末》報導,國際油價下跌,新能源汽車首當其衝,其中以中國電動車龍頭企業比亞迪受傷最重,原本就賣不動的電動車,在油價直直落後,不少消費者根本對電動車無感。   據悉,工信部副部長蘇波除到比亞迪考察新能源汽車發展和推廣情況外,更召集相關部會舉行節能與新能源汽車產業發展部際聯席會議聯絡員會議,除了發改委、科技部、財政部等 18 個部際聯席會議成員單位,還邀請國管局、國土資源部參加,規模空前,顯見中國政府也預見新能源汽車受油價影響,政府訂定的銷售目標恐難以達陣。  

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理
【其他文章推薦】

USB CONNECTOR掌控什麼技術要點? 帶您認識其相關發展及效能

台北網頁設計公司這麼多該如何選擇?

※智慧手機時代的來臨,RWD網頁設計為架站首選

※評比南投搬家公司費用收費行情懶人包大公開

※幫你省時又省力,新北清潔一流服務好口碑

※回頭車貨運收費標準

助力新能源車 意法半導體推新款車規碳化矽二極體

意法半導體推出新款車規碳化矽(SiC)二極體,以滿足電動汽車和插電式混合動力車(PHEVs,Plug-in Hybrids)等新能源汽車對車載充電器(OBCs,on-board battery chargers)在有限空間內處理大功率的苛刻要求。  

  新款二極體採用先進的技術可防止高電流突波燒毀裝置,其過電流保護是額定電流的2.5倍,因此設計人員可選用更小、更經濟實惠且可靠性和效能都不會受到影響的電流更小的二極體。此新碳化矽二極體通過車規產品測試,反向擊穿電壓提高到650V,能滿足設計人員和汽車廠商欲降低電壓補償係數的要求,以確保車載充電半導體元件的標準與瞬間峰值電壓之間有充足的安全邊際。   這次推出的650V二極體包括TO-220AC功率封裝的10A STPSC10H065DY和TO-220AC封裝的12A STPSC12H065DY。此外,TO-220AB封裝的STPSC20H065CTY和TO-247封裝的STPSC20H065CWY是內建2個10A二極體的雙二極體(dual-diode )產品,可最大幅度地提升空間利用度並減少車載充電器的重量。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

南韓推廣電動車 目標 2020 年上路 20 萬輛

南韓政府致力推廣價格更低、能源效率更高也更環保的下一代電動車,預計在 2020 年之前,把電動車的使用量提高到 20 萬輛。   南韓環境部 19 日宣布一套涵蓋範圍甚廣的措施,目標是加速電動車的商業化。具體措施包括延長實施稅務優惠、投資新的技術、訂定公家機關購車配額、研擬計畫擴增充電站數目,以及其他獎勵購買電動車的措施。   依照這項計畫,南韓政府訂定目標,希望在南韓上路的電動車總數能從目前的 800 輛提高到 2015 年的 3,000 輛,到 2020 年再提高到 20 萬輛。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※教你寫出一流的銷售文案?

北京明年起新能源車補貼下降 純電動車補27.4萬

北京市財政局公佈了《北京市示範應用新能源小客車財政補助資金管理細則》,明確了今後3年的相關財政補助具體標準。在2015年至2017年12月31日期間,北京市消費者購買純電動小客車最多可獲補助5.4萬元人民幣(約新臺幣27.4萬),購買燃料電池小客車的補助最高為18萬元(約新臺幣91萬),較今年標準均有下降。   此外,汽車生產企業享受中央和本市財政補助總額最高不超過車輛銷售價格的60%。如補助總額高於車輛銷售價格的60%,按車輛銷售價格60%扣除中央補助後計算本市財政補助額。對於實行公務用車編制管理的單位購買新能源小客車,不享受本市財政補助。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司"嚨底家"!

新北清潔公司,居家、辦公、裝潢細清專業服務

※推薦評價好的iphone維修中心

Day12-微信小程序實戰-交友小程序-優化“附近的人”頁面與serach組件的布局和樣式以及搜索歷史記錄和本地緩存*內附代碼)

回顧/:我們已經實現了显示附近的人的功能了,可以多個人看到附近的人頁面了

但是還是要進行優化有幾個問題:1、我們用戶選擇了其他的自定義頭像之後,在首頁可以看到頭像的變化,但是在附近的人中頭像會變成報錯的樣式:如:

 

 

 也就是500了,也就是找不到這個圖片了,解決方法:看開發文檔-》雲開發

https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/storage/api.html

其中有一個 “換取臨時鏈接”的功能(通過這個方法可以臨時的拿到一個圖片的路徑了),然後這個路徑就可以對應到我們的iconpath中了,有直接看demo

wx.cloud.getTempFileURL({
  fileList: ['cloud://xxx.png'],
  success: res => {
    // fileList 是一個有如下結構的對象數組
    // [{
    //    fileID: 'cloud://xxx.png', // 文件 ID
    //    tempFileURL: '', // 臨時文件網絡鏈接
    //    maxAge: 120 * 60 * 1000, // 有效期
    // }]
    console.log(res.fileList)
  },
  fail: console.error
})

我們剛剛換了頭像的測試號,可以看到在數據庫中

 

 

 

 正常的試https這樣的,但是我們修改了之後,它的路徑變成了我們設置的默認的,cloud開始的了

所以我們就可以直接在near.js裏面用for來判斷每個字段符不符合條件即可了,一旦找到了這個cloud開頭的路徑的話,也就是if裏面進行的東西

我們就要換取臨時的路徑即可了,如果else的話,我們還是和之前一樣的,直接push進去即可了

if裏面的話直接copy文檔裏面的demo即可了

我們通過

console.log(res.fileList) 打印出來的東西試一個數組: 

 

 裏面的那個tempFileURL就是一個臨時的路徑了

 getNearUsers(){
    db.collection('users').where({
      location: _.geoNear({
        geometry: db.Geo.Point(this.data.longitude, this.data.latitude),
        minDistance: 0,
        maxDistance: 5000
        //這1000和5000的單位是米
      }),
      islocation : true
    }).field({
      longitude : true,
      latitude : true ,
      userPhoto : true
    }).get().then((res)=>{
      console.log(res.data);
      let data = res.data;
      let result = [];
      if(data.length){

        for(let i=0;i<data.length;i++){
          if(data[i].userPhoto.includes('cloud://')){
            wx.cloud.getTempFileURL({
              fileList: [data[i].userPhoto ],
              success: res => {
                // console.log(res.fileList[0].tempFileURL)
                result.push({
                  // 然後就是把我們獲取到的臨時路徑直接賦值給iconpath即可了
                  iconPath: res.fileList[0].tempFileURL,
                  id: data[i]._id,
                  latitude: data[i].latitude,
                  longitude: data[i].longitude,
                  width: 30,
                  height: 30
                });
                
              }
            })
          }
          else{
            result.push({
              iconPath: data[i].userPhoto,
              id: data[i]._id,
              latitude: data[i].latitude,
              longitude: data[i].longitude,
              width: 30,
              height: 30
            });
          }
        
        }
        this.setData({
          markers : result
        });
      }
    });
  }

如果只是這個代碼的話,會發現我們測試賬號的如何信息都無法渲染出來,這個是因為js是異步操作的,我們要在if之後立馬就進行 setdata操作即可了

如何在全部for結束之後也再次的進行setdata操作即可了,完整代碼就是

getNearUsers(){
    db.collection('users').where({
      location: _.geoNear({
        geometry: db.Geo.Point(this.data.longitude, this.data.latitude),
        minDistance: 0,
        maxDistance: 5000
        //這1000和5000的單位是米
      }),
      islocation : true
    }).field({
      longitude : true,
      latitude : true ,
      userPhoto : true
    }).get().then((res)=>{
      console.log(res.data);
      let data = res.data;
      let result = [];
      if(data.length){

        for(let i=0;i<data.length;i++){
          if(data[i].userPhoto.includes('cloud://')){
            wx.cloud.getTempFileURL({
              fileList: [data[i].userPhoto ],
              success: res => {
                // console.log(res.fileList[0].tempFileURL)
                result.push({
                  // 然後就是把我們獲取到的臨時路徑直接賦值給iconpath即可了
                  iconPath: res.fileList[0].tempFileURL,
                  id: data[i]._id,
                  latitude: data[i].latitude,
                  longitude: data[i].longitude,
                  width: 30,
                  height: 30
                });
                this.setData({
                  markers: result
                });
              }
            })
          }
          else{
            result.push({
              iconPath: data[i].userPhoto,
              id: data[i]._id,
              latitude: data[i].latitude,
              longitude: data[i].longitude,
              width: 30,
              height: 30
            });
          }
        
        }
        this.setData({
          markers : result
        });
      }
    });
  }

 

 

 得到的效果就是,可以看到另外一個用戶剛剛它換的頭像了

(後面的優化就是可以點擊這個用戶的頭像之後我們就可以跳轉到它的詳情頁面了

這個功能在實現起來其實頁不複雜的,有一個和markers對應的事件,也就是點擊了這個markers就會觸發這個事件了  

 

通過這個事件其實我們是可以拿到id值得

 

 markertap(ev){
    console.log(ev);
  }

通過在near.js裏面得這個函數,然後我們點擊一下地圖裡面的marker圖片之後,我們得到的值就是:

 

這個markerID其實對應的就是用戶的id值了

  markertap(ev){
    // console.log(ev);
    wx.navigateTo({
      url: '/pages/detail/detail?userId=' + ev.markerId
    })
  }

通過這個代碼其實就可以實現,點擊地圖裡面的圖標的話我們就可以跳轉到這個用戶的詳情頁面去了

3、後面要測試的就是假如測試賬號關閉了共享位置的話

通過測試我們發現,測試號關閉了共享位置的話,在地圖裡面即使是刷新了還是會看到這個用戶的頭像的

 (其實代碼是沒有錯的,把項目關了再重啟之後會看到這個關閉了共享位置的用戶頭像就消失了

(其實還有其他可以優化的,就是可以在地圖的頭像上面加一段語音介紹自己等等的,因為小程序其實也是支持的,或者是可以計算我和你的距離

或者是我去你那邊的話我過去的導航和路線是怎麼樣的

 

二、search組件的布局和樣式

(就是在主頁的上面添加一個查找的框)

1、實現新建一個叫search的組件

 

 創立好了之後,就可以在首頁進行引用了

2、先在index.JSON文件裏面引入這個組件

{
  "usingComponents": {
    "search" : "/components/search/search"
  }
}

3、在主頁裏面和用標籤一樣引用就可以了

可以直接在index.wxml中通過 <search /> 來使用即可了

 

該search組件就被引入了

通過基本的結構wxml

<!--components/search/search.wxml-->
<view class="container">
  <view class="search"> 
    <view class="search-text">
      <text class="iconfont iconsousuo"></text>
      <input type="text" placeholder="搜索喵星人" />
    </view>
    <view class="search-cancel">取消</view>
  </view>
</view>

得到的效果:

 

會發現我們放大鏡圖標沒有显示出來,所以我們要配置一下,讓這個圖標可以穿透出來即可了

也就是之前copyText.js寫過的

  options: {
    styleIsolation: 'apply-shared'
  },

就是為了讓這個圖標可以生效的

 

 

 這樣的話,我們的放大鏡就進來了

之後就可以對search.wxss的樣式進行設計了

 

/* components/search/search.wxss */
.container{position: fixed;left: 0;top: 0;width: 100%;height: 70rpx;z-index: 999;}
.search{ display: flex ; align-items: center;}
.search-text{ display: flex; align-items: center;flex: 1;} 

 

但是發現,圖片和這個組件融合在一起了

 

 這是因為因為是組件的引入的話,就不像在主頁面一樣,可以佔位置的,所以就要到index.wxss設置一下讓index騰出一個空間來放這個搜索框的

通過在

 

 就是直接通過margin來騰出位置即可了

 

 上面其實是在index.wxss中給上面的騰出來100rpx的空間

/* components/search/search.wxss */
.container{position: fixed;left: 0;top: 0;width: 100%;height: 70rpx;z-index: 999;}
.search{ display: flex ; align-items: center; margin:20rpx;}
.search-text{ display: flex; align-items: center;flex: 1;border: 1px #cdcdcd solid;border-radius:10rpx; height: 65rpx} 
.search-text .iconsousuo{margin: 0 10rpx;}
.search-cancel{margin: 0 10rpx;}

得到的效果就是:

 

 但是有一個問題就是:我們在還沒點擊搜索的時候,其實不用显示後面的“取消”按鈕的,這個的話就要通過js邏輯來實現了

定義了一個isfocus來表示光標有沒有显示的(這個取消的按鈕其實是在我們獲取了光標之後才會有的)

通過在取消按鈕加上了一個wx:if判斷之後,得到的效果就是:

 

 並且當我們獲取到了光標之後,這個搜索框會適應整個頁面的高度了

 給contaner加上了  overflow: hidden; 之後得到的效果就是這個搜索框的下邊框“不見了”

 

 這個是因為,我們得container這個大得塊要比我們輸入框得高度要小了,這個時候就可以在wxss裏面通過調節container得height

 

 即可了

因為如果我們點擊了那個輸入框得胡,也就是聚焦了得話,我們得上面得搜索框的大容器显示的樣式是和沒聚焦的時候显示的不同的,所以我們就可以用三目運算符來通過這個isfocus來決定使用哪個容器,也就是說我們可以定義兩個樣式不同的容器了

<view class="{{ isFocus ? 'containerFocus' : 'container' }}">
.containerFocus{position: fixed;left: 0;top: 0;width: 100%;height: 100%;z-index: 999;
background: #ccc}

然後我們自行的吧js文件裏面定義的isFocus變量 定義weighted是true來看看我們獲取光標之後的效果是怎麼樣的:

 

之後我們就要通過邏輯里控制他們的聚焦切換不同的container了,如果是已經點擊聚焦的了話,還有一個就是可以看到我們搜索的歷史記錄,還有列表等等

 

通過:

<view class="search-history">
    <text>歷史記錄</text>
    <text class="iconfont iconshanchu"></text>
  </view>
.search-history{ display: flex;justify-content: space-between;margin:20rpx;}

效果:

 

 然後就是要搞一個搜索池了:

  <view class="search-history-btn">
    <text>小明</text>
    <text>123213</text>
    <text>dsadasd</text>
  </view>
.search-history{ display: flex;justify-content: space-between;margin:20rpx;}
.search-history-btn text{ border: 1px #cdcdcd solid; padding: 10rpx 20rpx;background: white;
border-radius: 20rpx; margin:10rpx;}

效果:(注意上面是給每一個搜索的text進行樣式的定義

上面就吧搜索的關鍵詞的布局搞好了,下面就是要對搜索的列表進行定義了(其實這個搜索的列表和我們好友的列表是很像的,可以直接直接copy 在friendList.wxml裏面的這個結構了

  <navigator wx:for="{{ friendList }}" wx:key="{{ index }}" url="{{ '../detail/detail?userId=' + item._id}}" open-type="navigate">
      <view class="friendList-item">
        <view>
         <image src="{{ item.userPhoto }}" />
         <text> {{ item.nickName }} </text>
        </view>
        <text class="iconfont iconyoujiantou"></text>
      </view>
     </navigator>

然後對  searchList-item 的樣式也是直接拷貝friendList的wxss

.friendList-item{
  /* 這裏可以直接把user.wxss中的樣式複印過來了 */
  height: 120rpx;border-bottom:1px #b4b5b6 dashed;
padding: 10rpx; display: flex;align-items: center;justify-content: space-between;
}
.friendList-item view{display : flex; align-items: center;}
.friendList-item image{width: 100rpx;height: 100rpx;border-radius: 50%;}

 

綜上所述,我們的代碼就是:

CSS

<!--components/search/search.wxml-->
<view class="{{ isFocus ? 'containerFocus' : 'container' }}">
  <view class="search"> 
    <view class="search-text">
      <text class="iconfont iconsousuo"></text>
      <input type="text" placeholder="搜索喵星人" />
    </view>
    <view wx:if="{{ isFocus }}" class="search-cancel">取消</view>
  </view>

  <view class="search-history">
    <text>歷史記錄</text>
    <text class="iconfont iconshanchu"></text>
  </view>
  <view class="search-history-btn">
    <text>小明</text>
    <text>123213</text>
    <text>dsadasd</text>
  </view>

    <navigator url="" open-type="navigate">
      <view class="searchList-item">
        <view>
         <image src="" />
         <text>小喵喵</text>
        </view>
        <text class="iconfont iconyoujiantou"></text>
      </view>
     </navigator>

</view>

html

然後還要在search.js裏面通過

options: { styleIsolation: ‘apply-shared’ } 引入外部樣式 效果圖:(選中搜索框時)

 

(未選中搜索框時

 

 

 三、實現搜索歷史記錄及本地緩存

1、我們先在searc.wxml的輸入框標籤加一個處理點擊這個輸入框的一個點擊事件

bindfocus=”handleFocus”

 還有我們在取消的標籤中,也要加一個點擊事件,點擊了的話就吧isFocus變成是false即可了

 <input type="text" placeholder="搜索喵星人" bindfocus="handleFocus" />


<view wx:if="{{ isFocus }}" class="search-cancel" bindtap="handleCancel">取消</view>
 methods: {
    handleFocus(){
     this.setData({
       isFocus : true
     }); 
    },
    handleCancel(){
      this.setData({
        isFocus: false
      }); 
    }
  }

得到的效果就是:點擊輸入框,就跳轉到輸入,點擊取消,就跳轉到首頁

還有一個小bug就是,因為輸入框的話,會默認只有在一個範圍以內,才可以輸入的,所以我們就可以讓這個輸入框適應整個範圍,可以在

給 search.wxss中添加一個代碼:

.search-text input {flex: 1;}

就讓這個輸入框可以自動的填滿整個的搜索框了

3、之後就是對輸入的東西進行處理了,可以是邊輸入邊搜索,也可以是輸入之後回車了才進行搜索,如果是邊輸入就邊搜索的話,我們可以通過bindinput來進行監聽的,那如果要是按回車的時候搜索怎麼辦呢—這個其實小程序幫我們搞好了

https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/storage/api.html

就可以通過在input中加上 bindconfirm 這個屬性來完成的,我們就定義了一個 handleConfirm 這個方法是只有我們回車了才會進行觸發的

 

 

 在手機端裏面的回車 其實默認的是 “完成”兩個字的(就是點擊這個輸入框的時候,手機就會彈出軟鍵盤了,它的確定按鈕是“搜索”兩個字的,那這個該怎麼樣去修改呢==微信也提供了

 

 默認的是我們的 done 也就是完成

所以就在input標籤中,吧confirm-type 屬性變成是 search 即可了,(這樣的話在手機的軟鍵盤就會显示 搜索 兩個字了)

(下面我們要做的就是 吧這個搜索的 放在歷史裏面管理起來了)

https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/storage/api.html

demo:

wx.setStorage({
  key:"key",
  data:"value"
})

設置的話,就是我們用戶點擊回車 之後,就可以吧這個搜索裏面的 ev.detail.value放到本地存儲裏面即可了

因為這個setStorage的話,我們要讓這個data是一個數組才行的,然後我們先通過

data : [111]看看能不能吧這個111存放到這個數組裡面

 

 可以在下面的調試板中 找到Storage 讓我們查看一下

可以看到,我們隨便輸入一點東西,然後按 回車 之後可以看到

先在search.js的data裏面定義一個 數組

然後我們就可以在wxml中,吧我們的歷史消息text,用一個數組來for出來了

 <view class="search-history-btn">
    <text wx:for="{{ historyList }}" wx:key="{{ index }}">{{ item }}</text>
  </view>

 

然後我們在一開始 聚焦了之後,就立馬從storage裏面吧數組拿出來,用getStorage方法:

https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/storage/api.html

wx.getStorage({
  key: 'key',
  success (res) {
    console.log(res.data)
  }
})

 

使用上面的demo之後,會報錯,這個報錯一般都是因為success的回調的時候要用箭頭函數才行的

   wx.getStorage({
        key: 'searchHistory',
        success:(res)=> {
          this.setData({
            historyList: res.data
          });
        }
      })

修改了之後,我們點擊 聚焦 之後

這個 111 就是我們剛剛寫入到 searchStorage 數組裡面的

 

 (這個有一個小bug,就是,假如我們輸入了兩次相同的搜索,然後存入到歷史記錄再打印出來的話,會有兩個的,我們不應該有兩個相同的歷史記錄的

 但是我們搜索重複詞的話,我們也是显示一次,然後把這個搜索的提升到最前面去),表示最近搜索,並且歷史記錄也要有一個數量的,不能把在一年之間的全部搜索記錄都显示出來的

這個去重的功能:1、實現克隆一份數組

 (unshift的話就是往數組的頭添加東西的,ES6本身就帶有一個set來完成去重功能的)

   handleConfirm(ev){
      // console.log(ev.detail.value);
      let cloneHistoryList = [...this.data.historyList];
      cloneHistoryList.unshift(ev.detail.value);
      wx.setStorage({
        key: "searchHistory",
        data: [...new Set(cloneHistoryList)]
      })
    }

我們的效果就達到了,重複輸入的話,會被提前,=

然後下面我們就要實現 歷史記錄的刪除功能了

就可以直接在這個刪除圖標的wxml中添加一個 bindtap點擊事件  handleDelete 即可了(這個刪除的話,是刪除掉全部的歷史記錄的)

(微信給我們提供的對storage的操作中,remove是操作某一項的,而clear是刪除掉所有的

https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/storage/api.html

wx.clearStorage()

直接這樣寫即可了

====**但是這樣可能如果我們後面在storage裏面也定義了其他的東西,這個語句的話會把其他緩存也會清理掉的,所以我們這裏還是使用remove好點的

wx.removeStorage({
  key: 'key',
  success (res) {
    console.log(res)
  }
})

因為我們也是要在這個成功的回到中,把這個歷史數據數組設置為空數組,所以我們就要使用成功返回的箭頭函數才行的

即可實現刪除功能了,

效果就是:

 

 

 之後再次輸入1的時候,

 

 然後就是清空 歷史記錄:

 

 

 

 

下面是這個部分的代碼

//components/search/search.js
Component({
  /**
   * 組件的屬性列表
   */
  options: {
    styleIsolation: 'apply-shared'
  },
  properties: {

  },

  /**
   * 組件的初始數據
   */
  data: {
    isFocus : false,
    historyList : []
  },

  /**
   * 組件的方法列表
   */
  methods: {

    handleFocus(){

      wx.getStorage({
        key: 'searchHistory',
        success:(res)=> {
          this.setData({
            historyList: res.data
          });
        }
      })

     this.setData({
       isFocus : true
     }); 
    },
    handleCancel(){
      this.setData({
        isFocus: false
      }); 
    },
    handleConfirm(ev){
      // console.log(ev.detail.value);
      let cloneHistoryList = [...this.data.historyList];
      cloneHistoryList.unshift(ev.detail.value);
      wx.setStorage({
        key: "searchHistory",
        data: [...new Set(cloneHistoryList)]
      })
    },
    handleHistoryDelete(){
      wx.removeStorage({
        key: 'searchHistory',
        success:(res)=>{
          this.setData({
            historyList : []
          });

        }
      })
    }
  }
})
<!--components/search/search.wxml-->
<view class="{{ isFocus ? 'containerFocus' : 'container' }}">
  <view class="search"> 
    <view class="search-text">
      <text class="iconfont iconsousuo"></text>
      <input type="text" placeholder="搜索喵星人" bindfocus="handleFocus" bindconfirm="handleConfirm" confirm-type="search"/>
    </view>
    <view wx:if="{{ isFocus }}" class="search-cancel" bindtap="handleCancel">取消</view>
  </view>

  <view class="search-history">
    <text>歷史記錄</text>
    <text bindtap="handleHistoryDelete" class="iconfont iconshanchu"></text>
  </view>
  <view class="search-history-btn">
    <text wx:for="{{ historyList }}" wx:key="{{ index }}">{{ item }}</text>
  </view>

    <navigator url="" open-type="navigate">
      <view class="searchList-item">
        <view>
         <image src="" />
         <text>小喵喵</text>
        </view>
        <text class="iconfont iconyoujiantou"></text>
      </view>
     </navigator>

</view>
/* components/search/search.wxss */
.container{position: fixed;left: 0;top: 0;width: 100%;height: 90rpx;z-index: 999;overflow: hidden;}
.containerFocus{position: fixed;left: 0;top: 0;width: 100%;height: 100%;z-index: 999;
background: #ccc}
.search{ display: flex ; align-items: center; margin:20rpx;}
.search-text{ display: flex; align-items: center;flex: 1;border: 1px #cdcdcd solid;border-radius:10rpx; height: 65rpx; background: white;} 
.search-text input {flex: 1;}
.search-text .iconsousuo{margin: 0 10rpx;}
.search-cancel{margin: 0 10rpx;}

.search-history{ display: flex;justify-content: space-between;margin:20rpx;margin-bottom: 30rpx;}
.search-history-btn{ margin-bottom: 30rpx; }
.search-history-btn text{ border: 1px #cdcdcd solid; padding: 10rpx 20rpx;background: white;
border-radius: 20rpx; margin:10rpx;}


.searchList-item{
  /* 這裏可以直接把user.wxss中的樣式複印過來了 */
  height: 120rpx;border-bottom:1px #b4b5b6 dashed;
padding: 10rpx; display: flex;align-items: center;justify-content: space-between;
}
.searchList-item view{display : flex; align-items: center;}
.searchList-item image{width: 100rpx;height: 100rpx;border-radius: 50%;}

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※教你寫出一流的銷售文案?

Python元類實戰,通過元類實現數據庫ORM框架

本文始發於個人公眾號:TechFlow,原創不易,求個關注

今天是Python專題的第19篇文章,我們一起來用元類實現一個簡易的ORM數據庫框架。

本文主要是受到了廖雪峰老師Python3入門教程的啟發,不過廖老師的博客有些精簡,一些小白可能看起來比較吃力。我在他的基礎上做了一些補充和註釋,盡量寫得淺顯一些。

ORM框架是什麼

如果是沒有做過後端的小夥伴上來估計會有點蒙,這個ORM框架究竟是什麼?ORM框架是後端工程師常用的一個框架,它的英文全稱是Object Relational Mapping,即對象-關係映射框架。顧名思義就是把關係轉化成對象的框架,關係這個詞我們在哪裡用的最多呢?

顯然應該是數據庫。之前我們在分佈式的文章介紹關係型數據庫和非關係型數據庫的時候就着重介紹過關係的含義。我們常用的MySQL就是經典的關係型數據庫,它存儲的形式是表,但是表承載的數據其實是兩個實體之間的”關係”。比如學生上課這個場景,學生和課程是兩個主體(entity),我們要記錄的是這兩個主體之間的關係,也就是學生上課這件事。

而ORM框架做的事情是將這些關係映射成類,這樣我們可以將這張表當中增刪改查的功能抽象成類當中的方法。這樣我們就可以通過調用類的方式來操作數據庫了,從而達到高度抽象業務邏輯、降低用戶使用難度的目的。

比如Java後端工程師常用的hibernate和ibatis都是用來做這件事情的,明確了框架的功能之後,我們先來設想一下最後的成果。假設我們現在開發出來了這麼一套框架,那麼它用起來的感覺應該是怎樣的?

我們來看下廖老師博客里給的例子:

class User(Model):
    # 定義類的屬性到列的映射:
    id = IntegerField('id')
    name = StringField('username')
    email = StringField('email')
    password = StringField('password')

User類代表了數據庫當中的一張表,它有4個字段:id, name, email和password,我們在定義字段的同時也通過類別指定了它們的類型。這個應該不難理解,上面的這個類等價於我們在數據庫當中執行了這麼一段建表的SQL:

create table if not exists user (
 id int,
    name string,
    email string,
    password string
)

我們定義了表字段之後,接下來要做的就是根據字段創建數據了,其實也就是根據類創建實例。我們希望User類型的實例就對應User表當中的一條記錄,並且我們可以通過調用實例當中的方法,來操作這張表進行增刪改查。

# 創建一個實例:
u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd')
# 保存到數據庫:
u.save()

那麼,我們怎樣可以實現這樣的功能呢?

功能實現

我們先從簡單的功能開始實現,首先是Field類,Field類表示數據庫表當中一個字段的類型。這裏的邏輯很容易理清楚,我們需要定義多種類型,比如IntegerField和StringField。我們可以對這些field類抽象出一個父類來:

class Field(object):
    def __init__(self, name, column_type):
        self.name = name
        self.column_type = column_type
        
    def __str__(self):
        return '<{}:{}>'.format(self.__class__.__name__, self.name)

__str__方法當中打印出來的兩個字段,分別是類別的名稱和字段的名稱,這段代碼應該不難理解。

接着,我們實現它的兩個子類,分別是IntegerField和StringField:

class StringField(Field):
    def __init__(self, name):
        super(StringField, self).__init__(name, 'varchar(100)')
        
        
class IntegerField(Field):
    def __init__(self, name):
        super(IntegerField, self).__init__(name, 'bigint')

這裏也不難理解,只是一個簡單的繼承應用而已。

接下來就到了最關鍵的部分,也就是Model類的實現。我們先來分析一下我們希望Model這個類擁有的功能,由於它是我們定義出來的每一張表的父類,所以它應該能夠獲取子類當中的字段,並且將它存放在一個容器當中。由於我們需要存儲的是字段名和類型的映射,所以將它存儲在dict當中比較合理。

另外一個功能是我們希望它能夠提供增刪改查的接口,能夠根據子類當中定義的字段自動生成相應的SQL語句去調用數據庫。這個也是ORM框架的意義所在。

第二個功能容易實現,只要第一個功能搞定了,做一下字符串處理即可。但是第一個功能有些麻煩,它也是元類的意義所在。因為父類當中的方法是無法獲取子類中定義的類屬性的,只能通過元類,在構建類的時候可以拿到屬性的信息。

所以我們已經很明確了,我們實現元類的目的就是為了實現這個功能。理清楚了之後,再來寫代碼就不難了。我們先來實現這個元類:

class ModelMetaclass(type):

    def __new__(cls, name, bases, attrs):
        # 創建model類的時候不做任何處理
        if name=='Model':
            return type.__new__(cls, name, bases, attrs)
        # 打印表名的信息
        print('Found model: %s' % name)
        # mappings用來存儲字段的信息
        mappings = dict()
        for k, v in attrs.items():
            # 判斷v的類型,只有是Field的子類才會存儲起來
            if isinstance(v, Field):
                print('Found mapping: %s ==> %s' % (k, v))
                mappings[k] = v
        # 將mappings當中的數據從類屬性當中移除,防止關鍵字衝突
        for k in mappings.keys():
            attrs.pop(k)
        attrs['__mappings__'] = mappings # 保存屬性和列的映射關係
        attrs['__table__'] = name # 假設表名和類名一致
        return type.__new__(cls, name, bases, attrs)

如果你看過之前的文章,對元類已經很熟悉了,那麼這段代碼對你來說應該不難理解。元類搞定了,剩下的Model就更簡單了。按照規範,我們需要實現增刪改查四個函數,但是這裏我們只是為了展示,所以就只實現其中一個作為例子,其他幾個都可以如法炮製。

class Model(dict, metaclass=ModelMetaclass):
    def __init__(self, **kw):
        # 由於Model的基類是dict,所以創造Model的字段會被解析成dict的構造參數
        # 也就是說字段名和字段值的映射會存儲在dict當中
        super(Model, self).__init__(**kw)
        
    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(r"'Model' object has no attribute '%s'" % key)

    def __setattr__(self, key, value):
        self[key] = value

    def save(self):
        fields = []
        params = []
        args = []
        for k, v in self.__mappings__.items():
            # fields存儲字段名
            fields.append(v.name)
            # params填充問號
            params.append('?')
            # 獲取字段的值
            args.append(getattr(self, k, None))
        sql = 'insert into %s (%s) values (%s)' % (self.__table__, ','.join(fields), ','.join(params))
        print('SQL: %s' % sql)
        print('ARGS: %s' % str(args))

Model當中的save方法不難看懂,但是前面的幾個方法看起來有些多餘。但實際上它們也很重要,這裡有一個關鍵信息是Model類的父類是dict,我們在構建Model的時候傳入的參數會被用來初始化一個dict。所以我們創建數據實例的時候數據的名稱和數據值的映射會被存儲在dict當中,所以我們在save方法當中才會從self的attr當中獲取字段的值。並且我們在初始化User的時候,也必須要填寫每個字段的名稱,原因就在這裏。

最後我們來運行一下:

從結果上來看,我們輸出了User這個類的插入SQL以及它的字段的值。只需要鏈接一下數據庫,我們的這個ORM框架就可以真正投入使用了。

總結

在整個ORM框架實現的過程當中,最重要的是我們對Model這個類創建了元類,但是真正應用的地方卻是在Model的子類。實際上在實際創建User類的時候,解釋器會先搜索User內部是否定義了元類,如果沒有,會上一層去往User的父類也就是Model類搜索元類,如果找到了元類,就會使用元類來創建User。相當於元類被隱形地繼承了下來,但是我們在使用子類的時候卻感知不到。

對於框架的使用者來說,也的確不需要了解框架內部的實現機制,只需要明白使用方法,照着使用就行了。雖然元類的實現和理解很複雜,但是使用起來卻很簡單,這也是它的一個顯著特點。

最後,本文的代碼示例源於廖雪峰老師的博客,向廖雪峰老師致敬。想要查看廖老師博客原文的,請點擊查看原文。

如果喜歡本文,可以的話,請點個關注,給我一點鼓勵,也方便獲取更多文章。

本文使用 mdnice 排版

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司"嚨底家"!

新北清潔公司,居家、辦公、裝潢細清專業服務

※推薦評價好的iphone維修中心